270 lines
9.7 KiB
Python
270 lines
9.7 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
System-level Workflow Runs Dashboard API.
|
|
|
|
Provides cross-feature, cross-mandate access to workflow runs
|
|
with RBAC scoping: user sees own runs, mandate admin sees mandate runs,
|
|
sysadmin sees all runs.
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
from modules.auth.authentication import getRequestContext, RequestContext
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.datamodels.datamodelPagination import PaginationParams
|
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
|
AutoRun, AutoStepLog, AutoWorkflow, AutoTask,
|
|
)
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
|
|
routeApiMsg = apiRouteContext("routeWorkflowDashboard")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"])
|
|
|
|
_GREENFIELD_DB = "poweron_graphicaleditor"
|
|
|
|
|
|
def _getDb() -> DatabaseConnector:
|
|
return DatabaseConnector(
|
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
|
dbDatabase=_GREENFIELD_DB,
|
|
dbUser=APP_CONFIG.get("DB_USER"),
|
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
|
userId=None,
|
|
)
|
|
|
|
|
|
def _getUserMandateIds(userId: str) -> list[str]:
|
|
"""Get mandate IDs the user is a member of."""
|
|
rootIface = getRootInterface()
|
|
memberships = rootIface.getUserMandates(userId)
|
|
return [um.mandateId for um in memberships if um.mandateId and um.enabled]
|
|
|
|
|
|
def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
|
|
"""Batch-check which mandates the user is admin for (2 SQL queries total)."""
|
|
if not mandateIds:
|
|
return []
|
|
rootIface = getRootInterface()
|
|
from modules.datamodels.datamodelMembership import UserMandateRole
|
|
allRoles = rootIface.db.getRecordset(UserMandateRole, recordFilter={
|
|
"userId": userId, "mandateId": mandateIds,
|
|
})
|
|
if not allRoles:
|
|
return []
|
|
|
|
roleIds = set()
|
|
roleToMandate: dict = {}
|
|
for r in allRoles:
|
|
row = r if isinstance(r, dict) else r.__dict__
|
|
rid = row.get("roleId")
|
|
mid = row.get("mandateId")
|
|
if rid:
|
|
roleIds.add(rid)
|
|
roleToMandate.setdefault(rid, set()).add(mid)
|
|
|
|
if not roleIds:
|
|
return []
|
|
|
|
from modules.datamodels.datamodelRbac import MandateRole
|
|
roleRecords = rootIface.db.getRecordset(MandateRole, recordFilter={"id": list(roleIds)})
|
|
adminMandates: set = set()
|
|
for role in (roleRecords or []):
|
|
row = role if isinstance(role, dict) else role.__dict__
|
|
if row.get("isAdmin"):
|
|
rid = row.get("id")
|
|
if rid and rid in roleToMandate:
|
|
adminMandates.update(roleToMandate[rid])
|
|
|
|
return [mid for mid in mandateIds if mid in adminMandates]
|
|
|
|
|
|
def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
|
|
"""
|
|
Build a DB filter dict based on RBAC:
|
|
- sysadmin: None (no filter)
|
|
- mandate admin: mandateId IN user's mandates
|
|
- normal user: ownerId = userId
|
|
"""
|
|
if context.hasSysAdminRole:
|
|
return None
|
|
|
|
userId = str(context.user.id) if context.user else None
|
|
if not userId:
|
|
return {"ownerId": "__impossible__"}
|
|
|
|
mandateIds = _getUserMandateIds(userId)
|
|
adminMandateIds = _getAdminMandateIds(userId, mandateIds)
|
|
|
|
if adminMandateIds:
|
|
return {"mandateId": adminMandateIds}
|
|
|
|
return {"ownerId": userId}
|
|
|
|
|
|
@router.get("")
|
|
@limiter.limit("60/minute")
|
|
def get_workflow_runs(
|
|
request: Request,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
mandateId: Optional[str] = Query(None, description="Filter by mandate"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""List workflow runs with RBAC scoping (SQL-paginated)."""
|
|
db = _getDb()
|
|
if not db._ensureTableExists(AutoRun):
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
baseFilter = _scopedRunFilter(context)
|
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
|
|
|
if status:
|
|
recordFilter["status"] = status
|
|
if mandateId:
|
|
recordFilter["mandateId"] = mandateId
|
|
|
|
page = (offset // limit) + 1 if limit > 0 else 1
|
|
pagination = PaginationParams(
|
|
page=page,
|
|
pageSize=limit,
|
|
sort=[{"field": "sysCreatedAt", "direction": "desc"}],
|
|
)
|
|
|
|
result = db.getRecordsetPaginated(
|
|
AutoRun,
|
|
pagination=pagination,
|
|
recordFilter=recordFilter if recordFilter else None,
|
|
)
|
|
pageRuns = result.get("items", []) if isinstance(result, dict) else result.items
|
|
total = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
|
|
|
|
wfIds = list({r.get("workflowId") for r in pageRuns if r.get("workflowId")})
|
|
wfLabelMap = {}
|
|
if wfIds and db._ensureTableExists(AutoWorkflow):
|
|
wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds})
|
|
for wf in (wfs or []):
|
|
wfLabelMap[wf.get("id")] = wf.get("label") or wf.get("id")
|
|
|
|
runs = []
|
|
for r in pageRuns:
|
|
row = dict(r)
|
|
row["workflowLabel"] = wfLabelMap.get(row.get("workflowId"), row.get("workflowId") or "—")
|
|
runs.append(row)
|
|
|
|
return {"runs": runs, "total": total, "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.get("/metrics")
|
|
@limiter.limit("60/minute")
|
|
def get_workflow_metrics(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Aggregated metrics across all accessible workflow runs (SQL COUNT)."""
|
|
db = _getDb()
|
|
if not db._ensureTableExists(AutoRun):
|
|
return {"totalRuns": 0, "runsByStatus": {}, "totalTokens": 0, "totalCredits": 0}
|
|
|
|
baseFilter = _scopedRunFilter(context)
|
|
|
|
countPagination = PaginationParams(page=1, pageSize=1)
|
|
countResult = db.getRecordsetPaginated(AutoRun, pagination=countPagination, recordFilter=baseFilter)
|
|
totalRuns = countResult.get("totalItems", 0) if isinstance(countResult, dict) else countResult.totalItems
|
|
|
|
statusValues = db.getDistinctColumnValues(AutoRun, "status", recordFilter=baseFilter)
|
|
|
|
runsByStatus = {}
|
|
for sv in statusValues:
|
|
statusFilter = dict(baseFilter) if baseFilter else {}
|
|
statusFilter["status"] = sv
|
|
sr = db.getRecordsetPaginated(AutoRun, pagination=PaginationParams(page=1, pageSize=1), recordFilter=statusFilter)
|
|
runsByStatus[sv] = sr.get("totalItems", 0) if isinstance(sr, dict) else sr.totalItems
|
|
|
|
totalTokens = 0
|
|
totalCredits = 0.0
|
|
if totalRuns > 0 and totalRuns <= 10000:
|
|
allRuns = db.getRecordset(AutoRun, recordFilter=baseFilter, fieldFilter=["costTokens", "costCredits"]) or []
|
|
for r in allRuns:
|
|
totalTokens += r.get("costTokens", 0) or 0
|
|
totalCredits += r.get("costCredits", 0.0) or 0.0
|
|
|
|
workflowCount = 0
|
|
activeWorkflows = 0
|
|
if db._ensureTableExists(AutoWorkflow):
|
|
wfFilter: dict = {"isTemplate": False}
|
|
if not context.hasSysAdminRole:
|
|
userId = str(context.user.id) if context.user else None
|
|
mandateIds = _getUserMandateIds(userId) if userId else []
|
|
if mandateIds:
|
|
wfFilter["mandateId"] = mandateIds
|
|
else:
|
|
wfFilter["mandateId"] = "__impossible__"
|
|
|
|
wfCount = db.getRecordsetPaginated(AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), recordFilter=wfFilter)
|
|
workflowCount = wfCount.get("totalItems", 0) if isinstance(wfCount, dict) else wfCount.totalItems
|
|
|
|
activeFilter = dict(wfFilter)
|
|
activeFilter["active"] = True
|
|
activeCount = db.getRecordsetPaginated(AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), recordFilter=activeFilter)
|
|
activeWorkflows = activeCount.get("totalItems", 0) if isinstance(activeCount, dict) else activeCount.totalItems
|
|
|
|
return {
|
|
"totalRuns": totalRuns,
|
|
"runsByStatus": runsByStatus,
|
|
"totalTokens": totalTokens,
|
|
"totalCredits": round(totalCredits, 4),
|
|
"workflowCount": workflowCount,
|
|
"activeWorkflows": activeWorkflows,
|
|
}
|
|
|
|
|
|
@router.get("/{runId}/steps")
|
|
@limiter.limit("60/minute")
|
|
def get_run_steps(
|
|
request: Request,
|
|
runId: str = Path(..., description="Run ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Get step logs for a specific run (with access check)."""
|
|
db = _getDb()
|
|
if not db._ensureTableExists(AutoRun):
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
|
|
|
runs = db.getRecordset(AutoRun, recordFilter={"id": runId})
|
|
if not runs:
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
|
run = dict(runs[0])
|
|
|
|
if not context.hasSysAdminRole:
|
|
userId = str(context.user.id) if context.user else None
|
|
runOwner = run.get("ownerId")
|
|
runMandate = run.get("mandateId")
|
|
|
|
if runOwner == userId:
|
|
pass
|
|
elif runMandate and userId and _isUserMandateAdmin(userId, runMandate):
|
|
pass
|
|
else:
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
|
|
|
if not db._ensureTableExists(AutoStepLog):
|
|
return {"steps": []}
|
|
|
|
records = db.getRecordset(AutoStepLog, recordFilter={"runId": runId})
|
|
steps = [dict(r) for r in records] if records else []
|
|
steps.sort(key=lambda s: s.get("startedAt") or 0)
|
|
return {"steps": steps}
|