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
@@ -1,25 +1,54 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from flowyml.storage.metadata import SQLiteMetadataStore
3
+ from flowyml.core.project import ProjectManager
4
+ from flowyml.ui.backend.dependencies import get_store
5
+ from pydantic import BaseModel
3
6
 
4
7
  router = APIRouter()
5
8
 
6
9
 
7
- def get_store():
8
- return SQLiteMetadataStore()
10
+ def _iter_metadata_stores():
11
+ """Yield tuples of (project_name, store) including global and project stores."""
12
+ stores = [(None, SQLiteMetadataStore())]
13
+ try:
14
+ manager = ProjectManager()
15
+ for project_meta in manager.list_projects():
16
+ name = project_meta.get("name")
17
+ if not name:
18
+ continue
19
+ project = manager.get_project(name)
20
+ if project:
21
+ stores.append((name, project.metadata_store))
22
+ except Exception:
23
+ pass
24
+ return stores
9
25
 
10
26
 
11
27
  @router.get("/")
12
- async def list_experiments(project: str = None):
28
+ async def list_experiments(project: str | None = None):
13
29
  """List all experiments, optionally filtered by project."""
14
30
  try:
15
- store = get_store()
16
- experiments = store.list_experiments()
31
+ combined_experiments = []
32
+
33
+ for project_name, store in _iter_metadata_stores():
34
+ # Skip other projects if filtering
35
+ if project and project_name and project != project_name:
36
+ continue
37
+
38
+ store_experiments = store.list_experiments()
17
39
 
18
- # Filter by project if specified
19
- if project:
20
- experiments = [e for e in experiments if e.get("project") == project]
40
+ for exp in store_experiments:
41
+ # Add project info if not already present
42
+ if project_name and not exp.get("project"):
43
+ exp["project"] = project_name
21
44
 
22
- return {"experiments": experiments}
45
+ # Skip if filtering by project and doesn't match
46
+ if project and exp.get("project") != project:
47
+ continue
48
+
49
+ combined_experiments.append(exp)
50
+
51
+ return {"experiments": combined_experiments}
23
52
  except Exception as e:
24
53
  return {"experiments": [], "error": str(e)}
25
54
 
@@ -27,11 +56,15 @@ async def list_experiments(project: str = None):
27
56
  @router.get("/{experiment_id}")
28
57
  async def get_experiment(experiment_id: str):
29
58
  """Get experiment details."""
30
- store = get_store()
31
- experiment = store.get_experiment(experiment_id)
32
- if not experiment:
33
- raise HTTPException(status_code=404, detail="Experiment not found")
34
- return experiment
59
+ # Try to find experiment in any store
60
+ for project_name, store in _iter_metadata_stores():
61
+ experiment = store.get_experiment(experiment_id)
62
+ if experiment:
63
+ if project_name and not experiment.get("project"):
64
+ experiment["project"] = project_name
65
+ return experiment
66
+
67
+ raise HTTPException(status_code=404, detail="Experiment not found")
35
68
 
36
69
 
37
70
  @router.put("/{experiment_name}/project")
@@ -47,3 +80,53 @@ async def update_experiment_project(experiment_name: str, project_update: dict):
47
80
  return {"message": f"Updated experiment {experiment_name} to project {project_name}"}
48
81
  except Exception as e:
49
82
  raise HTTPException(status_code=500, detail=str(e))
