# 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}