Fixed two critical issues preventing dashboard from loading:
1. OrchestrationStatus enum case mismatch:
- Changed OrchestrationStatus.COMPLETED → .completed
- Changed OrchestrationStatus.COMPLETED_WITH_WARNINGS → .partial_success
- Enum values are lowercase, not uppercase
2. Service hostname resolution errors:
- Fixed all service URLs to include -service suffix:
- http://inventory:8000 → http://inventory-service:8000
- http://production:8000 → http://production-service:8000
- http://procurement:8000 → http://procurement-service:8000
- http://alert-processor:8000 → http://alert-processor-service:8000
This fixes the AttributeError and "Name or service not known" errors
preventing the dashboard from loading demo data.
504 lines
20 KiB
Python
504 lines
20 KiB
Python
# ================================================================
|
|
# services/orchestrator/app/api/dashboard.py
|
|
# ================================================================
|
|
"""
|
|
Dashboard API endpoints for JTBD-aligned bakery dashboard
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from typing import Dict, Any, List, Optional
|
|
from pydantic import BaseModel, Field
|
|
from datetime import datetime
|
|
import logging
|
|
import httpx
|
|
|
|
from app.core.database import get_db
|
|
from ..services.dashboard_service import DashboardService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/dashboard", tags=["dashboard"])
|
|
|
|
|
|
# ============================================================
|
|
# Response Models
|
|
# ============================================================
|
|
|
|
class HealthChecklistItem(BaseModel):
|
|
"""Individual item in health checklist"""
|
|
icon: str = Field(..., description="Icon name: check, warning, alert")
|
|
text: str = Field(..., description="Checklist item text")
|
|
actionRequired: bool = Field(..., description="Whether action is required")
|
|
|
|
|
|
class BakeryHealthStatusResponse(BaseModel):
|
|
"""Overall bakery health status"""
|
|
status: str = Field(..., description="Health status: green, yellow, red")
|
|
headline: str = Field(..., description="Human-readable status headline")
|
|
lastOrchestrationRun: Optional[str] = Field(None, description="ISO timestamp of last orchestration")
|
|
nextScheduledRun: str = Field(..., description="ISO timestamp of next scheduled run")
|
|
checklistItems: List[HealthChecklistItem] = Field(..., description="Status checklist")
|
|
criticalIssues: int = Field(..., description="Count of critical issues")
|
|
pendingActions: int = Field(..., description="Count of pending actions")
|
|
|
|
|
|
class ReasoningInputs(BaseModel):
|
|
"""Inputs used by orchestrator for decision making"""
|
|
customerOrders: int = Field(..., description="Number of customer orders analyzed")
|
|
historicalDemand: bool = Field(..., description="Whether historical data was used")
|
|
inventoryLevels: bool = Field(..., description="Whether inventory levels were considered")
|
|
aiInsights: bool = Field(..., description="Whether AI insights were used")
|
|
|
|
|
|
class PurchaseOrderSummary(BaseModel):
|
|
"""Summary of a purchase order for dashboard"""
|
|
supplierName: str
|
|
itemCategories: List[str]
|
|
totalAmount: float
|
|
|
|
|
|
class ProductionBatchSummary(BaseModel):
|
|
"""Summary of a production batch for dashboard"""
|
|
productName: str
|
|
quantity: float
|
|
readyByTime: str
|
|
|
|
|
|
class OrchestrationSummaryResponse(BaseModel):
|
|
"""What the orchestrator did for the user"""
|
|
runTimestamp: Optional[str] = Field(None, description="When the orchestration ran")
|
|
runNumber: Optional[int] = Field(None, description="Run sequence number")
|
|
status: str = Field(..., description="Run status")
|
|
purchaseOrdersCreated: int = Field(..., description="Number of POs created")
|
|
purchaseOrdersSummary: List[PurchaseOrderSummary] = Field(default_factory=list)
|
|
productionBatchesCreated: int = Field(..., description="Number of batches created")
|
|
productionBatchesSummary: List[ProductionBatchSummary] = Field(default_factory=list)
|
|
reasoningInputs: ReasoningInputs
|
|
userActionsRequired: int = Field(..., description="Number of actions needing approval")
|
|
durationSeconds: Optional[int] = Field(None, description="How long orchestration took")
|
|
aiAssisted: bool = Field(False, description="Whether AI insights were used")
|
|
message: Optional[str] = Field(None, description="User-friendly message")
|
|
|
|
|
|
class ActionButton(BaseModel):
|
|
"""Action button configuration"""
|
|
label: str
|
|
type: str = Field(..., description="Button type: primary, secondary, tertiary")
|
|
action: str = Field(..., description="Action identifier")
|
|
|
|
|
|
class ActionItem(BaseModel):
|
|
"""Individual action requiring user attention"""
|
|
id: str
|
|
type: str = Field(..., description="Action type")
|
|
urgency: str = Field(..., description="Urgency: critical, important, normal")
|
|
title: str
|
|
subtitle: str
|
|
reasoning: str = Field(..., description="Why this action is needed")
|
|
consequence: str = Field(..., description="What happens if not done")
|
|
amount: Optional[float] = Field(None, description="Amount for financial actions")
|
|
currency: Optional[str] = Field(None, description="Currency code")
|
|
actions: List[ActionButton]
|
|
estimatedTimeMinutes: int
|
|
|
|
|
|
class ActionQueueResponse(BaseModel):
|
|
"""Prioritized queue of actions"""
|
|
actions: List[ActionItem]
|
|
totalActions: int
|
|
criticalCount: int
|
|
importantCount: int
|
|
|
|
|
|
class ProductionTimelineItem(BaseModel):
|
|
"""Individual production batch in timeline"""
|
|
id: str
|
|
batchNumber: str
|
|
productName: str
|
|
quantity: float
|
|
unit: str
|
|
plannedStartTime: Optional[str]
|
|
plannedEndTime: Optional[str]
|
|
actualStartTime: Optional[str]
|
|
status: str
|
|
statusIcon: str
|
|
statusText: str
|
|
progress: int = Field(..., ge=0, le=100, description="Progress percentage")
|
|
readyBy: Optional[str]
|
|
priority: str
|
|
reasoning: str
|
|
|
|
|
|
class ProductionTimelineResponse(BaseModel):
|
|
"""Today's production timeline"""
|
|
timeline: List[ProductionTimelineItem]
|
|
totalBatches: int
|
|
completedBatches: int
|
|
inProgressBatches: int
|
|
pendingBatches: int
|
|
|
|
|
|
class InsightCard(BaseModel):
|
|
"""Individual insight card"""
|
|
label: str
|
|
value: str
|
|
detail: str
|
|
color: str = Field(..., description="Color: green, amber, red")
|
|
|
|
|
|
class InsightsResponse(BaseModel):
|
|
"""Key insights grid"""
|
|
savings: InsightCard
|
|
inventory: InsightCard
|
|
waste: InsightCard
|
|
deliveries: InsightCard
|
|
|
|
|
|
# ============================================================
|
|
# API Endpoints
|
|
# ============================================================
|
|
|
|
@router.get("/health-status", response_model=BakeryHealthStatusResponse)
|
|
async def get_bakery_health_status(
|
|
tenant_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> BakeryHealthStatusResponse:
|
|
"""
|
|
Get overall bakery health status
|
|
|
|
This is the top-level indicator showing if the bakery is running smoothly
|
|
or if there are issues requiring attention.
|
|
"""
|
|
try:
|
|
dashboard_service = DashboardService(db)
|
|
|
|
# Gather metrics from various services
|
|
# In a real implementation, these would be fetched from respective services
|
|
# For now, we'll make HTTP calls to the services
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# Get alerts
|
|
try:
|
|
alerts_response = await client.get(
|
|
f"http://alert-processor-service:8000/api/v1/tenants/{tenant_id}/alerts/summary"
|
|
)
|
|
alerts_data = alerts_response.json() if alerts_response.status_code == 200 else {}
|
|
critical_alerts = alerts_data.get("critical_count", 0)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch alerts: {e}")
|
|
critical_alerts = 0
|
|
|
|
# Get pending PO count
|
|
try:
|
|
po_response = await client.get(
|
|
f"http://procurement-service:8000/api/v1/tenants/{tenant_id}/purchase-orders",
|
|
params={"status": "pending_approval", "limit": 100}
|
|
)
|
|
po_data = po_response.json() if po_response.status_code == 200 else {}
|
|
pending_approvals = len(po_data.get("items", []))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch POs: {e}")
|
|
pending_approvals = 0
|
|
|
|
# Get production delays
|
|
try:
|
|
prod_response = await client.get(
|
|
f"http://production-service:8000/api/v1/tenants/{tenant_id}/production-batches",
|
|
params={"status": "ON_HOLD", "limit": 100}
|
|
)
|
|
prod_data = prod_response.json() if prod_response.status_code == 200 else {}
|
|
production_delays = len(prod_data.get("items", []))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch production batches: {e}")
|
|
production_delays = 0
|
|
|
|
# Get inventory status
|
|
try:
|
|
inv_response = await client.get(
|
|
f"http://inventory-service:8000/api/v1/tenants/{tenant_id}/inventory/dashboard/stock-status"
|
|
)
|
|
inv_data = inv_response.json() if inv_response.status_code == 200 else {}
|
|
out_of_stock_count = inv_data.get("out_of_stock_count", 0)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch inventory: {e}")
|
|
out_of_stock_count = 0
|
|
|
|
# System errors (would come from monitoring system)
|
|
system_errors = 0
|
|
|
|
# Calculate health status
|
|
health_status = await dashboard_service.get_bakery_health_status(
|
|
tenant_id=tenant_id,
|
|
critical_alerts=critical_alerts,
|
|
pending_approvals=pending_approvals,
|
|
production_delays=production_delays,
|
|
out_of_stock_count=out_of_stock_count,
|
|
system_errors=system_errors
|
|
)
|
|
|
|
return BakeryHealthStatusResponse(**health_status)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting health status: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/orchestration-summary", response_model=OrchestrationSummaryResponse)
|
|
async def get_orchestration_summary(
|
|
tenant_id: str,
|
|
run_id: Optional[str] = Query(None, description="Specific run ID, or latest if not provided"),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> OrchestrationSummaryResponse:
|
|
"""
|
|
Get narrative summary of what the orchestrator did
|
|
|
|
This provides transparency into the automation, showing what was planned
|
|
and why, helping build user trust in the system.
|
|
"""
|
|
try:
|
|
dashboard_service = DashboardService(db)
|
|
|
|
# Get orchestration summary
|
|
summary = await dashboard_service.get_orchestration_summary(
|
|
tenant_id=tenant_id,
|
|
last_run_id=run_id
|
|
)
|
|
|
|
# Enhance with detailed PO and batch summaries
|
|
if summary["purchaseOrdersCreated"] > 0:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
try:
|
|
po_response = await client.get(
|
|
f"http://procurement-service:8000/api/v1/tenants/{tenant_id}/purchase-orders",
|
|
params={"status": "pending_approval", "limit": 10}
|
|
)
|
|
if po_response.status_code == 200:
|
|
pos = po_response.json().get("items", [])
|
|
summary["purchaseOrdersSummary"] = [
|
|
PurchaseOrderSummary(
|
|
supplierName=po.get("supplier_name", "Unknown"),
|
|
itemCategories=[item.get("ingredient_name", "Item") for item in po.get("items", [])[:3]],
|
|
totalAmount=float(po.get("total_amount", 0))
|
|
)
|
|
for po in pos[:5] # Show top 5
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch PO details: {e}")
|
|
|
|
if summary["productionBatchesCreated"] > 0:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
try:
|
|
batch_response = await client.get(
|
|
f"http://production-service:8000/api/v1/tenants/{tenant_id}/production-batches/today"
|
|
)
|
|
if batch_response.status_code == 200:
|
|
batches = batch_response.json().get("batches", [])
|
|
summary["productionBatchesSummary"] = [
|
|
ProductionBatchSummary(
|
|
productName=batch.get("product_name", "Unknown"),
|
|
quantity=batch.get("planned_quantity", 0),
|
|
readyByTime=batch.get("planned_end_time", "")
|
|
)
|
|
for batch in batches[:5] # Show top 5
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch batch details: {e}")
|
|
|
|
return OrchestrationSummaryResponse(**summary)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting orchestration summary: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/action-queue", response_model=ActionQueueResponse)
|
|
async def get_action_queue(
|
|
tenant_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> ActionQueueResponse:
|
|
"""
|
|
Get prioritized queue of actions requiring user attention
|
|
|
|
This is the core of the JTBD dashboard - showing exactly what the user
|
|
needs to do right now, prioritized by urgency and impact.
|
|
"""
|
|
try:
|
|
dashboard_service = DashboardService(db)
|
|
|
|
# Fetch data from various services
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# Get pending POs
|
|
pending_pos = []
|
|
try:
|
|
po_response = await client.get(
|
|
f"http://procurement-service:8000/api/v1/tenants/{tenant_id}/purchase-orders",
|
|
params={"status": "pending_approval", "limit": 20}
|
|
)
|
|
if po_response.status_code == 200:
|
|
pending_pos = po_response.json().get("items", [])
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch pending POs: {e}")
|
|
|
|
# Get critical alerts
|
|
critical_alerts = []
|
|
try:
|
|
alerts_response = await client.get(
|
|
f"http://alert-processor-service:8000/api/v1/tenants/{tenant_id}/alerts",
|
|
params={"severity": "critical", "resolved": False, "limit": 20}
|
|
)
|
|
if alerts_response.status_code == 200:
|
|
critical_alerts = alerts_response.json().get("alerts", [])
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch alerts: {e}")
|
|
|
|
# Get onboarding status
|
|
onboarding_incomplete = False
|
|
onboarding_steps = []
|
|
try:
|
|
onboarding_response = await client.get(
|
|
f"http://auth:8000/api/v1/tenants/{tenant_id}/onboarding-progress"
|
|
)
|
|
if onboarding_response.status_code == 200:
|
|
onboarding_data = onboarding_response.json()
|
|
onboarding_incomplete = not onboarding_data.get("completed", True)
|
|
onboarding_steps = onboarding_data.get("steps", [])
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch onboarding status: {e}")
|
|
|
|
# Build action queue
|
|
actions = await dashboard_service.get_action_queue(
|
|
tenant_id=tenant_id,
|
|
pending_pos=pending_pos,
|
|
critical_alerts=critical_alerts,
|
|
onboarding_incomplete=onboarding_incomplete,
|
|
onboarding_steps=onboarding_steps
|
|
)
|
|
|
|
# Count by urgency
|
|
critical_count = sum(1 for a in actions if a["urgency"] == "critical")
|
|
important_count = sum(1 for a in actions if a["urgency"] == "important")
|
|
|
|
return ActionQueueResponse(
|
|
actions=[ActionItem(**action) for action in actions[:10]], # Show top 10
|
|
totalActions=len(actions),
|
|
criticalCount=critical_count,
|
|
importantCount=important_count
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting action queue: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/production-timeline", response_model=ProductionTimelineResponse)
|
|
async def get_production_timeline(
|
|
tenant_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> ProductionTimelineResponse:
|
|
"""
|
|
Get today's production timeline
|
|
|
|
Shows what's being made today in chronological order with status and progress.
|
|
"""
|
|
try:
|
|
dashboard_service = DashboardService(db)
|
|
|
|
# Fetch today's production batches
|
|
batches = []
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
try:
|
|
response = await client.get(
|
|
f"http://production-service:8000/api/v1/tenants/{tenant_id}/production-batches/today"
|
|
)
|
|
if response.status_code == 200:
|
|
batches = response.json().get("batches", [])
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch production batches: {e}")
|
|
|
|
# Transform to timeline format
|
|
timeline = await dashboard_service.get_production_timeline(
|
|
tenant_id=tenant_id,
|
|
batches=batches
|
|
)
|
|
|
|
# Count by status
|
|
completed = sum(1 for item in timeline if item["status"] == "COMPLETED")
|
|
in_progress = sum(1 for item in timeline if item["status"] == "IN_PROGRESS")
|
|
pending = sum(1 for item in timeline if item["status"] == "PENDING")
|
|
|
|
return ProductionTimelineResponse(
|
|
timeline=[ProductionTimelineItem(**item) for item in timeline],
|
|
totalBatches=len(timeline),
|
|
completedBatches=completed,
|
|
inProgressBatches=in_progress,
|
|
pendingBatches=pending
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting production timeline: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/insights", response_model=InsightsResponse)
|
|
async def get_insights(
|
|
tenant_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> InsightsResponse:
|
|
"""
|
|
Get key insights for dashboard grid
|
|
|
|
Provides glanceable metrics on savings, inventory, waste, and deliveries.
|
|
"""
|
|
try:
|
|
dashboard_service = DashboardService(db)
|
|
|
|
# Fetch data from various services
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# Sustainability data
|
|
sustainability_data = {}
|
|
try:
|
|
response = await client.get(
|
|
f"http://inventory-service:8000/api/v1/tenants/{tenant_id}/sustainability/widget"
|
|
)
|
|
if response.status_code == 200:
|
|
sustainability_data = response.json()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch sustainability data: {e}")
|
|
|
|
# Inventory data
|
|
inventory_data = {}
|
|
try:
|
|
response = await client.get(
|
|
f"http://inventory-service:8000/api/v1/tenants/{tenant_id}/inventory/dashboard/stock-status"
|
|
)
|
|
if response.status_code == 200:
|
|
inventory_data = response.json()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch inventory data: {e}")
|
|
|
|
# Savings data (mock for now)
|
|
savings_data = {
|
|
"weekly_savings": 124,
|
|
"trend_percentage": 12
|
|
}
|
|
|
|
# Calculate insights
|
|
insights = await dashboard_service.calculate_insights(
|
|
tenant_id=tenant_id,
|
|
sustainability_data=sustainability_data,
|
|
inventory_data=inventory_data,
|
|
savings_data=savings_data
|
|
)
|
|
|
|
return InsightsResponse(
|
|
savings=InsightCard(**insights["savings"]),
|
|
inventory=InsightCard(**insights["inventory"]),
|
|
waste=InsightCard(**insights["waste"]),
|
|
deliveries=InsightCard(**insights["deliveries"])
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting insights: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|