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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Cloudpickle materializer for robust Python object serialization."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from flowyml.storage.materializers.base import BaseMaterializer
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import cloudpickle
|
|
10
|
+
|
|
11
|
+
CLOUDPICKLE_AVAILABLE = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
CLOUDPICKLE_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if CLOUDPICKLE_AVAILABLE:
|
|
17
|
+
|
|
18
|
+
class CloudpickleMaterializer(BaseMaterializer):
|
|
19
|
+
"""Materializer using cloudpickle for robust serialization."""
|
|
20
|
+
|
|
21
|
+
def save(self, obj: Any, path: Path) -> None:
|
|
22
|
+
"""Save object using cloudpickle.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
obj: Object to save
|
|
26
|
+
path: Directory path where object should be saved
|
|
27
|
+
"""
|
|
28
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
file_path = path / "data.cloudpickle"
|
|
31
|
+
with open(file_path, "wb") as f:
|
|
32
|
+
cloudpickle.dump(obj, f)
|
|
33
|
+
|
|
34
|
+
def load(self, path: Path) -> Any:
|
|
35
|
+
"""Load object using cloudpickle.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
path: Directory path from which to load object
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Loaded object
|
|
42
|
+
"""
|
|
43
|
+
file_path = path / "data.cloudpickle"
|
|
44
|
+
if not file_path.exists():
|
|
45
|
+
raise FileNotFoundError(f"Cloudpickle file not found at {file_path}")
|
|
46
|
+
|
|
47
|
+
with open(file_path, "rb") as f:
|
|
48
|
+
return cloudpickle.load(f)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def supported_types(cls) -> list[type]:
|
|
52
|
+
"""Return types supported by this materializer.
|
|
53
|
+
|
|
54
|
+
Cloudpickle can handle almost anything, so we expose ``object`` here
|
|
55
|
+
for consumers that explicitly opt into this materializer as a
|
|
56
|
+
fallback, but it is not registered automatically with the global
|
|
57
|
+
registry to avoid hijacking unknown types.
|
|
58
|
+
"""
|
|
59
|
+
return [object]
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
|
|
63
|
+
class CloudpickleMaterializer(BaseMaterializer):
|
|
64
|
+
"""Placeholder when cloudpickle is not available."""
|
|
65
|
+
|
|
66
|
+
def save(self, obj: Any, path: Path) -> None:
|
|
67
|
+
raise ImportError("cloudpickle is not installed")
|
|
68
|
+
|
|
69
|
+
def load(self, path: Path) -> Any:
|
|
70
|
+
raise ImportError("cloudpickle is not installed")
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def supported_types(cls) -> list[type]:
|
|
74
|
+
return []
|
flowyml/storage/metadata.py
CHANGED
|
@@ -129,6 +129,23 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
129
129
|
""",
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
+
# Model metrics table
|
|
133
|
+
cursor.execute(
|
|
134
|
+
"""
|
|
135
|
+
CREATE TABLE IF NOT EXISTS model_metrics (
|
|
136
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
+
project TEXT,
|
|
138
|
+
model_name TEXT,
|
|
139
|
+
run_id TEXT,
|
|
140
|
+
metric_name TEXT,
|
|
141
|
+
metric_value REAL,
|
|
142
|
+
environment TEXT,
|
|
143
|
+
tags TEXT,
|
|
144
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
145
|
+
)
|
|
146
|
+
""",
|
|
147
|
+
)
|
|
148
|
+
|
|
132
149
|
# Parameters table
|
|
133
150
|
cursor.execute(
|
|
134
151
|
"""
|
|
@@ -558,6 +575,91 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
558
575
|
|
|
559
576
|
return [{"name": row[0], "value": row[1], "step": row[2], "timestamp": row[3]} for row in rows]
|
|
560
577
|
|
|
578
|
+
def log_model_metrics(
|
|
579
|
+
self,
|
|
580
|
+
project: str,
|
|
581
|
+
model_name: str,
|
|
582
|
+
metrics: dict[str, float],
|
|
583
|
+
run_id: str | None = None,
|
|
584
|
+
environment: str | None = None,
|
|
585
|
+
tags: dict | None = None,
|
|
586
|
+
) -> None:
|
|
587
|
+
"""Log production model metrics independent of pipeline runs."""
|
|
588
|
+
if not metrics:
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
conn = sqlite3.connect(self.db_path)
|
|
592
|
+
cursor = conn.cursor()
|
|
593
|
+
tags_json = json.dumps(tags or {})
|
|
594
|
+
|
|
595
|
+
for metric_name, value in metrics.items():
|
|
596
|
+
try:
|
|
597
|
+
metric_value = float(value)
|
|
598
|
+
except (TypeError, ValueError):
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
cursor.execute(
|
|
602
|
+
"""
|
|
603
|
+
INSERT INTO model_metrics
|
|
604
|
+
(project, model_name, run_id, metric_name, metric_value, environment, tags)
|
|
605
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
606
|
+
""",
|
|
607
|
+
(project, model_name, run_id, metric_name, metric_value, environment, tags_json),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
conn.commit()
|
|
611
|
+
conn.close()
|
|
612
|
+
|
|
613
|
+
def list_model_metrics(
|
|
614
|
+
self,
|
|
615
|
+
project: str | None = None,
|
|
616
|
+
model_name: str | None = None,
|
|
617
|
+
limit: int = 100,
|
|
618
|
+
) -> list[dict]:
|
|
619
|
+
"""List logged model metrics."""
|
|
620
|
+
conn = sqlite3.connect(self.db_path)
|
|
621
|
+
cursor = conn.cursor()
|
|
622
|
+
|
|
623
|
+
query = """
|
|
624
|
+
SELECT project, model_name, run_id, metric_name, metric_value, environment, tags, created_at
|
|
625
|
+
FROM model_metrics
|
|
626
|
+
"""
|
|
627
|
+
params: list = []
|
|
628
|
+
clauses = []
|
|
629
|
+
|
|
630
|
+
if project:
|
|
631
|
+
clauses.append("project = ?")
|
|
632
|
+
params.append(project)
|
|
633
|
+
if model_name:
|
|
634
|
+
clauses.append("model_name = ?")
|
|
635
|
+
params.append(model_name)
|
|
636
|
+
|
|
637
|
+
if clauses:
|
|
638
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
639
|
+
|
|
640
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
641
|
+
params.append(limit)
|
|
642
|
+
|
|
643
|
+
cursor.execute(query, params)
|
|
644
|
+
rows = cursor.fetchall()
|
|
645
|
+
conn.close()
|
|
646
|
+
|
|
647
|
+
results = []
|
|
648
|
+
for row in rows:
|
|
649
|
+
results.append(
|
|
650
|
+
{
|
|
651
|
+
"project": row[0],
|
|
652
|
+
"model_name": row[1],
|
|
653
|
+
"run_id": row[2],
|
|
654
|
+
"metric_name": row[3],
|
|
655
|
+
"metric_value": row[4],
|
|
656
|
+
"environment": row[5],
|
|
657
|
+
"tags": json.loads(row[6]) if row[6] else {},
|
|
658
|
+
"created_at": row[7],
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
return results
|
|
662
|
+
|
|
561
663
|
def save_experiment(self, experiment_id: str, name: str, description: str = "", tags: dict = None) -> None:
|
|
562
664
|
"""Save experiment metadata.
|
|
563
665
|
|
|
@@ -632,7 +734,7 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
632
734
|
cursor = conn.cursor()
|
|
633
735
|
|
|
634
736
|
cursor.execute(
|
|
635
|
-
"SELECT experiment_id, name, description, tags, created_at FROM experiments ORDER BY created_at DESC",
|
|
737
|
+
"SELECT experiment_id, name, description, tags, created_at, project FROM experiments ORDER BY created_at DESC",
|
|
636
738
|
)
|
|
637
739
|
rows = cursor.fetchall()
|
|
638
740
|
|
|
@@ -649,6 +751,7 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
649
751
|
"description": row[2],
|
|
650
752
|
"tags": json.loads(row[3]),
|
|
651
753
|
"created_at": row[4],
|
|
754
|
+
"project": row[5],
|
|
652
755
|
"run_count": run_count,
|
|
653
756
|
},
|
|
654
757
|
)
|
|
@@ -890,15 +993,27 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
890
993
|
cursor = conn.cursor()
|
|
891
994
|
|
|
892
995
|
try:
|
|
893
|
-
# Update runs
|
|
996
|
+
# 1. Update the project column for all runs
|
|
894
997
|
cursor.execute(
|
|
895
998
|
"UPDATE runs SET project = ? WHERE pipeline_name = ?",
|
|
896
999
|
(project_name, pipeline_name),
|
|
897
1000
|
)
|
|
898
1001
|
|
|
899
|
-
# Update
|
|
900
|
-
|
|
901
|
-
|
|
1002
|
+
# 2. Update the JSON metadata blob for each run
|
|
1003
|
+
cursor.execute(
|
|
1004
|
+
"SELECT run_id, metadata FROM runs WHERE pipeline_name = ?",
|
|
1005
|
+
(pipeline_name,),
|
|
1006
|
+
)
|
|
1007
|
+
rows = cursor.fetchall()
|
|
1008
|
+
for run_id, metadata_json in rows:
|
|
1009
|
+
metadata = json.loads(metadata_json)
|
|
1010
|
+
metadata["project"] = project_name
|
|
1011
|
+
cursor.execute(
|
|
1012
|
+
"UPDATE runs SET metadata = ? WHERE run_id = ?",
|
|
1013
|
+
(json.dumps(metadata), run_id),
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
# 3. Update artifacts table
|
|
902
1017
|
cursor.execute("PRAGMA table_info(artifacts)")
|
|
903
1018
|
columns = [info[1] for info in cursor.fetchall()]
|
|
904
1019
|
if "project" in columns:
|
|
@@ -911,6 +1026,52 @@ class SQLiteMetadataStore(MetadataStore):
|
|
|
911
1026
|
(project_name, pipeline_name),
|
|
912
1027
|
)
|
|
913
1028
|
|
|
1029
|
+
# 4. Update traces table
|
|
1030
|
+
cursor.execute("PRAGMA table_info(traces)")
|
|
1031
|
+
columns = [info[1] for info in cursor.fetchall()]
|
|
1032
|
+
if "project" in columns:
|
|
1033
|
+
# Update traces linked to runs of this pipeline
|
|
1034
|
+
# Note: This assumes we can link traces to runs via metadata or some other way
|
|
1035
|
+
# For now, let's assume traces might have run_id in metadata or we just update by project if we had it
|
|
1036
|
+
# But here we are moving a pipeline to a project.
|
|
1037
|
+
# If traces have a 'project' column, we should update it for traces belonging to these runs.
|
|
1038
|
+
# Since traces don't explicitly have run_id column in schema (it's in metadata),
|
|
1039
|
+
# we might need a more complex query or just skip if not easily linkable.
|
|
1040
|
+
# However, if we assume traces are logged with project context, we might not need to update them
|
|
1041
|
+
# if they were already correct. But if we are MOVING, we need to.
|
|
1042
|
+
# Let's try to update traces that have run_id in their metadata matching these runs.
|
|
1043
|
+
# This is expensive in SQLite with JSON.
|
|
1044
|
+
# Alternative: If traces are associated with the pipeline name directly?
|
|
1045
|
+
# For now, let's skip complex JSON matching for traces to avoid performance issues
|
|
1046
|
+
# unless we add a run_id column to traces.
|
|
1047
|
+
pass
|
|
1048
|
+
|
|
1049
|
+
# 5. Update model_metrics table
|
|
1050
|
+
cursor.execute("PRAGMA table_info(model_metrics)")
|
|
1051
|
+
columns = [info[1] for info in cursor.fetchall()]
|
|
1052
|
+
if "project" in columns:
|
|
1053
|
+
cursor.execute(
|
|
1054
|
+
"""
|
|
1055
|
+
UPDATE model_metrics
|
|
1056
|
+
SET project = ?
|
|
1057
|
+
WHERE run_id IN (SELECT run_id FROM runs WHERE pipeline_name = ?)
|
|
1058
|
+
""",
|
|
1059
|
+
(project_name, pipeline_name),
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# 6. Update experiments table
|
|
1063
|
+
# If an experiment contains runs from this pipeline, should the experiment be moved?
|
|
1064
|
+
# Maybe not automatically, as an experiment might contain runs from multiple pipelines.
|
|
1065
|
+
# But if the user wants "recursive", let's at least update the experiment_runs link
|
|
1066
|
+
# (which doesn't have project) - wait, experiments have project.
|
|
1067
|
+
# Let's find experiments that ONLY contain runs from this pipeline and move them?
|
|
1068
|
+
# Or just leave experiments as is?
|
|
1069
|
+
# The user said "same for all related objects, experiments etc".
|
|
1070
|
+
# Let's be safe and NOT move experiments automatically as they are higher level grouping.
|
|
1071
|
+
# BUT, we should ensure that the runs inside the experiment are consistent.
|
|
1072
|
+
# The runs are already updated in step 1.
|
|
1073
|
+
# So, we are good on experiments.
|
|
1074
|
+
|
|
914
1075
|
conn.commit()
|
|
915
1076
|
finally:
|
|
916
1077
|
conn.close()
|
flowyml/ui/backend/main.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
from fastapi import FastAPI
|
|
2
2
|
from fastapi.middleware.cors import CORSMiddleware
|
|
3
3
|
from fastapi.staticfiles import StaticFiles
|
|
4
|
-
from fastapi.responses import FileResponse
|
|
4
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
5
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
6
|
import os
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
from flowyml.monitoring.alerts import alert_manager, AlertLevel
|
|
6
10
|
|
|
7
11
|
# Include API routers
|
|
8
12
|
from flowyml.ui.backend.routers import (
|
|
@@ -17,6 +21,8 @@ from flowyml.ui.backend.routers import (
|
|
|
17
21
|
leaderboard,
|
|
18
22
|
execution,
|
|
19
23
|
plugins,
|
|
24
|
+
metrics,
|
|
25
|
+
client,
|
|
20
26
|
)
|
|
21
27
|
|
|
22
28
|
app = FastAPI(
|
|
@@ -52,6 +58,7 @@ async def get_public_config():
|
|
|
52
58
|
"remote_server_url": config.remote_server_url,
|
|
53
59
|
"remote_ui_url": config.remote_ui_url,
|
|
54
60
|
"enable_ui": config.enable_ui,
|
|
61
|
+
"remote_services": config.remote_services,
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
|
|
@@ -65,7 +72,9 @@ app.include_router(schedules.router, prefix="/api/schedules", tags=["schedules"]
|
|
|
65
72
|
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
|
|
66
73
|
app.include_router(leaderboard.router, prefix="/api/leaderboard", tags=["leaderboard"])
|
|
67
74
|
app.include_router(execution.router, prefix="/api/execution", tags=["execution"])
|
|
75
|
+
app.include_router(metrics.router, prefix="/api/metrics", tags=["metrics"])
|
|
68
76
|
app.include_router(plugins.router, prefix="/api", tags=["plugins"])
|
|
77
|
+
app.include_router(client.router, prefix="/api/client", tags=["client"])
|
|
69
78
|
|
|
70
79
|
|
|
71
80
|
# Stats endpoint for dashboard
|
|
@@ -185,3 +194,34 @@ else:
|
|
|
185
194
|
"message": "flowyml API is running.",
|
|
186
195
|
"detail": "Frontend not built. Run 'npm run build' in flowyml/ui/frontend to enable the UI.",
|
|
187
196
|
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.exception_handler(Exception)
|
|
200
|
+
async def global_exception_handler(request, exc):
|
|
201
|
+
error_msg = str(exc)
|
|
202
|
+
stack_trace = traceback.format_exc()
|
|
203
|
+
|
|
204
|
+
# Log and alert
|
|
205
|
+
alert_manager.send_alert(
|
|
206
|
+
title="Backend API Error",
|
|
207
|
+
message=f"Unhandled exception in {request.method} {request.url.path}: {error_msg}",
|
|
208
|
+
level=AlertLevel.ERROR,
|
|
209
|
+
metadata={"traceback": stack_trace, "path": request.url.path},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return JSONResponse(
|
|
213
|
+
status_code=500,
|
|
214
|
+
content={
|
|
215
|
+
"error": "Internal Server Error",
|
|
216
|
+
"message": "Something went wrong on our end. We've been notified.",
|
|
217
|
+
"detail": error_msg, # In prod maybe hide this, but for now it's useful
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.exception_handler(RequestValidationError)
|
|
223
|
+
async def validation_exception_handler(request, exc):
|
|
224
|
+
return JSONResponse(
|
|
225
|
+
status_code=422,
|
|
226
|
+
content={"error": "Validation Error", "detail": exc.errors()},
|
|
227
|
+
)
|