83
+
84
+
85
+ class ExperimentCreate(BaseModel):
86
+ experiment_id: str
87
+ name: str
88
+ description: str = ""
89
+ tags: dict = {}
90
+ project: str | None = None
91
+
92
+
93
+ @router.post("/")
94
+ async def create_experiment(experiment: ExperimentCreate):
95
+ """Create or update an experiment."""
96
+ try:
97
+ store = get_store()
98
+ store.save_experiment(
99
+ experiment_id=experiment.experiment_id,
100
+ name=experiment.name,
101
+ description=experiment.description,
102
+ tags=experiment.tags,
103
+ )
104
+
105
+ if experiment.project:
106
+ store.update_experiment_project(experiment.name, experiment.project)
107
+
108
+ return {"status": "success", "experiment_id": experiment.experiment_id}
109
+ except Exception as e:
110
+ raise HTTPException(status_code=500, detail=str(e))
111
+
112
+
113
+ class ExperimentRunLog(BaseModel):
114
+ run_id: str
115
+ metrics: dict | None = None
116
+ parameters: dict | None = None
117
+
118
+
119
+ @router.post("/{experiment_id}/runs")
120
+ async def log_experiment_run(experiment_id: str, log: ExperimentRunLog):
121
+ """Log a run to an experiment."""
122
+ try:
123
+ store = get_store()
124
+ store.log_experiment_run(
125
+ experiment_id=experiment_id,
126
+ run_id=log.run_id,
127
+ metrics=log.metrics,
128
+ parameters=log.parameters,
129
+ )
130
+ return {"status": "success"}
131
+ except Exception as e:
132
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,168 @@
1
+ """Model metrics logging API endpoints."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends, Security, Query
4
+ from fastapi.security import HTTPAuthorizationCredentials
5
+ from pydantic import BaseModel, Field
6
+ from typing import Any
7
+
8
+ from flowyml.ui.backend.auth import verify_api_token, security
9
+ from flowyml.ui.backend.dependencies import get_store
10
+ from flowyml.storage.metadata import SQLiteMetadataStore
11
+ from flowyml.core.project import ProjectManager
12
+ from flowyml.utils.config import get_config
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ def require_permission(permission: str):
18
+ """Create dependency enforcing a given permission."""
19
+
20
+ async def _verify(credentials: HTTPAuthorizationCredentials = Security(security)):
21
+ return await verify_api_token(credentials, required_permission=permission)
22
+
23
+ return _verify
24
+
25
+
26
+ def get_project_manager() -> ProjectManager:
27
+ """Get a project manager rooted at configured projects dir."""
28
+ config = get_config()
29
+ return ProjectManager(str(config.projects_dir))
30
+
31
+
32
+ def get_global_store() -> SQLiteMetadataStore:
33
+ """Metadata store for shared metrics."""
34
+ config = get_config()
35
+ return SQLiteMetadataStore(str(config.metadata_db))
36
+
37
+
38
+ class MetricsLogRequest(BaseModel):
39
+ """Payload for logging production model metrics."""
40
+
41
+ project: str = Field(..., description="Project identifier")
42
+ model_name: str = Field(..., description="Name of the model emitting metrics")
43
+ metrics: dict[str, float] = Field(..., description="Dictionary of metric_name -> value")
44
+ run_id: str | None = Field(None, description="Related run identifier (optional)")
45
+ environment: str | None = Field(None, description="Environment label (e.g., prod, staging)")
46
+ tags: dict[str, Any] | None = Field(default_factory=dict, description="Optional metadata tags")
47
+
48
+
49
+ class MetricLog(BaseModel):
50
+ run_id: str
51
+ name: str
52
+ value: float
53
+ step: int = 0
54
+
55
+
56
+ @router.post("/")
57
+ async def log_metric(metric: MetricLog):
58
+ """Log a single metric."""
59
+ try:
60
+ store = get_global_store()
61
+ store.save_metric(metric.run_id, metric.name, metric.value, metric.step)
62
+ return {"status": "success"}
63
+ except Exception as e:
64
+ raise HTTPException(status_code=500, detail=str(e))
65
+
66
+
67
+ @router.post("/log")
68
+ async def log_model_metrics(
69
+ payload: MetricsLogRequest,
70
+ token_data: dict = Depends(require_permission("write")),
71
+ ):
72
+ """Log production metrics for a model.
73
+
74
+ Requires tokens with the `write` permission. Project-scoped tokens
75
+ may only submit metrics for their project.
76
+ """
77
+ if token_data.get("project") and token_data["project"] != payload.project:
78
+ raise HTTPException(
79
+ status_code=403,
80
+ detail=f"Token is scoped to project '{token_data['project']}'",
81
+ )
82
+
83
+ if not payload.metrics:
84
+ raise HTTPException(status_code=400, detail="metrics dictionary cannot be empty")
85
+
86
+ numeric_metrics = {}
87
+ for name, value in payload.metrics.items():
88
+ try:
89
+ numeric_metrics[name] = float(value)
90
+ except (TypeError, ValueError):
91
+ raise HTTPException(
92
+ status_code=400,
93
+ detail=f"Metric '{name}' must be numeric.",
94
+ )
95
+
96
+ project_manager = get_project_manager()
97
+ project = project_manager.get_project(payload.project)
98
+ if not project:
99
+ project = project_manager.create_project(payload.project)
100
+
101
+ shared_store = get_global_store()
102
+ shared_store.log_model_metrics(
103
+ project=payload.project,
104
+ model_name=payload.model_name,
105
+ metrics=numeric_metrics,
106
+ run_id=payload.run_id,
107
+ environment=payload.environment,
108
+ tags=payload.tags,
109
+ )
110
+
111
+ project.log_model_metrics(
112
+ model_name=payload.model_name,
113
+ metrics=numeric_metrics,
114
+ run_id=payload.run_id,
115
+ environment=payload.environment,
116
+ tags=payload.tags,
117
+ )
118
+
119
+ return {
120
+ "project": payload.project,
121
+ "model_name": payload.model_name,
122
+ "logged_metrics": list(numeric_metrics.keys()),
123
+ "message": "Metrics logged successfully",
124
+ }
125
+
126
+
127
+ @router.get("")
128
+ async def list_model_metrics(
129
+ project: str | None = Query(default=None, description="Filter by project"),
130
+ model_name: str | None = Query(default=None, description="Filter by model"),
131
+ limit: int = Query(default=100, ge=1, le=500),
132
+ token_data: dict = Depends(require_permission("read")),
133
+ ):
134
+ """Retrieve the latest logged model metrics."""
135
+ if token_data.get("project"):
136
+ if project and token_data["project"] != project:
137
+ raise HTTPException(
138
+ status_code=403,
139
+ detail=f"Token is scoped to project '{token_data['project']}'",
140
+ )
141
+ project = token_data["project"]
142
+
143
+ store: SQLiteMetadataStore
144
+ if project:
145
+ project_manager = get_project_manager()
146
+ project_obj = project_manager.get_project(project)
147
+ if not project_obj:
148
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
149
+ store = project_obj.metadata_store
150
+ else:
151
+ store = get_global_store()
152
+
153
+ records = store.list_model_metrics(project=project, model_name=model_name, limit=limit)
154
+ return {"metrics": records}
155
+
156
+
157
+ @router.get("/observability/orchestrator")
158
+ async def get_orchestrator_metrics():
159
+ """Get orchestrator-level performance metrics."""
160
+ store = get_store()
161
+ return store.get_orchestrator_metrics(days=30)
162
+
163
+
164
+ @router.get("/observability/cache")
165
+ async def get_cache_metrics():
166
+ """Get cache performance metrics."""
167
+ store = get_store()
168
+ return store.get_cache_metrics(days=30)
@@ -1,25 +1,74 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from pydantic import BaseModel
3
- from flowyml.storage.metadata import SQLiteMetadataStore
4
- from flowyml.utils.config import get_config
3
+ from flowyml.ui.backend.dependencies import get_store
4
+ from flowyml.core.project import ProjectManager
5
5
 
6
6
  router = APIRouter()
7
7
 
8
8
 
9
- def get_store():
10
- get_config()
11
- # Assuming default path or from config
12
- # The SQLiteMetadataStore defaults to .flowyml/metadata.db which is what we want for now
13
- return SQLiteMetadataStore()
9
+ def _iter_metadata_stores():
10
+ """Yield tuples of (project_name, store) including global and project stores."""
11
+ stores = [(None, get_store())]
12
+ try:
13
+ manager = ProjectManager()
14
+ for project_meta in manager.list_projects():
15
+ name = project_meta.get("name")
16
+ if not name:
17
+ continue
18
+ project = manager.get_project(name)
19
+ if project:
20
+ stores.append((name, project.metadata_store))
21
+ except Exception:
22
+ pass
23
+ return stores
14
24
 
15
25
 
16
26
  @router.get("/")
17
- async def list_pipelines(project: str = None):
18
- """List all unique pipelines, optionally filtered by project."""
27
+ async def list_pipelines(project: str | None = None, limit: int = 100):
28
+ """List all unique pipelines with details, optionally filtered by project."""
19
29
  try:
20
- store = get_store()
21
- pipelines = store.list_pipelines(project=project)
22
- return {"pipelines": pipelines}
30
+ pipeline_map = {} # pipeline_name -> data
31
+
32
+ for project_name, store in _iter_metadata_stores():
33
+ # Skip other projects if filtering
34
+ if project and project_name and project != project_name:
35
+ continue
36
+
37
+ # Get pipeline names from this store
38
+ store_pipeline_names = store.list_pipelines()
39
+
40
+ for name in store_pipeline_names:
41
+ # Get runs for this pipeline
42
+ filters = {"pipeline_name": name}
43
+ runs = store.query(**filters)
44
+
45
+ if not runs:
46
+ continue
47
+
48
+ last_run = runs[0]
49
+ run_project = last_run.get("project") or project_name
50
+
51
+ # Skip if filtering by project and doesn't match
52
+ if project and run_project != project:
53
+ continue
54
+
55
+ # Use composite key if we already have this pipeline from another project
56
+ key = f"{name}:{run_project}" if run_project else name
57
+
58
+ if key not in pipeline_map:
59
+ pipeline_map[key] = {
60
+ "name": name,
61
+ "created": last_run.get("start_time"),
62
+ "version": last_run.get("git_sha", "latest")[:7] if last_run.get("git_sha") else "1.0",
63
+ "status": last_run.get("status", "unknown"),
64
+ "run_count": len(runs),
65
+ "last_run_id": last_run.get("run_id"),
66
+ "project": run_project,
67
+ }
68
+
69
+ # Return list of pipelines
70
+ enriched_pipelines = list(pipeline_map.values())[:limit]
71
+ return {"pipelines": enriched_pipelines}
23
72
  except Exception as e:
24
73
  raise HTTPException(status_code=500, detail=str(e))
25
74
 
@@ -108,3 +157,19 @@ async def update_pipeline_project(pipeline_name: str, update: ProjectUpdate):
108
157
  return {"status": "success", "project": update.project_name}
109
158
  except Exception as e:
110
159
  raise HTTPException(status_code=500, detail=str(e))
160
+
161
+
162
+ class PipelineDefinitionCreate(BaseModel):
163
+ pipeline_name: str
164
+ definition: dict
165
+
166
+
167
+ @router.post("/")
168
+ async def save_pipeline_definition(data: PipelineDefinitionCreate):
169
+ """Save a pipeline definition."""
170
+ try:
171
+ store = get_store()
172
+ store.save_pipeline_definition(data.pipeline_name, data.definition)
173
+ return {"status": "success", "pipeline_name": data.pipeline_name}
174
+ except Exception as e:
175
+ raise HTTPException(status_code=500, detail=str(e))
@@ -1,17 +1,23 @@
1
- from fastapi import APIRouter, HTTPException
1
+ from fastapi import APIRouter, HTTPException, Depends
2
2
  from flowyml.core.project import ProjectManager
3
+ from flowyml.utils.config import get_config
3
4
  from pydantic import BaseModel
4
5
 
5
6
  router = APIRouter()
6
- manager = ProjectManager()
7
+
8
+
9
+ def get_projects_manager() -> ProjectManager:
10
+ """Instantiate a ProjectManager bound to the current config."""
11
+ config = get_config()
12
+ return ProjectManager(str(config.projects_dir))
7
13
 
8
14
 
9
15
  @router.get("/")
10
- async def list_projects():
16
+ async def list_projects(manager: ProjectManager = Depends(get_projects_manager)):
11
17
  """List all projects."""
12
18
  try:
13
19
  projects = manager.list_projects()
14
- return projects
20
+ return {"projects": projects}
15
21
  except Exception as e:
16
22
  raise HTTPException(status_code=500, detail=str(e))
17
23
 
@@ -22,7 +28,7 @@ class ProjectCreate(BaseModel):
22
28
 
23
29
 
24
30
  @router.post("/")
25
- async def create_project(project: ProjectCreate):
31
+ async def create_project(project: ProjectCreate, manager: ProjectManager = Depends(get_projects_manager)):
26
32
  """Create a new project."""
27
33
  created_project = manager.create_project(project.name, project.description)
28
34
  return {
@@ -33,7 +39,7 @@ async def create_project(project: ProjectCreate):
33
39
 
34
40
 
35
41
  @router.get("/{project_name}")
36
- async def get_project(project_name: str):
42
+ async def get_project(project_name: str, manager: ProjectManager = Depends(get_projects_manager)):
37
43
  """Get project details."""
38
44
  project = manager.get_project(project_name)
39
45
  if not project:
@@ -53,6 +59,7 @@ async def get_project_runs(
53
59
  project_name: str,
54
60
  pipeline_name: str | None = None,
55
61
  limit: int = 100,
62
+ manager: ProjectManager = Depends(get_projects_manager),
56
63
  ):
57
64
  """Get runs for a project."""
58
65
  project = manager.get_project(project_name)
@@ -68,6 +75,7 @@ async def get_project_artifacts(
68
75
  project_name: str,
69
76
  artifact_type: str | None = None,
70
77
  limit: int = 100,
78
+ manager: ProjectManager = Depends(get_projects_manager),
71
79
  ):
72
80
  """Get artifacts for a project."""
73
81
  project = manager.get_project(project_name)
@@ -78,8 +86,26 @@ async def get_project_artifacts(
78
86
  return artifacts
79
87
 
80
88
 
89
+ @router.get("/{project_name}/metrics")
90
+ async def get_project_metrics(
91
+ project_name: str,
92
+ model_name: str | None = None,
93
+ limit: int = 100,
94
+ manager: ProjectManager = Depends(get_projects_manager),
95
+ ):
96
+ """Get logged production metrics for a project."""
97
+ project = manager.get_project(project_name)
98
+ if not project:
99
+ raise HTTPException(status_code=404, detail="Project not found")
100
+
101
+ return {
102
+ "project": project_name,
103
+ "metrics": project.list_model_metrics(model_name=model_name, limit=limit),
104
+ }
105
+
106
+
81
107
  @router.delete("/{project_name}")
82
- async def delete_project(project_name: str):
108
+ async def delete_project(project_name: str, manager: ProjectManager = Depends(get_projects_manager)):
83
109
  """Delete a project."""
84
110
  manager.delete_project(project_name, confirm=True)
85
111
  return {"deleted": True}