flowyml 1.7.1__py3-none-any.whl → 1.8.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/assets/base.py +15 -0
- flowyml/assets/dataset.py +570 -17
- flowyml/assets/metrics.py +5 -0
- flowyml/assets/model.py +1052 -15
- flowyml/cli/main.py +709 -0
- flowyml/cli/stack_cli.py +138 -25
- flowyml/core/__init__.py +17 -0
- flowyml/core/executor.py +231 -37
- flowyml/core/image_builder.py +129 -0
- flowyml/core/log_streamer.py +227 -0
- flowyml/core/orchestrator.py +59 -4
- flowyml/core/pipeline.py +65 -13
- flowyml/core/routing.py +558 -0
- flowyml/core/scheduler.py +88 -5
- flowyml/core/step.py +9 -1
- flowyml/core/step_grouping.py +49 -35
- flowyml/core/types.py +407 -0
- flowyml/integrations/keras.py +247 -82
- flowyml/monitoring/alerts.py +10 -0
- flowyml/monitoring/notifications.py +104 -25
- flowyml/monitoring/slack_blocks.py +323 -0
- flowyml/plugins/__init__.py +251 -0
- flowyml/plugins/alerters/__init__.py +1 -0
- flowyml/plugins/alerters/slack.py +168 -0
- flowyml/plugins/base.py +752 -0
- flowyml/plugins/config.py +478 -0
- flowyml/plugins/deployers/__init__.py +22 -0
- flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
- flowyml/plugins/deployers/sagemaker.py +306 -0
- flowyml/plugins/deployers/vertex.py +290 -0
- flowyml/plugins/integration.py +369 -0
- flowyml/plugins/manager.py +510 -0
- flowyml/plugins/model_registries/__init__.py +22 -0
- flowyml/plugins/model_registries/mlflow.py +159 -0
- flowyml/plugins/model_registries/sagemaker.py +489 -0
- flowyml/plugins/model_registries/vertex.py +386 -0
- flowyml/plugins/orchestrators/__init__.py +13 -0
- flowyml/plugins/orchestrators/sagemaker.py +443 -0
- flowyml/plugins/orchestrators/vertex_ai.py +461 -0
- flowyml/plugins/registries/__init__.py +13 -0
- flowyml/plugins/registries/ecr.py +321 -0
- flowyml/plugins/registries/gcr.py +313 -0
- flowyml/plugins/registry.py +454 -0
- flowyml/plugins/stack.py +494 -0
- flowyml/plugins/stack_config.py +537 -0
- flowyml/plugins/stores/__init__.py +13 -0
- flowyml/plugins/stores/gcs.py +460 -0
- flowyml/plugins/stores/s3.py +453 -0
- flowyml/plugins/trackers/__init__.py +11 -0
- flowyml/plugins/trackers/mlflow.py +316 -0
- flowyml/plugins/validators/__init__.py +3 -0
- flowyml/plugins/validators/deepchecks.py +119 -0
- flowyml/registry/__init__.py +2 -1
- flowyml/registry/model_environment.py +109 -0
- flowyml/registry/model_registry.py +241 -96
- flowyml/serving/__init__.py +17 -0
- flowyml/serving/model_server.py +628 -0
- flowyml/stacks/__init__.py +60 -0
- flowyml/stacks/aws.py +93 -0
- flowyml/stacks/base.py +62 -0
- flowyml/stacks/components.py +12 -0
- flowyml/stacks/gcp.py +44 -9
- flowyml/stacks/plugins.py +115 -0
- flowyml/stacks/registry.py +2 -1
- flowyml/storage/sql.py +401 -12
- flowyml/tracking/experiment.py +8 -5
- flowyml/ui/backend/Dockerfile +87 -16
- flowyml/ui/backend/auth.py +12 -2
- flowyml/ui/backend/main.py +149 -5
- flowyml/ui/backend/routers/ai_context.py +226 -0
- flowyml/ui/backend/routers/assets.py +23 -4
- flowyml/ui/backend/routers/auth.py +96 -0
- flowyml/ui/backend/routers/deployments.py +660 -0
- flowyml/ui/backend/routers/model_explorer.py +597 -0
- flowyml/ui/backend/routers/plugins.py +103 -51
- flowyml/ui/backend/routers/projects.py +91 -8
- flowyml/ui/backend/routers/runs.py +132 -1
- flowyml/ui/backend/routers/schedules.py +54 -29
- flowyml/ui/backend/routers/templates.py +319 -0
- flowyml/ui/backend/routers/websocket.py +2 -2
- flowyml/ui/frontend/Dockerfile +55 -6
- flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
- flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/dist/logo.png +0 -0
- flowyml/ui/frontend/nginx.conf +65 -4
- flowyml/ui/frontend/package-lock.json +1415 -74
- flowyml/ui/frontend/package.json +4 -0
- flowyml/ui/frontend/public/logo.png +0 -0
- flowyml/ui/frontend/src/App.jsx +10 -7
- flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
- flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
- flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
- flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
- flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +601 -101
- flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
- flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +424 -29
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
- flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
- flowyml/ui/frontend/src/components/Layout.jsx +6 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
- flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
- flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
- flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
- flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
- flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
- flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
- flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
- flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
- flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
- flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
- flowyml/ui/frontend/src/router/index.jsx +47 -20
- flowyml/ui/frontend/src/services/pluginService.js +3 -1
- flowyml/ui/server_manager.py +5 -5
- flowyml/ui/utils.py +157 -39
- flowyml/utils/config.py +37 -15
- flowyml/utils/model_introspection.py +123 -0
- flowyml/utils/observability.py +30 -0
- flowyml-1.8.0.dist-info/METADATA +174 -0
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/RECORD +134 -73
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
- flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- flowyml-1.7.1.dist-info/METADATA +0 -477
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
flowyml/ui/backend/main.py
CHANGED
|
@@ -8,7 +8,18 @@ import traceback
|
|
|
8
8
|
|
|
9
9
|
from flowyml.monitoring.alerts import alert_manager, AlertLevel
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# OpenTelemetry Imports
|
|
12
|
+
from opentelemetry import trace, metrics
|
|
13
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
14
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
15
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
16
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
17
|
+
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
|
18
|
+
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
19
|
+
from prometheus_client import make_asgi_app
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
|
|
12
23
|
from flowyml.ui.backend.routers import (
|
|
13
24
|
pipelines,
|
|
14
25
|
runs,
|
|
@@ -21,28 +32,149 @@ from flowyml.ui.backend.routers import (
|
|
|
21
32
|
leaderboard,
|
|
22
33
|
execution,
|
|
23
34
|
plugins,
|
|
24
|
-
metrics,
|
|
35
|
+
metrics as metrics_router,
|
|
25
36
|
client,
|
|
26
37
|
stats,
|
|
27
38
|
websocket,
|
|
39
|
+
deployments,
|
|
40
|
+
model_explorer,
|
|
41
|
+
auth, # New Auth Router
|
|
42
|
+
templates, # Pipeline Templates
|
|
43
|
+
ai_context, # AI Assistant Context
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Initialize OpenTelemetry
|
|
47
|
+
resource = Resource(
|
|
48
|
+
attributes={
|
|
49
|
+
SERVICE_NAME: "flowyml-backend",
|
|
50
|
+
},
|
|
28
51
|
)
|
|
29
52
|
|
|
53
|
+
# Tracing - Use OTLP exporter in production, console in development
|
|
54
|
+
trace_provider = TracerProvider(resource=resource)
|
|
55
|
+
_otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
56
|
+
if _otlp_endpoint:
|
|
57
|
+
# Production: Use OTLP exporter (supports Jaeger, Honeycomb, Google Cloud Trace, etc.)
|
|
58
|
+
try:
|
|
59
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
60
|
+
|
|
61
|
+
trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=_otlp_endpoint)))
|
|
62
|
+
except ImportError:
|
|
63
|
+
# Fallback to console if OTLP exporter not installed
|
|
64
|
+
trace_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
65
|
+
else:
|
|
66
|
+
# Development: Console exporter for debugging
|
|
67
|
+
trace_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
68
|
+
trace.set_tracer_provider(trace_provider)
|
|
69
|
+
|
|
70
|
+
# Metrics (Prometheus)
|
|
71
|
+
reader = PrometheusMetricReader()
|
|
72
|
+
meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
|
|
73
|
+
metrics.set_meter_provider(meter_provider)
|
|
74
|
+
|
|
75
|
+
# Routers included above
|
|
76
|
+
|
|
30
77
|
app = FastAPI(
|
|
31
78
|
title="flowyml UI",
|
|
32
79
|
description="Real-time UI for flowyml pipelines",
|
|
33
80
|
version="0.1.0",
|
|
34
81
|
)
|
|
35
82
|
|
|
36
|
-
#
|
|
83
|
+
# Instrument FastAPI
|
|
84
|
+
FastAPIInstrumentor.instrument_app(app)
|
|
85
|
+
|
|
86
|
+
# Expose Prometheus metrics
|
|
87
|
+
metrics_app = make_asgi_app()
|
|
88
|
+
app.mount("/metrics", metrics_app)
|
|
89
|
+
|
|
90
|
+
# Configure CORS - Production settings with localhost for development
|
|
91
|
+
_is_production = os.getenv("FLOWYML_ENV") == "production"
|
|
92
|
+
_cors_origins = (
|
|
93
|
+
[
|
|
94
|
+
# Production: Only allow specific origins
|
|
95
|
+
"https://flowyml.unicolab.ai",
|
|
96
|
+
"https://app.flowyml.io",
|
|
97
|
+
]
|
|
98
|
+
if _is_production
|
|
99
|
+
else [
|
|
100
|
+
# Development: Allow localhost and common dev ports
|
|
101
|
+
"http://localhost:3000",
|
|
102
|
+
"http://localhost:5173",
|
|
103
|
+
"http://localhost:8080",
|
|
104
|
+
"http://127.0.0.1:3000",
|
|
105
|
+
"http://127.0.0.1:5173",
|
|
106
|
+
"http://127.0.0.1:8080",
|
|
107
|
+
"*", # Fallback for dev flexibility
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
37
111
|
app.add_middleware(
|
|
38
112
|
CORSMiddleware,
|
|
39
|
-
allow_origins=
|
|
113
|
+
allow_origins=_cors_origins,
|
|
40
114
|
allow_credentials=True,
|
|
41
115
|
allow_methods=["*"],
|
|
42
116
|
allow_headers=["*"],
|
|
43
117
|
)
|
|
44
118
|
|
|
45
119
|
|
|
120
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
121
|
+
async def dispatch(self, request: Request, call_next):
|
|
122
|
+
# 0. Skip if not in production to allow easy local development
|
|
123
|
+
if os.getenv("FLOWYML_ENV") != "production":
|
|
124
|
+
return await call_next(request)
|
|
125
|
+
|
|
126
|
+
# 1. Check if token auth is enabled via env var
|
|
127
|
+
api_token = os.getenv("FLOWYML_API_TOKEN")
|
|
128
|
+
if not api_token:
|
|
129
|
+
return await call_next(request)
|
|
130
|
+
|
|
131
|
+
# 2. Define public paths
|
|
132
|
+
path = request.url.path
|
|
133
|
+
if (
|
|
134
|
+
path in ["/api/health", "/metrics", "/docs", "/redoc", "/openapi.json"]
|
|
135
|
+
or path.startswith(("/assets", "/api/auth/login", "/api/auth/logout")) # Allow login endpoints
|
|
136
|
+
or path == "/"
|
|
137
|
+
or request.method == "OPTIONS"
|
|
138
|
+
):
|
|
139
|
+
return await call_next(request)
|
|
140
|
+
|
|
141
|
+
# 3. Check Auth Header OR Cookie
|
|
142
|
+
auth_header = request.headers.get("Authorization")
|
|
143
|
+
if not auth_header:
|
|
144
|
+
# Check for cookie
|
|
145
|
+
cookie_token = request.cookies.get("access_token")
|
|
146
|
+
if cookie_token:
|
|
147
|
+
auth_header = cookie_token # Reuse validation logic below
|
|
148
|
+
else:
|
|
149
|
+
token_param = request.query_params.get("token")
|
|
150
|
+
if token_param == api_token:
|
|
151
|
+
return await call_next(request)
|
|
152
|
+
|
|
153
|
+
return JSONResponse(
|
|
154
|
+
status_code=401,
|
|
155
|
+
content={"error": "Unauthorized", "message": "Missing authentication token"},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# 4. Validate Token
|
|
159
|
+
try:
|
|
160
|
+
scheme, token = auth_header.split()
|
|
161
|
+
if scheme.lower() != "bearer" or token != api_token:
|
|
162
|
+
return JSONResponse(
|
|
163
|
+
status_code=401,
|
|
164
|
+
content={"error": "Unauthorized", "message": "Invalid authentication token"},
|
|
165
|
+
)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return JSONResponse(
|
|
168
|
+
status_code=401,
|
|
169
|
+
content={"error": "Unauthorized", "message": "Invalid Authorization header format"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return await call_next(request)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
app.add_middleware(AuthMiddleware)
|
|
176
|
+
|
|
177
|
+
|
|
46
178
|
# Health check endpoint
|
|
47
179
|
@app.get("/api/health")
|
|
48
180
|
async def health_check():
|
|
@@ -74,11 +206,16 @@ app.include_router(schedules.router, prefix="/api/schedules", tags=["schedules"]
|
|
|
74
206
|
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
|
|
75
207
|
app.include_router(leaderboard.router, prefix="/api/leaderboard", tags=["leaderboard"])
|
|
76
208
|
app.include_router(execution.router, prefix="/api/execution", tags=["execution"])
|
|
77
|
-
app.include_router(
|
|
209
|
+
app.include_router(metrics_router.router, prefix="/api/metrics", tags=["metrics"])
|
|
78
210
|
app.include_router(plugins.router, prefix="/api", tags=["plugins"])
|
|
79
211
|
app.include_router(client.router, prefix="/api/client", tags=["client"])
|
|
80
212
|
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
|
81
213
|
app.include_router(websocket.router, tags=["websocket"])
|
|
214
|
+
app.include_router(deployments.router, prefix="/api", tags=["deployments"])
|
|
215
|
+
app.include_router(model_explorer.router, prefix="/api", tags=["model-explorer"])
|
|
216
|
+
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
|
217
|
+
app.include_router(templates.router, tags=["templates"])
|
|
218
|
+
app.include_router(ai_context.router, tags=["ai"]) # AI Context for assistant
|
|
82
219
|
|
|
83
220
|
|
|
84
221
|
# Static file serving for frontend
|
|
@@ -150,3 +287,10 @@ async def validation_exception_handler(request, exc):
|
|
|
150
287
|
status_code=422,
|
|
151
288
|
content={"error": "Validation Error", "detail": exc.errors()},
|
|
152
289
|
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
import uvicorn
|
|
294
|
+
|
|
295
|
+
port = int(os.environ.get("PORT", "8080"))
|
|
296
|
+
uvicorn.run("flowyml.ui.backend.main:app", host="0.0.0.0", port=port, reload=False) # noqa: S104
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Context Router - Provides comprehensive context for the AI assistant.
|
|
3
|
+
|
|
4
|
+
This endpoint aggregates run details, logs, metrics, and step code to provide
|
|
5
|
+
rich context for the in-browser AI assistant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from ..dependencies import get_store
|
|
11
|
+
import json
|
|
12
|
+
from flowyml.utils.config import get_config
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AIContextRequest(BaseModel):
|
|
18
|
+
"""Request model for AI context."""
|
|
19
|
+
|
|
20
|
+
page_type: str # 'run', 'pipeline', 'experiment', 'asset'
|
|
21
|
+
resource_id: str # run_id, pipeline_name, etc.
|
|
22
|
+
include_logs: bool = True
|
|
23
|
+
include_code: bool = True
|
|
24
|
+
include_metrics: bool = True
|
|
25
|
+
max_log_lines: int = 100
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AIContextResponse(BaseModel):
|
|
29
|
+
"""Response model with comprehensive AI context."""
|
|
30
|
+
|
|
31
|
+
page_type: str
|
|
32
|
+
resource_id: str
|
|
33
|
+
summary: dict
|
|
34
|
+
details: dict
|
|
35
|
+
suggestions: list[str] = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _summarize_run(run: dict, include_logs: bool = True, include_code: bool = True, max_log_lines: int = 100) -> dict:
|
|
39
|
+
"""Generate a comprehensive summary of a run for AI context."""
|
|
40
|
+
steps_info = []
|
|
41
|
+
failed_steps = []
|
|
42
|
+
|
|
43
|
+
steps = run.get("steps", {})
|
|
44
|
+
if isinstance(steps, str):
|
|
45
|
+
try:
|
|
46
|
+
steps = json.loads(steps)
|
|
47
|
+
except Exception:
|
|
48
|
+
steps = {}
|
|
49
|
+
|
|
50
|
+
if steps:
|
|
51
|
+
for name, step_data in steps.items():
|
|
52
|
+
step_summary = {
|
|
53
|
+
"name": name,
|
|
54
|
+
"status": "success"
|
|
55
|
+
if step_data.get("success")
|
|
56
|
+
else ("failed" if step_data.get("error") else "pending"),
|
|
57
|
+
"duration": f"{step_data.get('duration', 0):.2f}s",
|
|
58
|
+
"cached": step_data.get("cached", False),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Include error details for failed steps
|
|
62
|
+
if step_data.get("error"):
|
|
63
|
+
step_summary["error"] = str(step_data["error"])[:500] # Truncate long errors
|
|
64
|
+
failed_steps.append(name)
|
|
65
|
+
|
|
66
|
+
# Include source code if requested
|
|
67
|
+
if include_code and step_data.get("source_code"):
|
|
68
|
+
step_summary["source_code"] = step_data["source_code"][:2000] # Limit code size
|
|
69
|
+
|
|
70
|
+
# Include inputs/outputs
|
|
71
|
+
step_summary["inputs"] = step_data.get("inputs", [])[:5]
|
|
72
|
+
step_summary["outputs"] = step_data.get("outputs", [])[:5]
|
|
73
|
+
|
|
74
|
+
steps_info.append(step_summary)
|
|
75
|
+
|
|
76
|
+
summary = {
|
|
77
|
+
"run_id": str(run.get("run_id")),
|
|
78
|
+
"pipeline_name": run.get("pipeline_name"),
|
|
79
|
+
"status": run.get("status"),
|
|
80
|
+
"duration": f"{run.get('duration', 0):.2f}s" if run.get("duration") else None,
|
|
81
|
+
"start_time": run.get("start_time"),
|
|
82
|
+
"end_time": run.get("end_time"),
|
|
83
|
+
"total_steps": len(steps_info),
|
|
84
|
+
"successful_steps": len([s for s in steps_info if s["status"] == "success"]),
|
|
85
|
+
"failed_steps": len(failed_steps),
|
|
86
|
+
"cached_steps": len([s for s in steps_info if s["cached"]]),
|
|
87
|
+
"steps": steps_info,
|
|
88
|
+
"failed_step_names": failed_steps,
|
|
89
|
+
"context_params": run.get("context") or {},
|
|
90
|
+
"environment": run.get("environment") or {},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return summary
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_run_logs(run_id: str, max_lines: int = 100) -> dict:
|
|
97
|
+
"""Fetch recent logs for a run from filesystem."""
|
|
98
|
+
logs_by_step = {}
|
|
99
|
+
runs_dir = get_config().runs_dir
|
|
100
|
+
log_dir = runs_dir / run_id / "logs"
|
|
101
|
+
|
|
102
|
+
if not log_dir.exists():
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
for log_file in log_dir.glob("*.log"):
|
|
106
|
+
step_name = log_file.stem
|
|
107
|
+
try:
|
|
108
|
+
with open(log_file) as f:
|
|
109
|
+
lines = f.readlines()
|
|
110
|
+
# Get last N lines
|
|
111
|
+
recent_lines = lines[-max_lines:]
|
|
112
|
+
logs_by_step[step_name] = [{"message": line.strip(), "level": "INFO"} for line in recent_lines]
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
return logs_by_step
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _get_run_metrics(run_id: str) -> list:
|
|
120
|
+
"""Fetch metrics for a run."""
|
|
121
|
+
metrics = []
|
|
122
|
+
store = get_store()
|
|
123
|
+
|
|
124
|
+
db_metrics = store.get_metrics(run_id)
|
|
125
|
+
if db_metrics:
|
|
126
|
+
for m in db_metrics[:50]:
|
|
127
|
+
metrics.append(
|
|
128
|
+
{
|
|
129
|
+
"name": m.get("name"),
|
|
130
|
+
"value": m.get("value"),
|
|
131
|
+
"step": m.get("step"),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return metrics
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _generate_suggestions(summary: dict) -> list[str]:
|
|
139
|
+
"""Generate AI-friendly suggestions based on run data."""
|
|
140
|
+
suggestions = []
|
|
141
|
+
|
|
142
|
+
if summary.get("failed_steps", 0) > 0:
|
|
143
|
+
suggestions.append(f"Analyze the {summary['failed_steps']} failed step(s) and suggest fixes")
|
|
144
|
+
|
|
145
|
+
if summary.get("cached_steps", 0) == 0 and summary.get("total_steps", 0) > 3:
|
|
146
|
+
suggestions.append("Consider enabling caching for frequently-run steps")
|
|
147
|
+
|
|
148
|
+
duration_str = summary.get("duration")
|
|
149
|
+
if duration_str:
|
|
150
|
+
try:
|
|
151
|
+
duration = float(duration_str.replace("s", ""))
|
|
152
|
+
if duration > 300:
|
|
153
|
+
suggestions.append("The run took over 5 minutes - consider optimization opportunities")
|
|
154
|
+
except (ValueError, AttributeError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
return suggestions
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.post("/context", response_model=AIContextResponse)
|
|
161
|
+
async def get_ai_context(request: AIContextRequest):
|
|
162
|
+
"""
|
|
163
|
+
Get comprehensive AI context for a specific page/resource.
|
|
164
|
+
|
|
165
|
+
This endpoint aggregates all relevant information that the AI assistant
|
|
166
|
+
might need to provide helpful, context-aware responses.
|
|
167
|
+
"""
|
|
168
|
+
if request.page_type == "run":
|
|
169
|
+
# Fetch run data
|
|
170
|
+
store = get_store()
|
|
171
|
+
run = store.load_run(request.resource_id)
|
|
172
|
+
|
|
173
|
+
if not run:
|
|
174
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
175
|
+
|
|
176
|
+
# Generate comprehensive summary
|
|
177
|
+
summary = _summarize_run(
|
|
178
|
+
run,
|
|
179
|
+
include_logs=request.include_logs,
|
|
180
|
+
include_code=request.include_code,
|
|
181
|
+
max_log_lines=request.max_log_lines,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Fetch additional data
|
|
185
|
+
details = {}
|
|
186
|
+
|
|
187
|
+
if request.include_logs:
|
|
188
|
+
details["logs"] = _get_run_logs(request.resource_id, request.max_log_lines)
|
|
189
|
+
|
|
190
|
+
if request.include_metrics:
|
|
191
|
+
details["metrics"] = _get_run_metrics(request.resource_id)
|
|
192
|
+
|
|
193
|
+
# Generate suggestions
|
|
194
|
+
suggestions = _generate_suggestions(summary)
|
|
195
|
+
|
|
196
|
+
return AIContextResponse(
|
|
197
|
+
page_type=request.page_type,
|
|
198
|
+
resource_id=request.resource_id,
|
|
199
|
+
summary=summary,
|
|
200
|
+
details=details,
|
|
201
|
+
suggestions=suggestions,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Add more page types as needed
|
|
205
|
+
raise HTTPException(status_code=400, detail=f"Unsupported page type: {request.page_type}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get("/context/run/{run_id}")
|
|
209
|
+
async def get_run_ai_context(
|
|
210
|
+
run_id: str,
|
|
211
|
+
include_logs: bool = True,
|
|
212
|
+
include_code: bool = True,
|
|
213
|
+
include_metrics: bool = True,
|
|
214
|
+
):
|
|
215
|
+
"""
|
|
216
|
+
Convenience endpoint for getting AI context for a specific run.
|
|
217
|
+
"""
|
|
218
|
+
return await get_ai_context(
|
|
219
|
+
AIContextRequest(
|
|
220
|
+
page_type="run",
|
|
221
|
+
resource_id=run_id,
|
|
222
|
+
include_logs=include_logs,
|
|
223
|
+
include_code=include_code,
|
|
224
|
+
include_metrics=include_metrics,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
|
2
2
|
from fastapi.responses import FileResponse
|
|
3
3
|
from pydantic import BaseModel, Field
|
|
4
|
-
from flowyml.storage.metadata import SQLiteMetadataStore
|
|
5
4
|
from flowyml.core.project import ProjectManager
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from flowyml.ui.backend.dependencies import get_store
|
|
@@ -18,7 +17,8 @@ def _save_file_sync(src, dst):
|
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
def _iter_metadata_stores():
|
|
21
|
-
|
|
20
|
+
# Use the main configured store (PostgreSQL in Docker deployment)
|
|
21
|
+
stores = [(None, get_store())]
|
|
22
22
|
try:
|
|
23
23
|
manager = ProjectManager()
|
|
24
24
|
for project_meta in manager.list_projects():
|
|
@@ -51,6 +51,14 @@ async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = Non
|
|
|
51
51
|
"""List all assets, optionally filtered by project."""
|
|
52
52
|
try:
|
|
53
53
|
combined = []
|
|
54
|
+
project_run_ids = set()
|
|
55
|
+
|
|
56
|
+
# If filtering by project, first get all run_ids for that project
|
|
57
|
+
if project:
|
|
58
|
+
main_store = get_store()
|
|
59
|
+
all_runs = main_store.list_runs(limit=1000)
|
|
60
|
+
project_run_ids = {r.get("run_id") for r in all_runs if r.get("project") == project}
|
|
61
|
+
|
|
54
62
|
for project_name, store in _iter_metadata_stores():
|
|
55
63
|
if project and project_name and project != project_name:
|
|
56
64
|
continue
|
|
@@ -61,14 +69,25 @@ async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = Non
|
|
|
61
69
|
if run_id:
|
|
62
70
|
filters["run_id"] = run_id
|
|
63
71
|
|
|
64
|
-
assets = store.list_assets(limit=limit, **filters)
|
|
72
|
+
assets = store.list_assets(limit=limit * 2, **filters) # Get more to filter
|
|
65
73
|
for asset in assets:
|
|
66
74
|
combined.append((asset, project_name))
|
|
67
75
|
|
|
68
76
|
assets = _dedupe_assets(combined)
|
|
69
77
|
|
|
78
|
+
# Enhanced filtering: include artifacts that either:
|
|
79
|
+
# 1. Have project field matching
|
|
80
|
+
# 2. Have run_id that belongs to the project
|
|
70
81
|
if project:
|
|
71
|
-
|
|
82
|
+
filtered_assets = []
|
|
83
|
+
for a in assets:
|
|
84
|
+
if a.get("project") == project:
|
|
85
|
+
filtered_assets.append(a)
|
|
86
|
+
elif a.get("run_id") in project_run_ids:
|
|
87
|
+
# Also include and tag with project
|
|
88
|
+
a["project"] = project
|
|
89
|
+
filtered_assets.append(a)
|
|
90
|
+
assets = filtered_assets
|
|
72
91
|
|
|
73
92
|
assets = assets[:limit]
|
|
74
93
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, status, Response, Request
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# --- Models ---
|
|
9
|
+
class User(BaseModel):
|
|
10
|
+
username: str
|
|
11
|
+
role: str = "admin"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Token(BaseModel):
|
|
15
|
+
access_token: str
|
|
16
|
+
token_type: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoginRequest(BaseModel):
|
|
20
|
+
username: str
|
|
21
|
+
password: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- Config ---
|
|
25
|
+
# For simplicity in this iteration, we use single-user admin auth
|
|
26
|
+
ADMIN_USER = os.getenv("FLOWYML_ADMIN_USER", "admin")
|
|
27
|
+
ADMIN_PASSWORD = os.getenv("FLOWYML_ADMIN_PASSWORD", "flowyml")
|
|
28
|
+
# In production, this token is also a long-lived secret or signed JWT.
|
|
29
|
+
# For simplicity, we reuse the API Token logic or generate a session token.
|
|
30
|
+
# Here, we'll implement a simple session token mechanism or reuse API token.
|
|
31
|
+
API_TOKEN = os.getenv("FLOWYML_API_TOKEN")
|
|
32
|
+
|
|
33
|
+
# --- Routes ---
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/login", response_model=Token)
|
|
37
|
+
async def login(response: Response, login_data: LoginRequest):
|
|
38
|
+
"""
|
|
39
|
+
Login with username and password.
|
|
40
|
+
Sets HttpOnly cookie for browser sessions.
|
|
41
|
+
Returns token for CLI/API use.
|
|
42
|
+
"""
|
|
43
|
+
if login_data.username != ADMIN_USER or login_data.password != ADMIN_PASSWORD:
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
46
|
+
detail="Incorrect username or password",
|
|
47
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Use the API token as the session token for simplicity in this unified auth model
|
|
51
|
+
# Ensure API_TOKEN is set in production!
|
|
52
|
+
if not API_TOKEN:
|
|
53
|
+
# Fallback for dev if not set (though middleware skips auth in dev usually)
|
|
54
|
+
token = "dev-token-placeholder" # noqa: S105
|
|
55
|
+
else:
|
|
56
|
+
token = API_TOKEN
|
|
57
|
+
|
|
58
|
+
# Set HttpOnly cookie
|
|
59
|
+
# accessible only by server, secure in prod
|
|
60
|
+
response.set_cookie(
|
|
61
|
+
key="access_token",
|
|
62
|
+
value=f"Bearer {token}",
|
|
63
|
+
httponly=True,
|
|
64
|
+
max_age=86400 * 7, # 7 days
|
|
65
|
+
secure=os.getenv("FLOWYML_ENV") == "production",
|
|
66
|
+
samesite="lax",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {"access_token": token, "token_type": "bearer"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/logout")
|
|
73
|
+
async def logout(response: Response):
|
|
74
|
+
"""Clear session cookie."""
|
|
75
|
+
response.delete_cookie("access_token")
|
|
76
|
+
return {"message": "Logged out successfully"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/me", response_model=User)
|
|
80
|
+
async def get_current_user(request: Request):
|
|
81
|
+
"""
|
|
82
|
+
Get current logged in user.
|
|
83
|
+
Used by frontend to verify session.
|
|
84
|
+
"""
|
|
85
|
+
# If middleware let us through, we are authenticated.
|
|
86
|
+
# In 'production' mode, unauth requests are blocked by middleware.
|
|
87
|
+
# In 'development' mode, middleware skips, so we check env.
|
|
88
|
+
|
|
89
|
+
if os.getenv("FLOWYML_ENV") != "production":
|
|
90
|
+
# Local dev: always return default admin
|
|
91
|
+
return {"username": "developer", "role": "admin"}
|
|
92
|
+
|
|
93
|
+
# In production, if we reached here, AuthMiddleware validated us.
|
|
94
|
+
# We can inspect headers/cookies to determine *who* it is
|
|
95
|
+
# (if we had multi-user), but for now it's just Admin.
|
|
96
|
+
return {"username": ADMIN_USER, "role": "admin"}
|