flowyml 1.2.0__py3-none-any.whl → 1.3.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 +22 -5
- flowyml/core/retry_policy.py +80 -0
- 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/components.py +24 -2
- flowyml/stacks/gcp.py +158 -11
- flowyml/stacks/local.py +5 -0
- flowyml/storage/artifacts.py +15 -5
- flowyml/storage/materializers/__init__.py +2 -0
- flowyml/storage/materializers/cloudpickle.py +74 -0
- flowyml/storage/metadata.py +166 -5
- flowyml/ui/backend/main.py +41 -1
- flowyml/ui/backend/routers/assets.py +356 -15
- flowyml/ui/backend/routers/client.py +46 -0
- flowyml/ui/backend/routers/execution.py +13 -2
- flowyml/ui/backend/routers/experiments.py +48 -12
- flowyml/ui/backend/routers/metrics.py +213 -0
- flowyml/ui/backend/routers/pipelines.py +63 -7
- flowyml/ui/backend/routers/projects.py +33 -7
- flowyml/ui/backend/routers/runs.py +150 -8
- 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.3.0.dist-info}/METADATA +42 -4
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/RECORD +89 -52
- {flowyml-1.2.0.dist-info → flowyml-1.3.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 → flowyml-1.3.0.dist-info}/WHEEL +0 -0
- {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from fastapi.responses import FileResponse
|
|
3
|
+
from pydantic import BaseModel
|
|
2
4
|
from flowyml.storage.metadata import SQLiteMetadataStore
|
|
5
|
+
from flowyml.core.project import ProjectManager
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from pathlib import Path
|
|
3
8
|
|
|
4
9
|
router = APIRouter()
|
|
5
10
|
|
|
@@ -8,38 +13,374 @@ def get_store():
|
|
|
8
13
|
return SQLiteMetadataStore()
|
|
9
14
|
|
|
10
15
|
|
|
16
|
+
def _iter_metadata_stores():
|
|
17
|
+
stores = [(None, SQLiteMetadataStore())]
|
|
18
|
+
try:
|
|
19
|
+
manager = ProjectManager()
|
|
20
|
+
for project_meta in manager.list_projects():
|
|
21
|
+
name = project_meta.get("name")
|
|
22
|
+
if not name:
|
|
23
|
+
continue
|
|
24
|
+
project = manager.get_project(name)
|
|
25
|
+
if project:
|
|
26
|
+
stores.append((name, project.metadata_store))
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return stores
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dedupe_assets(assets):
|
|
33
|
+
dedup = {}
|
|
34
|
+
for asset, project_name in assets:
|
|
35
|
+
artifact_id = asset.get("artifact_id") or f"{project_name}:{len(dedup)}"
|
|
36
|
+
if artifact_id in dedup:
|
|
37
|
+
continue
|
|
38
|
+
entry = dict(asset)
|
|
39
|
+
if project_name and not entry.get("project"):
|
|
40
|
+
entry["project"] = project_name
|
|
41
|
+
dedup[artifact_id] = entry
|
|
42
|
+
return list(dedup.values())
|
|
43
|
+
|
|
44
|
+
|
|
11
45
|
@router.get("/")
|
|
12
46
|
async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = None, project: str = None):
|
|
13
47
|
"""List all assets, optionally filtered by project."""
|
|
14
48
|
try:
|
|
15
|
-
|
|
49
|
+
combined = []
|
|
50
|
+
for project_name, store in _iter_metadata_stores():
|
|
51
|
+
if project and project_name and project != project_name:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
filters = {}
|
|
55
|
+
if asset_type:
|
|
56
|
+
filters["type"] = asset_type
|
|
57
|
+
if run_id:
|
|
58
|
+
filters["run_id"] = run_id
|
|
16
59
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
filters["type"] = asset_type
|
|
21
|
-
if run_id:
|
|
22
|
-
filters["run_id"] = run_id
|
|
60
|
+
assets = store.list_assets(limit=limit, **filters)
|
|
61
|
+
for asset in assets:
|
|
62
|
+
combined.append((asset, project_name))
|
|
23
63
|
|
|
24
|
-
assets =
|
|
64
|
+
assets = _dedupe_assets(combined)
|
|
25
65
|
|
|
26
|
-
# Filter by project if specified
|
|
27
66
|
if project:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
assets = [a for a in assets if a.get("run_id") in project_run_ids]
|
|
67
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
68
|
+
|
|
69
|
+
assets = assets[:limit]
|
|
32
70
|
|
|
33
71
|
return {"assets": assets}
|
|
34
72
|
except Exception as e:
|
|
35
73
|
return {"assets": [], "error": str(e)}
|
|
36
74
|
|
|
37
75
|
|
|
76
|
+
@router.get("/stats")
|
|
77
|
+
async def get_asset_stats(project: Optional[str] = None):
|
|
78
|
+
"""Get statistics about assets for the dashboard."""
|
|
79
|
+
try:
|
|
80
|
+
combined_assets = []
|
|
81
|
+
for project_name, store in _iter_metadata_stores():
|
|
82
|
+
if project and project_name and project != project_name:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
assets = store.list_assets(limit=1000)
|
|
86
|
+
for asset in assets:
|
|
87
|
+
combined_assets.append((asset, project_name))
|
|
88
|
+
|
|
89
|
+
assets = _dedupe_assets(combined_assets)
|
|
90
|
+
|
|
91
|
+
if project:
|
|
92
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
93
|
+
|
|
94
|
+
# Calculate statistics
|
|
95
|
+
total_assets = len(assets)
|
|
96
|
+
|
|
97
|
+
# Count by type
|
|
98
|
+
type_counts = {}
|
|
99
|
+
for asset in assets:
|
|
100
|
+
asset_type = asset.get("type", "unknown")
|
|
101
|
+
type_counts[asset_type] = type_counts.get(asset_type, 0) + 1
|
|
102
|
+
|
|
103
|
+
# Calculate total storage (if size info available)
|
|
104
|
+
total_storage = sum(asset.get("size_bytes", 0) for asset in assets)
|
|
105
|
+
|
|
106
|
+
# Get recent assets (last 10)
|
|
107
|
+
sorted_assets = sorted(
|
|
108
|
+
assets,
|
|
109
|
+
key=lambda x: x.get("created_at", ""),
|
|
110
|
+
reverse=True,
|
|
111
|
+
)
|
|
112
|
+
recent_assets = sorted_assets[:10]
|
|
113
|
+
|
|
114
|
+
# Count by project
|
|
115
|
+
project_counts = {}
|
|
116
|
+
for asset in assets:
|
|
117
|
+
proj = asset.get("project", "default")
|
|
118
|
+
project_counts[proj] = project_counts.get(proj, 0) + 1
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"total_assets": total_assets,
|
|
122
|
+
"by_type": type_counts,
|
|
123
|
+
"total_storage_bytes": total_storage,
|
|
124
|
+
"recent_assets": recent_assets,
|
|
125
|
+
"by_project": project_counts,
|
|
126
|
+
}
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.get("/search")
|
|
132
|
+
async def search_assets(q: str, limit: int = 50, project: Optional[str] = None):
|
|
133
|
+
"""Search assets by name or properties."""
|
|
134
|
+
try:
|
|
135
|
+
combined_assets = []
|
|
136
|
+
for project_name, store in _iter_metadata_stores():
|
|
137
|
+
if project and project_name and project != project_name:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
assets = store.list_assets(limit=1000)
|
|
141
|
+
for asset in assets:
|
|
142
|
+
combined_assets.append((asset, project_name))
|
|
143
|
+
|
|
144
|
+
assets = _dedupe_assets(combined_assets)
|
|
145
|
+
|
|
146
|
+
if project:
|
|
147
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
148
|
+
|
|
149
|
+
# Simple fuzzy search in name, type, and step
|
|
150
|
+
query_lower = q.lower()
|
|
151
|
+
matching_assets = []
|
|
152
|
+
|
|
153
|
+
for asset in assets:
|
|
154
|
+
name = asset.get("name", "").lower()
|
|
155
|
+
asset_type = asset.get("type", "").lower()
|
|
156
|
+
step = asset.get("step", "").lower()
|
|
157
|
+
|
|
158
|
+
# Check if query matches any field
|
|
159
|
+
if query_lower in name or query_lower in asset_type or query_lower in step:
|
|
160
|
+
matching_assets.append(asset)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"assets": matching_assets[:limit],
|
|
164
|
+
"total": len(matching_assets),
|
|
165
|
+
"query": q,
|
|
166
|
+
}
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise HTTPException(status_code=500, detail=f"Failed to search: {str(e)}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.get("/lineage")
|
|
172
|
+
async def get_asset_lineage(
|
|
173
|
+
asset_id: Optional[str] = None,
|
|
174
|
+
project: Optional[str] = None,
|
|
175
|
+
depth: int = 3,
|
|
176
|
+
):
|
|
177
|
+
"""
|
|
178
|
+
Get lineage graph for artifacts.
|
|
179
|
+
|
|
180
|
+
Returns nodes (artifacts, runs, pipelines) and edges (relationships).
|
|
181
|
+
Can be scoped to a specific asset or all assets in a project.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
store = get_store()
|
|
185
|
+
nodes = []
|
|
186
|
+
edges = []
|
|
187
|
+
visited_artifacts = set()
|
|
188
|
+
visited_runs = set()
|
|
189
|
+
|
|
190
|
+
# Get starting artifacts
|
|
191
|
+
if asset_id:
|
|
192
|
+
artifact = store.load_artifact(asset_id)
|
|
193
|
+
if not artifact:
|
|
194
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
195
|
+
starting_artifacts = [artifact]
|
|
196
|
+
elif project:
|
|
197
|
+
# Get all artifacts for project
|
|
198
|
+
all_runs = store.list_runs(limit=1000)
|
|
199
|
+
project_run_ids = {r.get("run_id") for r in all_runs if r.get("project") == project}
|
|
200
|
+
all_artifacts = store.list_assets(limit=1000)
|
|
201
|
+
starting_artifacts = [a for a in all_artifacts if a.get("run_id") in project_run_ids]
|
|
202
|
+
else:
|
|
203
|
+
# Get recent artifacts
|
|
204
|
+
starting_artifacts = store.list_assets(limit=50)
|
|
205
|
+
|
|
206
|
+
# Build graph
|
|
207
|
+
for artifact in starting_artifacts:
|
|
208
|
+
artifact_id = artifact.get("artifact_id")
|
|
209
|
+
if not artifact_id or artifact_id in visited_artifacts:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
visited_artifacts.add(artifact_id)
|
|
213
|
+
|
|
214
|
+
# Add artifact node
|
|
215
|
+
nodes.append(
|
|
216
|
+
{
|
|
217
|
+
"id": artifact_id,
|
|
218
|
+
"type": "artifact",
|
|
219
|
+
"label": artifact.get("name", artifact_id),
|
|
220
|
+
"artifact_type": artifact.get("type", "unknown"),
|
|
221
|
+
"metadata": {
|
|
222
|
+
"created_at": artifact.get("created_at"),
|
|
223
|
+
"properties": artifact.get("properties", {}),
|
|
224
|
+
"size": artifact.get("size"),
|
|
225
|
+
"run_id": artifact.get("run_id"),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Add relationship to run
|
|
231
|
+
run_id = artifact.get("run_id")
|
|
232
|
+
if run_id and run_id not in visited_runs:
|
|
233
|
+
visited_runs.add(run_id)
|
|
234
|
+
run = store.load_run(run_id)
|
|
235
|
+
|
|
236
|
+
if run:
|
|
237
|
+
# Add run node
|
|
238
|
+
nodes.append(
|
|
239
|
+
{
|
|
240
|
+
"id": run_id,
|
|
241
|
+
"type": "run",
|
|
242
|
+
"label": run.get("run_name", run_id[:8]),
|
|
243
|
+
"metadata": {
|
|
244
|
+
"pipeline_name": run.get("pipeline_name"),
|
|
245
|
+
"status": run.get("status"),
|
|
246
|
+
"project": run.get("project"),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Add edge: run produces artifact
|
|
252
|
+
edges.append(
|
|
253
|
+
{
|
|
254
|
+
"id": f"{run_id}->{artifact_id}",
|
|
255
|
+
"source": run_id,
|
|
256
|
+
"target": artifact_id,
|
|
257
|
+
"type": "produces",
|
|
258
|
+
"label": artifact.get("step", ""),
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Find input artifacts (from DAG/steps metadata)
|
|
263
|
+
metadata = run.get("metadata", {})
|
|
264
|
+
# dag = metadata.get("dag", {})
|
|
265
|
+
steps = metadata.get("steps", {})
|
|
266
|
+
|
|
267
|
+
# Get step that produced this artifact
|
|
268
|
+
artifact_step = artifact.get("step")
|
|
269
|
+
if artifact_step and artifact_step in steps:
|
|
270
|
+
step_info = steps.get(artifact_step, {})
|
|
271
|
+
inputs = step_info.get("inputs", [])
|
|
272
|
+
|
|
273
|
+
# Find artifacts that were inputs to this step
|
|
274
|
+
for input_name in inputs:
|
|
275
|
+
# Search for artifact with this name from the same run
|
|
276
|
+
input_artifacts = [
|
|
277
|
+
a for a in all_artifacts if a.get("name") == input_name and a.get("run_id") == run_id
|
|
278
|
+
]
|
|
279
|
+
for input_artifact in input_artifacts:
|
|
280
|
+
input_id = input_artifact.get("artifact_id")
|
|
281
|
+
if input_id and input_id not in visited_artifacts:
|
|
282
|
+
visited_artifacts.add(input_id)
|
|
283
|
+
nodes.append(
|
|
284
|
+
{
|
|
285
|
+
"id": input_id,
|
|
286
|
+
"type": "artifact",
|
|
287
|
+
"label": input_artifact.get("name", input_id),
|
|
288
|
+
"artifact_type": input_artifact.get("type", "unknown"),
|
|
289
|
+
"metadata": {
|
|
290
|
+
"created_at": input_artifact.get("created_at"),
|
|
291
|
+
"properties": input_artifact.get("properties", {}),
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Add edge: input artifact consumed by run
|
|
297
|
+
edges.append(
|
|
298
|
+
{
|
|
299
|
+
"id": f"{input_id}->{run_id}",
|
|
300
|
+
"source": input_id,
|
|
301
|
+
"target": run_id,
|
|
302
|
+
"type": "consumes",
|
|
303
|
+
"label": artifact_step,
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
"nodes": nodes,
|
|
309
|
+
"edges": edges,
|
|
310
|
+
"metadata": {
|
|
311
|
+
"total_artifacts": len([n for n in nodes if n["type"] == "artifact"]),
|
|
312
|
+
"total_runs": len([n for n in nodes if n["type"] == "run"]),
|
|
313
|
+
"depth_used": depth,
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
except HTTPException:
|
|
318
|
+
raise
|
|
319
|
+
except Exception as e:
|
|
320
|
+
raise HTTPException(status_code=500, detail=f"Failed to build lineage: {str(e)}")
|
|
321
|
+
|
|
322
|
+
|
|
38
323
|
@router.get("/{artifact_id}")
|
|
39
324
|
async def get_asset(artifact_id: str):
|
|
40
325
|
"""Get details for a specific asset."""
|
|
41
|
-
|
|
42
|
-
asset = store.load_artifact(artifact_id)
|
|
326
|
+
asset = _find_asset(artifact_id)
|
|
43
327
|
if not asset:
|
|
44
328
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
45
329
|
return asset
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.get("/{artifact_id}/download")
|
|
333
|
+
async def download_asset(artifact_id: str):
|
|
334
|
+
"""Download the artifact file referenced by metadata."""
|
|
335
|
+
asset, _ = _find_asset_with_store(artifact_id)
|
|
336
|
+
if not asset:
|
|
337
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
338
|
+
|
|
339
|
+
artifact_path = asset.get("path")
|
|
340
|
+
if not artifact_path:
|
|
341
|
+
raise HTTPException(status_code=404, detail="Artifact path not available")
|
|
342
|
+
|
|
343
|
+
file_path = Path(artifact_path)
|
|
344
|
+
if not file_path.exists():
|
|
345
|
+
raise HTTPException(status_code=404, detail="Artifact file not found on disk")
|
|
346
|
+
|
|
347
|
+
return FileResponse(
|
|
348
|
+
path=file_path,
|
|
349
|
+
filename=file_path.name,
|
|
350
|
+
media_type="application/octet-stream",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class ProjectUpdate(BaseModel):
|
|
355
|
+
project_name: str
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@router.put("/{artifact_id}/project")
|
|
359
|
+
async def update_asset_project(artifact_id: str, update: ProjectUpdate):
|
|
360
|
+
"""Update the project for an asset."""
|
|
361
|
+
try:
|
|
362
|
+
_, store = _find_asset_with_store(artifact_id)
|
|
363
|
+
if not store:
|
|
364
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
365
|
+
|
|
366
|
+
store.update_artifact_project(artifact_id, update.project_name)
|
|
367
|
+
return {"status": "success", "project": update.project_name}
|
|
368
|
+
except HTTPException:
|
|
369
|
+
raise
|
|
370
|
+
except Exception as e:
|
|
371
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _find_asset(artifact_id: str):
|
|
375
|
+
asset, _ = _find_asset_with_store(artifact_id)
|
|
376
|
+
return asset
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _find_asset_with_store(artifact_id: str):
|
|
380
|
+
for project_name, store in _iter_metadata_stores():
|
|
381
|
+
asset = store.load_artifact(artifact_id)
|
|
382
|
+
if asset:
|
|
383
|
+
if project_name and not asset.get("project"):
|
|
384
|
+
asset["project"] = project_name
|
|
385
|
+
return asset, store
|
|
386
|
+
return None, None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from flowyml.monitoring.alerts import alert_manager, AlertLevel
|
|
5
|
+
|
|
6
|
+
router = APIRouter()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientError(BaseModel):
|
|
10
|
+
message: str
|
|
11
|
+
stack: Optional[str] = None
|
|
12
|
+
component_stack: Optional[str] = None
|
|
13
|
+
url: Optional[str] = None
|
|
14
|
+
user_agent: Optional[str] = None
|
|
15
|
+
additional_info: Optional[dict[str, Any]] = 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
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from fastapi import APIRouter, HTTPException
|
|
2
2
|
from flowyml.storage.metadata import SQLiteMetadataStore
|
|
3
|
+
from flowyml.core.project import ProjectManager
|
|
4
|
+
from typing import Optional
|
|
3
5
|
|
|
4
6
|
router = APIRouter()
|
|
5
7
|
|
|
@@ -8,18 +10,48 @@ def get_store():
|
|
|
8
10
|
return SQLiteMetadataStore()
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
def _iter_metadata_stores():
|
|
14
|
+
"""Yield tuples of (project_name, store) including global and project stores."""
|
|
15
|
+
stores = [(None, SQLiteMetadataStore())]
|
|
16
|
+
try:
|
|
17
|
+
manager = ProjectManager()
|
|
18
|
+
for project_meta in manager.list_projects():
|
|
19
|
+
name = project_meta.get("name")
|
|
20
|
+
if not name:
|
|
21
|
+
continue
|
|
22
|
+
project = manager.get_project(name)
|
|
23
|
+
if project:
|
|
24
|
+
stores.append((name, project.metadata_store))
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return stores
|
|
28
|
+
|
|
29
|
+
|
|
11
30
|
@router.get("/")
|
|
12
|
-
async def list_experiments(project: str = None):
|
|
31
|
+
async def list_experiments(project: Optional[str] = None):
|
|
13
32
|
"""List all experiments, optionally filtered by project."""
|
|
14
33
|
try:
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
combined_experiments = []
|
|
35
|
+
|
|
36
|
+
for project_name, store in _iter_metadata_stores():
|
|
37
|
+
# Skip other projects if filtering
|
|
38
|
+
if project and project_name and project != project_name:
|
|
39
|
+
continue
|
|
17
40
|
|
|
18
|
-
|
|
19
|
-
if project:
|
|
20
|
-
experiments = [e for e in experiments if e.get("project") == project]
|
|
41
|
+
store_experiments = store.list_experiments()
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
for exp in store_experiments:
|
|
44
|
+
# Add project info if not already present
|
|
45
|
+
if project_name and not exp.get("project"):
|
|
46
|
+
exp["project"] = project_name
|
|
47
|
+
|
|
48
|
+
# Skip if filtering by project and doesn't match
|
|
49
|
+
if project and exp.get("project") != project:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
combined_experiments.append(exp)
|
|
53
|
+
|
|
54
|
+
return {"experiments": combined_experiments}
|
|
23
55
|
except Exception as e:
|
|
24
56
|
return {"experiments": [], "error": str(e)}
|
|
25
57
|
|
|
@@ -27,11 +59,15 @@ async def list_experiments(project: str = None):
|
|
|
27
59
|
@router.get("/{experiment_id}")
|
|
28
60
|
async def get_experiment(experiment_id: str):
|
|
29
61
|
"""Get experiment details."""
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
62
|
+
# Try to find experiment in any store
|
|
63
|
+
for project_name, store in _iter_metadata_stores():
|
|
64
|
+
experiment = store.get_experiment(experiment_id)
|
|
65
|
+
if experiment:
|
|
66
|
+
if project_name and not experiment.get("project"):
|
|
67
|
+
experiment["project"] = project_name
|
|
68
|
+
return experiment
|
|
69
|
+
|
|
70
|
+
raise HTTPException(status_code=404, detail="Experiment not found")
|
|
35
71
|
|
|
36
72
|
|
|
37
73
|
@router.put("/{experiment_name}/project")
|