mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
chore: UI enhancements for workflow runs view (#142)
* add local state in filters * feat: add sorting feature by duration * chore: refactor workfow run view
This commit is contained in:
parent
6827744327
commit
5fe1c8ce2f
23 changed files with 1014 additions and 479 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from api.db.base_client import BaseDBClient
|
from api.db.base_client import BaseDBClient
|
||||||
|
from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause
|
||||||
from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel
|
from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel
|
||||||
|
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||||
|
|
||||||
|
|
||||||
class CampaignClient(BaseDBClient):
|
class CampaignClient(BaseDBClient):
|
||||||
|
|
@ -165,6 +167,89 @@ class CampaignClient(BaseDBClient):
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_campaign_runs_paginated(
|
||||||
|
self,
|
||||||
|
campaign_id: int,
|
||||||
|
organization_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
filters: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: Optional[str] = "desc",
|
||||||
|
) -> tuple[list[WorkflowRunResponseSchema], int]:
|
||||||
|
"""Get workflow runs for a campaign with pagination, filters and sorting"""
|
||||||
|
async with self.async_session() as session:
|
||||||
|
# First verify campaign belongs to organization
|
||||||
|
campaign_query = select(CampaignModel).where(
|
||||||
|
CampaignModel.id == campaign_id,
|
||||||
|
CampaignModel.organization_id == organization_id,
|
||||||
|
)
|
||||||
|
campaign_result = await session.execute(campaign_query)
|
||||||
|
campaign = campaign_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not campaign:
|
||||||
|
raise ValueError(f"Campaign {campaign_id} not found")
|
||||||
|
|
||||||
|
# Build base query
|
||||||
|
base_query = select(WorkflowRunModel).where(
|
||||||
|
WorkflowRunModel.campaign_id == campaign_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
base_query = apply_workflow_run_filters(base_query, filters)
|
||||||
|
|
||||||
|
# Count total with filters
|
||||||
|
count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id))
|
||||||
|
count_result = await session.execute(count_query)
|
||||||
|
total_count = count_result.scalar()
|
||||||
|
|
||||||
|
# Get paginated results with filters and sorting
|
||||||
|
order_clause = get_workflow_run_order_clause(sort_by, sort_order)
|
||||||
|
result = await session.execute(
|
||||||
|
base_query.order_by(order_clause).limit(limit).offset(offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
runs = [
|
||||||
|
WorkflowRunResponseSchema.model_validate(
|
||||||
|
{
|
||||||
|
"id": run.id,
|
||||||
|
"workflow_id": run.workflow_id,
|
||||||
|
"name": run.name,
|
||||||
|
"mode": run.mode,
|
||||||
|
"created_at": run.created_at,
|
||||||
|
"is_completed": run.is_completed,
|
||||||
|
"recording_url": run.recording_url,
|
||||||
|
"transcript_url": run.transcript_url,
|
||||||
|
"cost_info": {
|
||||||
|
"dograh_token_usage": (
|
||||||
|
run.cost_info.get("dograh_token_usage")
|
||||||
|
if run.cost_info
|
||||||
|
and "dograh_token_usage" in run.cost_info
|
||||||
|
else round(
|
||||||
|
float(run.cost_info.get("total_cost_usd", 0)) * 100,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if run.cost_info and "total_cost_usd" in run.cost_info
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"call_duration_seconds": int(
|
||||||
|
round(run.cost_info.get("call_duration_seconds") or 0)
|
||||||
|
)
|
||||||
|
if run.cost_info
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
if run.cost_info
|
||||||
|
else None,
|
||||||
|
"definition_id": run.definition_id,
|
||||||
|
"initial_context": run.initial_context,
|
||||||
|
"gathered_context": run.gathered_context,
|
||||||
|
"call_type": run.call_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for run in result.scalars().all()
|
||||||
|
]
|
||||||
|
return runs, total_count
|
||||||
|
|
||||||
async def get_campaign_by_id(self, campaign_id: int) -> Optional[CampaignModel]:
|
async def get_campaign_by_id(self, campaign_id: int) -> Optional[CampaignModel]:
|
||||||
"""Get campaign by ID without organization check (for internal use)"""
|
"""Get campaign by ID without organization check (for internal use)"""
|
||||||
async with self.async_session() as session:
|
async with self.async_session() as session:
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,47 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Integer, and_, cast, func
|
from sqlalchemy import Float, Integer, and_, cast, func
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from api.db.models import WorkflowRunModel
|
from api.db.models import WorkflowRunModel
|
||||||
|
|
||||||
|
|
||||||
|
def get_workflow_run_order_clause(
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: str = "desc",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the order clause for workflow run queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sort_by: Field to sort by ('duration', 'created_at', etc.)
|
||||||
|
sort_order: 'asc' or 'desc'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SQLAlchemy order clause
|
||||||
|
"""
|
||||||
|
# Determine sort column
|
||||||
|
if sort_by == "duration":
|
||||||
|
sort_column = WorkflowRunModel.cost_info.op("->>")(
|
||||||
|
"call_duration_seconds"
|
||||||
|
).cast(Float)
|
||||||
|
else:
|
||||||
|
# Default to created_at
|
||||||
|
sort_column = WorkflowRunModel.created_at
|
||||||
|
|
||||||
|
# Apply sort order
|
||||||
|
if sort_order == "asc":
|
||||||
|
return sort_column.asc().nullslast()
|
||||||
|
else:
|
||||||
|
return sort_column.desc().nullslast()
|
||||||
|
|
||||||
|
|
||||||
# Mapping of attribute names to database fields
|
# Mapping of attribute names to database fields
|
||||||
ATTRIBUTE_FIELD_MAPPING = {
|
ATTRIBUTE_FIELD_MAPPING = {
|
||||||
"dateRange": "created_at",
|
"dateRange": "created_at",
|
||||||
"dispositionCode": "gathered_context.mapped_call_disposition",
|
"dispositionCode": "gathered_context.mapped_call_disposition",
|
||||||
"duration": "usage_info.call_duration_seconds",
|
"duration": "cost_info.call_duration_seconds",
|
||||||
"status": "is_completed",
|
"status": "is_completed",
|
||||||
"tokenUsage": "cost_info.total_cost_usd",
|
"tokenUsage": "cost_info.total_cost_usd",
|
||||||
"runId": "id",
|
"runId": "id",
|
||||||
|
|
@ -153,7 +184,7 @@ def apply_workflow_run_filters(
|
||||||
min_val = value.get("min")
|
min_val = value.get("min")
|
||||||
max_val = value.get("max")
|
max_val = value.get("max")
|
||||||
|
|
||||||
if field == "usage_info.call_duration_seconds":
|
if field == "cost_info.call_duration_seconds":
|
||||||
# Use ->> operator for compatibility with all PostgreSQL versions
|
# Use ->> operator for compatibility with all PostgreSQL versions
|
||||||
# (subscript [] only works in PostgreSQL 14+)
|
# (subscript [] only works in PostgreSQL 14+)
|
||||||
duration_text = cast(WorkflowRunModel.usage_info, JSONB).op("->>")(
|
duration_text = cast(WorkflowRunModel.usage_info, JSONB).op("->>")(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
from api.db.base_client import BaseDBClient
|
from api.db.base_client import BaseDBClient
|
||||||
from api.db.filters import apply_workflow_run_filters
|
from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause
|
||||||
from api.db.models import (
|
from api.db.models import (
|
||||||
OrganizationModel,
|
OrganizationModel,
|
||||||
UserModel,
|
UserModel,
|
||||||
|
|
@ -103,10 +103,16 @@ class WorkflowRunClient(BaseDBClient):
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
filters: Optional[List[Dict[str, Any]]] = None,
|
filters: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: str = "desc",
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""
|
"""
|
||||||
Get paginated workflow runs for superadmin with organization information.
|
Get paginated workflow runs for superadmin with organization information.
|
||||||
Returns tuple of (workflow_runs, total_count).
|
Returns tuple of (workflow_runs, total_count).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sort_by: Field to sort by ('duration', 'created_at', etc.)
|
||||||
|
sort_order: 'asc' or 'desc'
|
||||||
"""
|
"""
|
||||||
async with self.async_session() as session:
|
async with self.async_session() as session:
|
||||||
# Build base query with joins
|
# Build base query with joins
|
||||||
|
|
@ -128,7 +134,8 @@ class WorkflowRunClient(BaseDBClient):
|
||||||
count_result = await session.execute(count_query)
|
count_result = await session.execute(count_query)
|
||||||
total_count = count_result.scalar()
|
total_count = count_result.scalar()
|
||||||
|
|
||||||
# Get paginated results with filters
|
# Get paginated results with filters and sorting
|
||||||
|
order_clause = get_workflow_run_order_clause(sort_by, sort_order)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
base_query.options(
|
base_query.options(
|
||||||
joinedload(WorkflowRunModel.workflow).joinedload(
|
joinedload(WorkflowRunModel.workflow).joinedload(
|
||||||
|
|
@ -138,7 +145,7 @@ class WorkflowRunClient(BaseDBClient):
|
||||||
.joinedload(WorkflowModel.user)
|
.joinedload(WorkflowModel.user)
|
||||||
.joinedload(UserModel.selected_organization),
|
.joinedload(UserModel.selected_organization),
|
||||||
)
|
)
|
||||||
.order_by(WorkflowRunModel.created_at.desc())
|
.order_by(order_clause)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
|
@ -225,6 +232,8 @@ class WorkflowRunClient(BaseDBClient):
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
filters: Optional[List[Dict[str, Any]]] = None,
|
filters: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: Optional[str] = "desc",
|
||||||
) -> tuple[list[WorkflowRunResponseSchema], int]:
|
) -> tuple[list[WorkflowRunResponseSchema], int]:
|
||||||
async with self.async_session() as session:
|
async with self.async_session() as session:
|
||||||
# Build base query
|
# Build base query
|
||||||
|
|
@ -251,11 +260,10 @@ class WorkflowRunClient(BaseDBClient):
|
||||||
count_result = await session.execute(count_query)
|
count_result = await session.execute(count_query)
|
||||||
total_count = count_result.scalar()
|
total_count = count_result.scalar()
|
||||||
|
|
||||||
# Get paginated results with filters
|
# Get paginated results with filters and sorting
|
||||||
|
order_clause = get_workflow_run_order_clause(sort_by, sort_order)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
base_query.order_by(WorkflowRunModel.created_at.desc())
|
base_query.order_by(order_clause).limit(limit).offset(offset)
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
)
|
||||||
runs = [
|
runs = [
|
||||||
WorkflowRunResponseSchema.model_validate(
|
WorkflowRunResponseSchema.model_validate(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
|
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
|
||||||
|
|
@ -91,6 +92,16 @@ class WorkflowRunResponse(BaseModel):
|
||||||
completed_at: Optional[datetime]
|
completed_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRunsResponse(BaseModel):
|
||||||
|
"""Paginated response for campaign workflow runs"""
|
||||||
|
|
||||||
|
runs: List[dict] # WorkflowRunResponseSchema from schemas
|
||||||
|
total_count: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
class CampaignProgressResponse(BaseModel):
|
class CampaignProgressResponse(BaseModel):
|
||||||
campaign_id: int
|
campaign_id: int
|
||||||
state: str
|
state: str
|
||||||
|
|
@ -296,21 +307,65 @@ async def pause_campaign(
|
||||||
@router.get("/{campaign_id}/runs")
|
@router.get("/{campaign_id}/runs")
|
||||||
async def get_campaign_runs(
|
async def get_campaign_runs(
|
||||||
campaign_id: int,
|
campaign_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 50,
|
||||||
|
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
||||||
|
sort_by: Optional[str] = Query(
|
||||||
|
None, description="Field to sort by (e.g., 'duration', 'created_at')"
|
||||||
|
),
|
||||||
|
sort_order: Optional[str] = Query(
|
||||||
|
"desc", description="Sort order ('asc' or 'desc')"
|
||||||
|
),
|
||||||
user: UserModel = Depends(get_user),
|
user: UserModel = Depends(get_user),
|
||||||
) -> List[WorkflowRunResponse]:
|
) -> CampaignRunsResponse:
|
||||||
"""Get campaign workflow runs"""
|
"""Get campaign workflow runs with pagination, filters and sorting"""
|
||||||
runs = await db_client.get_campaign_runs(campaign_id, user.selected_organization_id)
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
return [
|
# Parse filters if provided
|
||||||
WorkflowRunResponse(
|
filter_criteria = []
|
||||||
id=run.id,
|
if filters:
|
||||||
workflow_id=run.workflow_id,
|
try:
|
||||||
state="completed" if run.is_completed else "running",
|
filter_criteria = json.loads(filters)
|
||||||
created_at=run.created_at,
|
except json.JSONDecodeError:
|
||||||
completed_at=run.created_at if run.is_completed else None,
|
raise HTTPException(status_code=400, detail="Invalid filter format")
|
||||||
|
|
||||||
|
# Restrict allowed filter attributes for regular users
|
||||||
|
allowed_attributes = {
|
||||||
|
"dateRange",
|
||||||
|
"dispositionCode",
|
||||||
|
"duration",
|
||||||
|
"status",
|
||||||
|
"tokenUsage",
|
||||||
|
}
|
||||||
|
for filter_item in filter_criteria:
|
||||||
|
attribute = filter_item.get("attribute")
|
||||||
|
if attribute and attribute not in allowed_attributes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail=f"Invalid attribute '{attribute}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
runs, total_count = await db_client.get_campaign_runs_paginated(
|
||||||
|
campaign_id,
|
||||||
|
user.selected_organization_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
filters=filter_criteria if filter_criteria else None,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_order=sort_order,
|
||||||
)
|
)
|
||||||
for run in runs
|
except ValueError as e:
|
||||||
]
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
total_pages = (total_count + limit - 1) // limit
|
||||||
|
|
||||||
|
return CampaignRunsResponse(
|
||||||
|
runs=[run.model_dump() for run in runs],
|
||||||
|
total_count=total_count,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/resume")
|
@router.post("/{campaign_id}/resume")
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,12 @@ async def get_workflow_runs(
|
||||||
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
|
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
|
||||||
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
|
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
|
||||||
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
||||||
|
sort_by: Optional[str] = Query(
|
||||||
|
None, description="Field to sort by (e.g., 'duration', 'created_at')"
|
||||||
|
),
|
||||||
|
sort_order: Optional[str] = Query(
|
||||||
|
"desc", description="Sort order ('asc' or 'desc')"
|
||||||
|
),
|
||||||
user: UserModel = Depends(get_superuser),
|
user: UserModel = Depends(get_superuser),
|
||||||
) -> SuperuserWorkflowRunsListResponse:
|
) -> SuperuserWorkflowRunsListResponse:
|
||||||
"""
|
"""
|
||||||
|
|
@ -124,8 +130,16 @@ async def get_workflow_runs(
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid filter format")
|
raise HTTPException(status_code=400, detail="Invalid filter format")
|
||||||
|
|
||||||
|
# Validate sort_order
|
||||||
|
if sort_order not in ("asc", "desc"):
|
||||||
|
sort_order = "desc"
|
||||||
|
|
||||||
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
|
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
|
||||||
limit=limit, offset=offset, filters=filter_criteria
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
filters=filter_criteria,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_order=sort_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
total_pages = (total_count + limit - 1) // limit # Ceiling division
|
total_pages = (total_count + limit - 1) // limit # Ceiling division
|
||||||
|
|
|
||||||
|
|
@ -666,10 +666,16 @@ async def get_workflow_runs(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
|
||||||
|
sort_by: Optional[str] = Query(
|
||||||
|
None, description="Field to sort by (e.g., 'duration', 'created_at')"
|
||||||
|
),
|
||||||
|
sort_order: Optional[str] = Query(
|
||||||
|
"desc", description="Sort order ('asc' or 'desc')"
|
||||||
|
),
|
||||||
user: UserModel = Depends(get_user),
|
user: UserModel = Depends(get_user),
|
||||||
) -> WorkflowRunsResponse:
|
) -> WorkflowRunsResponse:
|
||||||
"""
|
"""
|
||||||
Get workflow runs with optional filtering.
|
Get workflow runs with optional filtering and sorting.
|
||||||
|
|
||||||
Filters should be provided as a JSON-encoded array of filter criteria.
|
Filters should be provided as a JSON-encoded array of filter criteria.
|
||||||
Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}]
|
Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}]
|
||||||
|
|
@ -699,23 +705,15 @@ async def get_workflow_runs(
|
||||||
status_code=403, detail=f"Invalid attribute '{attribute}'"
|
status_code=403, detail=f"Invalid attribute '{attribute}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply filters if any
|
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
|
||||||
if filter_criteria:
|
workflow_id,
|
||||||
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
|
organization_id=user.selected_organization_id,
|
||||||
workflow_id,
|
limit=limit,
|
||||||
organization_id=user.selected_organization_id,
|
offset=offset,
|
||||||
limit=limit,
|
filters=filter_criteria if filter_criteria else None,
|
||||||
offset=offset,
|
sort_by=sort_by,
|
||||||
filters=filter_criteria,
|
sort_order=sort_order,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Use existing logic for unfiltered results
|
|
||||||
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
|
|
||||||
workflow_id,
|
|
||||||
organization_id=user.selected_organization_id,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
total_pages = (total_count + limit - 1) // limit
|
total_pages = (total_count + limit - 1) // limit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
|
import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCampaignApiV1CampaignCampaignIdGet,
|
getCampaignApiV1CampaignCampaignIdGet,
|
||||||
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
|
|
||||||
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
|
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
|
||||||
pauseCampaignApiV1CampaignCampaignIdPausePost,
|
pauseCampaignApiV1CampaignCampaignIdPausePost,
|
||||||
resumeCampaignApiV1CampaignCampaignIdResumePost,
|
resumeCampaignApiV1CampaignCampaignIdResumePost,
|
||||||
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
|
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
|
||||||
import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
|
import type { CampaignResponse } from '@/client/types.gen';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import {
|
import { CampaignRuns } from '@/components/workflow-runs';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
|
|
||||||
export default function CampaignDetailPage() {
|
export default function CampaignDetailPage() {
|
||||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const campaignId = parseInt(params.campaignId as string);
|
const campaignId = parseInt(params.campaignId as string);
|
||||||
|
|
||||||
// Redirect if not authenticated
|
// Redirect if not authenticated
|
||||||
|
|
@ -44,10 +37,6 @@ export default function CampaignDetailPage() {
|
||||||
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
|
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
|
||||||
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
|
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
|
||||||
|
|
||||||
// Runs state
|
|
||||||
const [runs, setRuns] = useState<WorkflowRunResponse[]>([]);
|
|
||||||
const [isLoadingRuns, setIsLoadingRuns] = useState(false);
|
|
||||||
|
|
||||||
// Action state
|
// Action state
|
||||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||||
|
|
||||||
|
|
@ -77,36 +66,10 @@ export default function CampaignDetailPage() {
|
||||||
}
|
}
|
||||||
}, [user, getAccessToken, campaignId]);
|
}, [user, getAccessToken, campaignId]);
|
||||||
|
|
||||||
// Fetch campaign runs
|
|
||||||
const fetchCampaignRuns = useCallback(async () => {
|
|
||||||
if (!user) return;
|
|
||||||
setIsLoadingRuns(true);
|
|
||||||
try {
|
|
||||||
const accessToken = await getAccessToken();
|
|
||||||
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
|
|
||||||
path: {
|
|
||||||
campaign_id: campaignId,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
setRuns(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch campaign runs:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingRuns(false);
|
|
||||||
}
|
|
||||||
}, [user, getAccessToken, campaignId]);
|
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCampaign();
|
fetchCampaign();
|
||||||
fetchCampaignRuns();
|
}, [fetchCampaign]);
|
||||||
}, [fetchCampaign, fetchCampaignRuns]);
|
|
||||||
|
|
||||||
// Handle back navigation
|
// Handle back navigation
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
|
@ -120,13 +83,6 @@ export default function CampaignDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle run click
|
|
||||||
const handleRunClick = (runId: number) => {
|
|
||||||
if (campaign) {
|
|
||||||
router.push(`/workflow/${campaign.workflow_id}/run/${runId}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle CSV download
|
// Handle CSV download
|
||||||
const handleDownloadCsv = async () => {
|
const handleDownloadCsv = async () => {
|
||||||
if (!user || !campaign || campaign.source_type !== 'csv') return;
|
if (!user || !campaign || campaign.source_type !== 'csv') return;
|
||||||
|
|
@ -497,71 +453,11 @@ export default function CampaignDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Workflow Runs */}
|
{/* Workflow Runs */}
|
||||||
<Card>
|
<CampaignRuns
|
||||||
<CardHeader>
|
campaignId={campaignId}
|
||||||
<CardTitle>Workflow Runs</CardTitle>
|
workflowId={campaign.workflow_id}
|
||||||
<CardDescription>
|
searchParams={searchParams}
|
||||||
Executions triggered by this campaign
|
/>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoadingRuns ? (
|
|
||||||
<div className="animate-pulse space-y-3">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="h-12 bg-muted rounded"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : runs.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Run ID</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
<TableHead className="text-right">Action</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{runs.map((run) => (
|
|
||||||
<TableRow
|
|
||||||
key={run.id}
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleRunClick(run.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={run.state === 'completed' ? 'secondary' : 'default'}>
|
|
||||||
{run.state}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDateTime(run.created_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRunClick(run.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center py-8 text-muted-foreground">
|
|
||||||
{campaign.state === 'created'
|
|
||||||
? 'No runs yet. Start the campaign to begin execution.'
|
|
||||||
: 'No workflow runs found for this campaign.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
@ -31,7 +31,6 @@ import {
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useUserConfig } from '@/context/UserConfigContext';
|
import { useUserConfig } from '@/context/UserConfigContext';
|
||||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
|
||||||
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
|
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
|
||||||
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
||||||
import { impersonateAsSuperadmin } from '@/lib/utils';
|
import { impersonateAsSuperadmin } from '@/lib/utils';
|
||||||
|
|
@ -81,7 +80,6 @@ export default function RunsPage() {
|
||||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
|
|
||||||
// Initialize filters from URL
|
// Initialize filters from URL
|
||||||
|
|
@ -89,6 +87,15 @@ export default function RunsPage() {
|
||||||
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Applied filters are the ones actually used for fetching (only updated on Apply click)
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
|
||||||
|
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortBy, setSortBy] = useState<string | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
// Dialog state for comment editing
|
// Dialog state for comment editing
|
||||||
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
|
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
|
||||||
const [commentRunId, setCommentRunId] = useState<number | null>(null);
|
const [commentRunId, setCommentRunId] = useState<number | null>(null);
|
||||||
|
|
@ -100,7 +107,13 @@ export default function RunsPage() {
|
||||||
// Media preview dialog
|
// Media preview dialog
|
||||||
const mediaPreview = MediaPreviewDialog({ accessToken });
|
const mediaPreview = MediaPreviewDialog({ accessToken });
|
||||||
|
|
||||||
const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => {
|
const fetchRuns = useCallback(async (
|
||||||
|
page: number,
|
||||||
|
filters?: ActiveFilter[],
|
||||||
|
isAutoRefresh = false,
|
||||||
|
sortByParam?: string | null,
|
||||||
|
sortOrderParam?: 'asc' | 'desc'
|
||||||
|
) => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
// Don't show loading state for auto-refresh to prevent UI flicker
|
// Don't show loading state for auto-refresh to prevent UI flicker
|
||||||
|
|
@ -126,7 +139,9 @@ export default function RunsPage() {
|
||||||
query: {
|
query: {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
...(filterParam && { filters: filterParam })
|
...(filterParam && { filters: filterParam }),
|
||||||
|
...(sortByParam && { sort_by: sortByParam }),
|
||||||
|
...(sortOrderParam && { sort_order: sortOrderParam }),
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
|
@ -169,55 +184,42 @@ export default function RunsPage() {
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch runs when token is available and when page changes
|
// Fetch runs when token is available and when page/sort changes
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
fetchRuns(currentPage, activeFilters);
|
fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder);
|
||||||
}
|
}
|
||||||
}, [currentPage, accessToken, activeFilters, fetchRuns]);
|
}, [currentPage, accessToken, appliedFilters, fetchRuns, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Auto-refresh every 5 seconds when enabled and filters are active
|
// Auto-refresh every 5 seconds when enabled and filters are active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only set up interval if auto-refresh is enabled and there are active filters
|
// Only set up interval if auto-refresh is enabled and there are applied filters
|
||||||
if (!autoRefresh || activeFilters.length === 0) {
|
if (!autoRefresh || appliedFilters.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
// Pass true to indicate this is an auto-refresh
|
// Pass true to indicate this is an auto-refresh
|
||||||
fetchRuns(currentPage, activeFilters, true);
|
fetchRuns(currentPage, appliedFilters, true, sortBy, sortOrder);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Cleanup interval on unmount or when dependencies change
|
// Cleanup interval on unmount or when dependencies change
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [currentPage, activeFilters, fetchRuns, autoRefresh]);
|
}, [currentPage, appliedFilters, fetchRuns, autoRefresh, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Update current time every second to show live duration for running calls
|
|
||||||
useEffect(() => {
|
|
||||||
const hasRunningCalls = runs.some(run => !run.is_completed);
|
|
||||||
if (!hasRunningCalls) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
setCurrentTime(Date.now());
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [runs]);
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
updatePageInUrl(page, activeFilters);
|
updatePageInUrl(page, appliedFilters);
|
||||||
fetchRuns(page, activeFilters);
|
fetchRuns(page, appliedFilters, false, sortBy, sortOrder);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyFilters = useCallback(async () => {
|
const handleApplyFilters = useCallback(async () => {
|
||||||
setIsExecutingFilters(true);
|
setIsExecutingFilters(true);
|
||||||
setCurrentPage(1); // Reset to first page when applying filters
|
setCurrentPage(1); // Reset to first page when applying filters
|
||||||
|
setAppliedFilters(activeFilters); // Update applied filters
|
||||||
updatePageInUrl(1, activeFilters);
|
updatePageInUrl(1, activeFilters);
|
||||||
await fetchRuns(1, activeFilters);
|
await fetchRuns(1, activeFilters, false, sortBy, sortOrder);
|
||||||
setIsExecutingFilters(false);
|
setIsExecutingFilters(false);
|
||||||
}, [activeFilters, fetchRuns, updatePageInUrl]);
|
}, [activeFilters, fetchRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||||
setActiveFilters(filters);
|
setActiveFilters(filters);
|
||||||
|
|
@ -226,10 +228,25 @@ export default function RunsPage() {
|
||||||
const handleClearFilters = useCallback(async () => {
|
const handleClearFilters = useCallback(async () => {
|
||||||
setIsExecutingFilters(true);
|
setIsExecutingFilters(true);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setAppliedFilters([]); // Clear applied filters
|
||||||
updatePageInUrl(1, []); // Clear filters from URL
|
updatePageInUrl(1, []); // Clear filters from URL
|
||||||
await fetchRuns(1, []); // Fetch all runs without filters
|
await fetchRuns(1, [], false, sortBy, sortOrder); // Fetch all runs without filters
|
||||||
setIsExecutingFilters(false);
|
setIsExecutingFilters(false);
|
||||||
}, [fetchRuns, updatePageInUrl]);
|
}, [fetchRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: string) => {
|
||||||
|
// Reset to first page when sort changes
|
||||||
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
if (sortBy === field) {
|
||||||
|
// Toggle order if same field
|
||||||
|
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// New field, default to desc
|
||||||
|
setSortBy(field);
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
// Save comment function declared outside JSX (requirement #2)
|
// Save comment function declared outside JSX (requirement #2)
|
||||||
const saveAdminComment = useCallback(async () => {
|
const saveAdminComment = useCallback(async () => {
|
||||||
|
|
@ -265,29 +282,10 @@ export default function RunsPage() {
|
||||||
|
|
||||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||||
|
|
||||||
const calculateDuration = (createdAt: string, isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
|
const calculateDuration = (isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
|
||||||
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
|
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
|
||||||
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
|
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCompleted) {
|
|
||||||
const startTime = new Date(createdAt).getTime();
|
|
||||||
const duration = Math.floor((currentTime - startTime) / 1000);
|
|
||||||
|
|
||||||
// If duration exceeds 5 minutes (300 seconds), show "-" as it's likely an error
|
|
||||||
if (duration > 300) {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration < 60) {
|
|
||||||
return `${duration}s`;
|
|
||||||
} else {
|
|
||||||
const minutes = Math.floor(duration / 60);
|
|
||||||
const seconds = duration % 60;
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '-';
|
return '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -383,9 +381,33 @@ export default function RunsPage() {
|
||||||
<TableHead className="font-semibold">Disposition</TableHead>
|
<TableHead className="font-semibold">Disposition</TableHead>
|
||||||
<TableHead className="font-semibold">Tags</TableHead>
|
<TableHead className="font-semibold">Tags</TableHead>
|
||||||
<TableHead className="font-semibold">Comment</TableHead>
|
<TableHead className="font-semibold">Comment</TableHead>
|
||||||
<TableHead className="font-semibold">Duration</TableHead>
|
<TableHead
|
||||||
|
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
|
||||||
|
onClick={() => handleSort('duration')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Duration
|
||||||
|
{sortBy === 'duration' ? (
|
||||||
|
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||||
<TableHead className="font-semibold">Created At</TableHead>
|
<TableHead
|
||||||
|
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Created At
|
||||||
|
{sortBy === 'created_at' ? (
|
||||||
|
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="font-semibold">Actions</TableHead>
|
<TableHead className="font-semibold">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -393,7 +415,7 @@ export default function RunsPage() {
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={run.id}
|
key={run.id}
|
||||||
className={selectedRowId === run.id ? "bg-blue-50" : ""}>
|
className={selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}>
|
||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="font-mono text-sm">
|
||||||
#{run.id}
|
#{run.id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -422,7 +444,7 @@ export default function RunsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{run.gathered_context?.mapped_call_disposition ? (
|
{run.gathered_context?.mapped_call_disposition ? (
|
||||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
<Badge variant="default">
|
||||||
{run.gathered_context.mapped_call_disposition as string}
|
{run.gathered_context.mapped_call_disposition as string}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -451,7 +473,7 @@ export default function RunsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm whitespace-pre-wrap break-words">
|
<TableCell className="text-sm whitespace-pre-wrap break-words">
|
||||||
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
|
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
|
||||||
{calculateDuration(run.created_at, run.is_completed, run.usage_info)}
|
{calculateDuration(run.is_completed, run.usage_info)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { useUserConfig } from '@/context/UserConfigContext';
|
import { useUserConfig } from '@/context/UserConfigContext';
|
||||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
|
||||||
import { usageFilterAttributes } from '@/lib/filterAttributes';
|
import { usageFilterAttributes } from '@/lib/filterAttributes';
|
||||||
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
||||||
import { ActiveFilter, DateRangeValue } from '@/types/filters';
|
import { ActiveFilter, DateRangeValue } from '@/types/filters';
|
||||||
|
|
@ -534,7 +533,7 @@ export default function UsagePage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{run.disposition ? (
|
{run.disposition ? (
|
||||||
<Badge variant={getDispositionBadgeVariant(run.disposition)}>
|
<Badge variant="default">
|
||||||
{run.disposition}
|
{run.disposition}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
||||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||||
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
import { WorkflowRunsTable } from "@/components/workflow-runs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
||||||
import { useUserConfig } from '@/context/UserConfigContext';
|
import { useUserConfig } from '@/context/UserConfigContext';
|
||||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
|
||||||
import { downloadFile } from "@/lib/files";
|
|
||||||
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||||
|
|
||||||
|
|
@ -44,6 +30,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||||
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortBy, setSortBy] = useState<string | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
const { accessToken } = useUserConfig();
|
const { accessToken } = useUserConfig();
|
||||||
|
|
||||||
// Initialize filters from URL
|
// Initialize filters from URL
|
||||||
|
|
@ -51,8 +41,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
return decodeFiltersFromURL(searchParams, availableAttributes);
|
return decodeFiltersFromURL(searchParams, availableAttributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
|
||||||
|
|
||||||
// Load disposition codes from workflow configuration
|
// Load disposition codes from workflow configuration
|
||||||
const loadDispositionCodes = useCallback(async () => {
|
const loadDispositionCodes = useCallback(async () => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
@ -89,7 +77,12 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
loadDispositionCodes();
|
loadDispositionCodes();
|
||||||
}, [loadDispositionCodes]);
|
}, [loadDispositionCodes]);
|
||||||
|
|
||||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
const fetchWorkflowRuns = useCallback(async (
|
||||||
|
page: number,
|
||||||
|
filters?: ActiveFilter[],
|
||||||
|
sortByParam?: string | null,
|
||||||
|
sortOrderParam?: 'asc' | 'desc'
|
||||||
|
) => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -109,7 +102,9 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
query: {
|
query: {
|
||||||
page: page,
|
page: page,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
...(filterParam && { filters: filterParam })
|
...(filterParam && { filters: filterParam }),
|
||||||
|
...(sortByParam && { sort_by: sortByParam }),
|
||||||
|
...(sortOrderParam && { sort_order: sortOrderParam }),
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
|
@ -152,16 +147,16 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
}, [router, workflowId]);
|
}, [router, workflowId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorkflowRuns(currentPage, activeFilters);
|
fetchWorkflowRuns(currentPage, activeFilters, sortBy, sortOrder);
|
||||||
}, [currentPage, activeFilters, fetchWorkflowRuns]);
|
}, [currentPage, activeFilters, fetchWorkflowRuns, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleApplyFilters = useCallback(async () => {
|
const handleApplyFilters = useCallback(async () => {
|
||||||
setIsExecutingFilters(true);
|
setIsExecutingFilters(true);
|
||||||
setCurrentPage(1); // Reset to first page when applying filters
|
setCurrentPage(1); // Reset to first page when applying filters
|
||||||
updatePageInUrl(1, activeFilters);
|
updatePageInUrl(1, activeFilters);
|
||||||
await fetchWorkflowRuns(1, activeFilters);
|
await fetchWorkflowRuns(1, activeFilters, sortBy, sortOrder);
|
||||||
setIsExecutingFilters(false);
|
setIsExecutingFilters(false);
|
||||||
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
|
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||||
setActiveFilters(filters);
|
setActiveFilters(filters);
|
||||||
|
|
@ -171,182 +166,51 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
||||||
setIsExecutingFilters(true);
|
setIsExecutingFilters(true);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
updatePageInUrl(1, []); // Clear filters from URL
|
updatePageInUrl(1, []); // Clear filters from URL
|
||||||
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
|
await fetchWorkflowRuns(1, [], sortBy, sortOrder); // Fetch all workflows without filters
|
||||||
setIsExecutingFilters(false);
|
setIsExecutingFilters(false);
|
||||||
}, [fetchWorkflowRuns, updatePageInUrl]);
|
}, [fetchWorkflowRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
updatePageInUrl(page, activeFilters);
|
||||||
|
}, [updatePageInUrl, activeFilters]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: string) => {
|
||||||
|
// Reset to first page when sort changes
|
||||||
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
if (sortBy === field) {
|
||||||
|
// Toggle order if same field
|
||||||
|
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// New field, default to desc
|
||||||
|
setSortBy(field);
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8">
|
<div className="container mx-auto py-8">
|
||||||
<div className="mb-6">
|
<WorkflowRunsTable
|
||||||
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
|
runs={workflowRuns}
|
||||||
<FilterBuilder
|
loading={loading}
|
||||||
availableAttributes={configuredAttributes}
|
error={error}
|
||||||
activeFilters={activeFilters}
|
currentPage={currentPage}
|
||||||
onFiltersChange={handleFiltersChange}
|
totalPages={totalPages}
|
||||||
onApplyFilters={handleApplyFilters}
|
totalCount={totalCount}
|
||||||
onClearFilters={handleClearFilters}
|
onPageChange={handlePageChange}
|
||||||
isExecuting={isExecutingFilters}
|
availableAttributes={configuredAttributes}
|
||||||
/>
|
activeFilters={activeFilters}
|
||||||
</div>
|
onFiltersChange={handleFiltersChange}
|
||||||
{loading ? (
|
onApplyFilters={handleApplyFilters}
|
||||||
<div className="flex justify-center">
|
onClearFilters={handleClearFilters}
|
||||||
<div className="animate-pulse">Loading workflow runs...</div>
|
isExecutingFilters={isExecutingFilters}
|
||||||
</div>
|
sortBy={sortBy}
|
||||||
) : error ? (
|
sortOrder={sortOrder}
|
||||||
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
|
onSort={handleSort}
|
||||||
{error}
|
workflowId={workflowId}
|
||||||
</div>
|
accessToken={accessToken}
|
||||||
) : workflowRuns.length === 0 ? (
|
/>
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-muted-foreground">No workflow runs found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Workflow Runs</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Showing {workflowRuns.length} of {totalCount} total runs
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50">
|
|
||||||
<TableHead className="font-semibold">ID</TableHead>
|
|
||||||
<TableHead className="font-semibold">Status</TableHead>
|
|
||||||
<TableHead className="font-semibold">Created At</TableHead>
|
|
||||||
<TableHead className="font-semibold">Call Type</TableHead>
|
|
||||||
<TableHead className="font-semibold">Duration</TableHead>
|
|
||||||
<TableHead className="font-semibold">Disposition</TableHead>
|
|
||||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
|
||||||
<TableHead className="font-semibold">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{workflowRuns.map((run) => (
|
|
||||||
<TableRow
|
|
||||||
key={run.id}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
|
|
||||||
>
|
|
||||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={run.is_completed ? "default" : "secondary"}>
|
|
||||||
{run.is_completed ? "Completed" : "In Progress"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
|
|
||||||
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
{typeof run.cost_info?.call_duration_seconds === 'number'
|
|
||||||
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{run.gathered_context?.mapped_call_disposition ? (
|
|
||||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
|
||||||
{run.gathered_context.mapped_call_disposition as string}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
{typeof run.cost_info?.dograh_token_usage === 'number'
|
|
||||||
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{run.transcript_url && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (accessToken) downloadFile(run.transcript_url, accessToken);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="h-3 w-3 mr-1" />
|
|
||||||
Transcript
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{run.recording_url && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (accessToken) downloadFile(run.recording_url, accessToken);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="h-3 w-3 mr-1" />
|
|
||||||
Recording
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between mt-6">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const newPage = currentPage - 1;
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
updatePageInUrl(newPage, activeFilters);
|
|
||||||
}}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const newPage = currentPage + 1;
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
updatePageInUrl(newPage, activeFilters);
|
|
||||||
}}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -81,6 +81,19 @@ export type CampaignResponse = {
|
||||||
max_concurrency?: number | null;
|
max_concurrency?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated response for campaign workflow runs
|
||||||
|
*/
|
||||||
|
export type CampaignRunsResponse = {
|
||||||
|
runs: Array<{
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
total_count: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignSourceDownloadResponse = {
|
export type CampaignSourceDownloadResponse = {
|
||||||
download_url: string;
|
download_url: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
|
|
@ -1138,14 +1151,6 @@ export type WorkflowRunDetail = {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowRunResponse = {
|
|
||||||
id: number;
|
|
||||||
workflow_id: number;
|
|
||||||
state: string;
|
|
||||||
created_at: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowRunResponseSchema = {
|
export type WorkflowRunResponseSchema = {
|
||||||
id: number;
|
id: number;
|
||||||
workflow_id: number;
|
workflow_id: number;
|
||||||
|
|
@ -1494,6 +1499,27 @@ export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = {
|
||||||
200: unknown;
|
200: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/telephony/cloudonix/cdr';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors = {
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImpersonateApiV1SuperuserImpersonatePostData = {
|
export type ImpersonateApiV1SuperuserImpersonatePostData = {
|
||||||
body: ImpersonateRequest;
|
body: ImpersonateRequest;
|
||||||
headers?: {
|
headers?: {
|
||||||
|
|
@ -1547,6 +1573,14 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = {
|
||||||
* JSON-encoded filter criteria
|
* JSON-encoded filter criteria
|
||||||
*/
|
*/
|
||||||
filters?: string | null;
|
filters?: string | null;
|
||||||
|
/**
|
||||||
|
* Field to sort by (e.g., 'duration', 'created_at')
|
||||||
|
*/
|
||||||
|
sort_by?: string | null;
|
||||||
|
/**
|
||||||
|
* Sort order ('asc' or 'desc')
|
||||||
|
*/
|
||||||
|
sort_order?: string | null;
|
||||||
};
|
};
|
||||||
url: '/api/v1/superuser/workflow-runs';
|
url: '/api/v1/superuser/workflow-runs';
|
||||||
};
|
};
|
||||||
|
|
@ -1934,6 +1968,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
|
||||||
* JSON-encoded filter criteria
|
* JSON-encoded filter criteria
|
||||||
*/
|
*/
|
||||||
filters?: string | null;
|
filters?: string | null;
|
||||||
|
/**
|
||||||
|
* Field to sort by (e.g., 'duration', 'created_at')
|
||||||
|
*/
|
||||||
|
sort_by?: string | null;
|
||||||
|
/**
|
||||||
|
* Sort order ('asc' or 'desc')
|
||||||
|
*/
|
||||||
|
sort_order?: string | null;
|
||||||
};
|
};
|
||||||
url: '/api/v1/workflow/{workflow_id}/runs';
|
url: '/api/v1/workflow/{workflow_id}/runs';
|
||||||
};
|
};
|
||||||
|
|
@ -2601,7 +2643,22 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
|
||||||
path: {
|
path: {
|
||||||
campaign_id: number;
|
campaign_id: number;
|
||||||
};
|
};
|
||||||
query?: never;
|
query?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* JSON-encoded filter criteria
|
||||||
|
*/
|
||||||
|
filters?: string | null;
|
||||||
|
/**
|
||||||
|
* Field to sort by (e.g., 'duration', 'created_at')
|
||||||
|
*/
|
||||||
|
sort_by?: string | null;
|
||||||
|
/**
|
||||||
|
* Sort order ('asc' or 'desc')
|
||||||
|
*/
|
||||||
|
sort_order?: string | null;
|
||||||
|
};
|
||||||
url: '/api/v1/campaign/{campaign_id}/runs';
|
url: '/api/v1/campaign/{campaign_id}/runs';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2622,7 +2679,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses = {
|
||||||
/**
|
/**
|
||||||
* Successful Response
|
* Successful Response
|
||||||
*/
|
*/
|
||||||
200: Array<WorkflowRunResponse>;
|
200: CampaignRunsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses];
|
export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse = GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses[keyof GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
|
@ -25,6 +25,19 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
||||||
const [isFromOpen, setIsFromOpen] = useState(false);
|
const [isFromOpen, setIsFromOpen] = useState(false);
|
||||||
const [isToOpen, setIsToOpen] = useState(false);
|
const [isToOpen, setIsToOpen] = useState(false);
|
||||||
|
|
||||||
|
// Local state for time inputs - only syncs to parent on blur
|
||||||
|
const [fromTime, setFromTime] = useState(value.from?.toTimeString().slice(0, 5) ?? "");
|
||||||
|
const [toTime, setToTime] = useState(value.to?.toTimeString().slice(0, 5) ?? "");
|
||||||
|
|
||||||
|
// Sync local time state when parent value changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFromTime(value.from?.toTimeString().slice(0, 5) ?? "");
|
||||||
|
}, [value.from]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setToTime(value.to?.toTimeString().slice(0, 5) ?? "");
|
||||||
|
}, [value.to]);
|
||||||
|
|
||||||
const formatDate = (date: Date | null) => {
|
const formatDate = (date: Date | null) => {
|
||||||
if (!date) return "Select date";
|
if (!date) return "Select date";
|
||||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
@ -55,10 +68,11 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
||||||
setIsToOpen(false);
|
setIsToOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeChange = (type: 'from' | 'to', timeString: string) => {
|
const handleTimeBlur = (type: 'from' | 'to') => {
|
||||||
|
const timeString = type === 'from' ? fromTime : toTime;
|
||||||
const [hours, minutes] = timeString.split(':').map(Number);
|
const [hours, minutes] = timeString.split(':').map(Number);
|
||||||
const date = type === 'from' ? value.from : value.to;
|
const date = type === 'from' ? value.from : value.to;
|
||||||
if (date) {
|
if (date && !isNaN(hours) && !isNaN(minutes)) {
|
||||||
const newDate = new Date(date);
|
const newDate = new Date(date);
|
||||||
newDate.setHours(hours, minutes);
|
newDate.setHours(hours, minutes);
|
||||||
onChange({ ...value, [type]: newDate });
|
onChange({ ...value, [type]: newDate });
|
||||||
|
|
@ -112,8 +126,9 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
||||||
id="from-time"
|
id="from-time"
|
||||||
type="time"
|
type="time"
|
||||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||||
value={value.from.toTimeString().slice(0, 5)}
|
value={fromTime}
|
||||||
onChange={(e) => handleTimeChange('from', e.target.value)}
|
onChange={(e) => setFromTime(e.target.value)}
|
||||||
|
onBlur={() => handleTimeBlur('from')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -151,8 +166,9 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
||||||
id="to-time"
|
id="to-time"
|
||||||
type="time"
|
type="time"
|
||||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||||
value={value.to.toTimeString().slice(0, 5)}
|
value={toTime}
|
||||||
onChange={(e) => handleTimeChange('to', e.target.value)}
|
onChange={(e) => setToTime(e.target.value)}
|
||||||
|
onBlur={() => handleTimeBlur('to')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { NumberValue } from "@/types/filters";
|
import { NumberValue } from "@/types/filters";
|
||||||
|
|
@ -21,12 +23,23 @@ export const NumberFilter: React.FC<NumberFilterProps> = ({
|
||||||
max,
|
max,
|
||||||
step = 1,
|
step = 1,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Local state for fast typing - only syncs to parent on blur
|
||||||
|
const [localValue, setLocalValue] = useState<string>(value.value?.toString() ?? '');
|
||||||
|
|
||||||
|
// Sync local state when parent value changes (e.g., from URL or clear)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value.value?.toString() ?? '');
|
||||||
|
}, [value.value]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
setLocalValue(e.target.value);
|
||||||
if (newValue === '') {
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (localValue === '') {
|
||||||
onChange({ value: null });
|
onChange({ value: null });
|
||||||
} else {
|
} else {
|
||||||
const num = parseInt(newValue, 10);
|
const num = parseInt(localValue, 10);
|
||||||
if (!isNaN(num)) {
|
if (!isNaN(num)) {
|
||||||
onChange({ value: num });
|
onChange({ value: num });
|
||||||
}
|
}
|
||||||
|
|
@ -40,8 +53,9 @@ export const NumberFilter: React.FC<NumberFilterProps> = ({
|
||||||
<Input
|
<Input
|
||||||
id="number-filter"
|
id="number-filter"
|
||||||
type="number"
|
type="number"
|
||||||
value={value.value ?? ''}
|
value={localValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -24,13 +26,23 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
|
||||||
step = 1,
|
step = 1,
|
||||||
presets = [],
|
presets = [],
|
||||||
}) => {
|
}) => {
|
||||||
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Local state for fast typing - only syncs to parent on blur
|
||||||
const newValue = e.target.value === "" ? null : Number(e.target.value);
|
const [localMin, setLocalMin] = useState<string>(value.min?.toString() ?? "");
|
||||||
|
const [localMax, setLocalMax] = useState<string>(value.max?.toString() ?? "");
|
||||||
|
|
||||||
|
// Sync local state when parent value changes (e.g., from URL, clear, or presets)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalMin(value.min?.toString() ?? "");
|
||||||
|
setLocalMax(value.max?.toString() ?? "");
|
||||||
|
}, [value.min, value.max]);
|
||||||
|
|
||||||
|
const handleMinBlur = () => {
|
||||||
|
const newValue = localMin === "" ? null : Number(localMin);
|
||||||
onChange({ ...value, min: newValue });
|
onChange({ ...value, min: newValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleMaxBlur = () => {
|
||||||
const newValue = e.target.value === "" ? null : Number(e.target.value);
|
const newValue = localMax === "" ? null : Number(localMax);
|
||||||
onChange({ ...value, max: newValue });
|
onChange({ ...value, max: newValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,8 +76,9 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
|
||||||
id="min-value"
|
id="min-value"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={`Min ${unit || 'value'}`}
|
placeholder={`Min ${unit || 'value'}`}
|
||||||
value={value.min ?? ""}
|
value={localMin}
|
||||||
onChange={handleMinChange}
|
onChange={(e) => setLocalMin(e.target.value)}
|
||||||
|
onBlur={handleMinBlur}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
|
|
@ -80,8 +93,9 @@ export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
|
||||||
id="max-value"
|
id="max-value"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={`Max ${unit || 'value'}`}
|
placeholder={`Max ${unit || 'value'}`}
|
||||||
value={value.max ?? ""}
|
value={localMax}
|
||||||
onChange={handleMaxChange}
|
onChange={(e) => setLocalMax(e.target.value)}
|
||||||
|
onBlur={handleMaxBlur}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -14,6 +14,11 @@ interface TagInputFilterProps {
|
||||||
export const TagInputFilter: React.FC<TagInputFilterProps> = ({ value, onChange, error, placeholder="Enter tags (comma separated)" }) => {
|
export const TagInputFilter: React.FC<TagInputFilterProps> = ({ value, onChange, error, placeholder="Enter tags (comma separated)" }) => {
|
||||||
const [text, setText] = useState(value.codes.join(", "));
|
const [text, setText] = useState(value.codes.join(", "));
|
||||||
|
|
||||||
|
// Sync local state when parent value changes (e.g., from URL or clear)
|
||||||
|
useEffect(() => {
|
||||||
|
setText(value.codes.join(", "));
|
||||||
|
}, [value.codes]);
|
||||||
|
|
||||||
const handleBlur = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleBlur = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const tags = e.target.value
|
const tags = e.target.value
|
||||||
.split(/[,\n]/)
|
.split(/[,\n]/)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { TextValue } from "@/types/filters";
|
import { TextValue } from "@/types/filters";
|
||||||
|
|
||||||
|
|
@ -16,12 +18,25 @@ export const TextFilter: React.FC<TextFilterProps> = ({
|
||||||
placeholder = "Enter text",
|
placeholder = "Enter text",
|
||||||
maxLength,
|
maxLength,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Local state for fast typing - only syncs to parent on blur
|
||||||
|
const [localValue, setLocalValue] = useState(value.value || "");
|
||||||
|
|
||||||
|
// Sync local state when parent value changes (e.g., from URL or clear)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value.value || "");
|
||||||
|
}, [value.value]);
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
onChange({ value: localValue });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={value.value || ""}
|
value={localValue}
|
||||||
onChange={(e) => onChange({ value: e.target.value })}
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
className={error ? "border-red-500" : ""}
|
className={error ? "border-red-500" : ""}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
||||||
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
|
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
|
||||||
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
|
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
|
||||||
|
|
||||||
// Check if we're in workflow editor mode - collapse sidebar by default
|
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
|
||||||
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
|
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
|
||||||
|
const isSuperadmin = pathname.startsWith("/superadmin");
|
||||||
|
|
||||||
// If no sidebar needed, just return children
|
// If no sidebar needed, just return children
|
||||||
if (!shouldShowSidebar) {
|
if (!shouldShowSidebar) {
|
||||||
|
|
@ -33,7 +34,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={!isWorkflowEditor}>
|
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}>
|
||||||
<div className="flex min-h-screen w-full">
|
<div className="flex min-h-screen w-full">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="flex-1">
|
<SidebarInset className="flex-1">
|
||||||
|
|
|
||||||
197
ui/src/components/workflow-runs/CampaignRuns.tsx
Normal file
197
ui/src/components/workflow-runs/CampaignRuns.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { getCampaignRunsApiV1CampaignCampaignIdRunsGet } from "@/client/sdk.gen";
|
||||||
|
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||||
|
import { WorkflowRunsTable } from "@/components/workflow-runs";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||||
|
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||||
|
|
||||||
|
interface CampaignRunsProps {
|
||||||
|
campaignId: number;
|
||||||
|
workflowId: number;
|
||||||
|
searchParams?: URLSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignRuns({ campaignId, workflowId, searchParams }: CampaignRunsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { getAccessToken } = useAuth();
|
||||||
|
const [runs, setRuns] = useState<WorkflowRunResponseSchema[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(() => {
|
||||||
|
const pageParam = searchParams?.get('page');
|
||||||
|
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||||
|
});
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortBy, setSortBy] = useState<string | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// Initialize filters from URL
|
||||||
|
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||||
|
return searchParams ? decodeFiltersFromURL(searchParams, availableAttributes) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get access token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchToken = async () => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
setAccessToken(token);
|
||||||
|
};
|
||||||
|
fetchToken();
|
||||||
|
}, [getAccessToken]);
|
||||||
|
|
||||||
|
const fetchCampaignRuns = useCallback(async (
|
||||||
|
page: number,
|
||||||
|
filters?: ActiveFilter[],
|
||||||
|
sortByParam?: string | null,
|
||||||
|
sortOrderParam?: 'asc' | 'desc'
|
||||||
|
) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// Prepare filter data for API
|
||||||
|
let filterParam = undefined;
|
||||||
|
if (filters && filters.length > 0) {
|
||||||
|
const filterData = filters.map(filter => ({
|
||||||
|
attribute: filter.attribute.id,
|
||||||
|
type: filter.attribute.type,
|
||||||
|
value: filter.value
|
||||||
|
}));
|
||||||
|
filterParam = JSON.stringify(filterData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
|
||||||
|
path: { campaign_id: campaignId },
|
||||||
|
query: {
|
||||||
|
page: page,
|
||||||
|
limit: 50,
|
||||||
|
...(filterParam && { filters: filterParam }),
|
||||||
|
...(sortByParam && { sort_by: sortByParam }),
|
||||||
|
...(sortOrderParam && { sort_order: sortOrderParam }),
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error("Failed to fetch campaign runs");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
// The API returns runs as array of dicts, convert to WorkflowRunResponseSchema
|
||||||
|
setRuns((response.data.runs || []) as unknown as WorkflowRunResponseSchema[]);
|
||||||
|
setTotalPages(response.data.total_pages || 1);
|
||||||
|
setTotalCount(response.data.total_count || 0);
|
||||||
|
setCurrentPage(response.data.page || 1);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching campaign runs:", err);
|
||||||
|
setError("Failed to load campaign runs");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [campaignId, accessToken]);
|
||||||
|
|
||||||
|
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', page.toString());
|
||||||
|
|
||||||
|
// Add filters to URL if present
|
||||||
|
if (filters && filters.length > 0) {
|
||||||
|
const filterString = encodeFiltersToURL(filters);
|
||||||
|
if (filterString) {
|
||||||
|
const filterParams = new URLSearchParams(filterString);
|
||||||
|
filterParams.forEach((value, key) => params.set(key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/campaigns/${campaignId}?${params.toString()}`, { scroll: false });
|
||||||
|
}, [router, campaignId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken) {
|
||||||
|
fetchCampaignRuns(currentPage, activeFilters, sortBy, sortOrder);
|
||||||
|
}
|
||||||
|
}, [currentPage, activeFilters, fetchCampaignRuns, accessToken, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const handleApplyFilters = useCallback(async () => {
|
||||||
|
setIsExecutingFilters(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
updatePageInUrl(1, activeFilters);
|
||||||
|
await fetchCampaignRuns(1, activeFilters, sortBy, sortOrder);
|
||||||
|
setIsExecutingFilters(false);
|
||||||
|
}, [activeFilters, fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||||
|
setActiveFilters(filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(async () => {
|
||||||
|
setIsExecutingFilters(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setActiveFilters([]);
|
||||||
|
updatePageInUrl(1, []);
|
||||||
|
await fetchCampaignRuns(1, [], sortBy, sortOrder);
|
||||||
|
setIsExecutingFilters(false);
|
||||||
|
}, [fetchCampaignRuns, updatePageInUrl, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
updatePageInUrl(page, activeFilters);
|
||||||
|
}, [updatePageInUrl, activeFilters]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: string) => {
|
||||||
|
// Reset to first page when sort changes
|
||||||
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
if (sortBy === field) {
|
||||||
|
// Toggle order if same field
|
||||||
|
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// New field, default to desc
|
||||||
|
setSortBy(field);
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
// Use a subset of filter attributes relevant for campaigns
|
||||||
|
const campaignFilterAttributes: FilterAttribute[] = availableAttributes.filter(
|
||||||
|
attr => ['dateRange', 'dispositionCode', 'duration', 'status', 'tokenUsage'].includes(attr.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowRunsTable
|
||||||
|
runs={runs}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
availableAttributes={campaignFilterAttributes}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
onApplyFilters={handleApplyFilters}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
|
isExecutingFilters={isExecutingFilters}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSort={handleSort}
|
||||||
|
workflowId={workflowId}
|
||||||
|
accessToken={accessToken}
|
||||||
|
title="Campaign Workflow Runs"
|
||||||
|
emptyMessage="No workflow runs found for this campaign"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
ui/src/components/workflow-runs/WorkflowRunsTable.tsx
Normal file
251
ui/src/components/workflow-runs/WorkflowRunsTable.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||||
|
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
||||||
|
import { MediaPreviewButtons, MediaPreviewDialog } from "@/components/MediaPreviewDialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ActiveFilter, FilterAttribute } from "@/types/filters";
|
||||||
|
|
||||||
|
export interface WorkflowRunsTableProps {
|
||||||
|
// Data
|
||||||
|
runs: WorkflowRunResponseSchema[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
availableAttributes: FilterAttribute[];
|
||||||
|
activeFilters: ActiveFilter[];
|
||||||
|
onFiltersChange: (filters: ActiveFilter[]) => void;
|
||||||
|
onApplyFilters: () => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
isExecutingFilters: boolean;
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
sortBy?: string | null;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
onSort?: (field: string) => void;
|
||||||
|
|
||||||
|
// Navigation & Actions
|
||||||
|
workflowId: number;
|
||||||
|
accessToken: string | null;
|
||||||
|
|
||||||
|
// Optional customization
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
showFilters?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowRunsTable({
|
||||||
|
runs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalCount,
|
||||||
|
onPageChange,
|
||||||
|
availableAttributes,
|
||||||
|
activeFilters,
|
||||||
|
onFiltersChange,
|
||||||
|
onApplyFilters,
|
||||||
|
onClearFilters,
|
||||||
|
isExecutingFilters,
|
||||||
|
sortBy,
|
||||||
|
sortOrder = 'desc',
|
||||||
|
onSort,
|
||||||
|
workflowId,
|
||||||
|
accessToken,
|
||||||
|
title = "Workflow Run History",
|
||||||
|
subtitle,
|
||||||
|
showFilters = true,
|
||||||
|
emptyMessage = "No workflow runs found",
|
||||||
|
}: WorkflowRunsTableProps) {
|
||||||
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Media preview dialog
|
||||||
|
const mediaPreview = MediaPreviewDialog({ accessToken });
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||||
|
|
||||||
|
const handleRowClick = (runId: number) => {
|
||||||
|
window.open(`/workflow/${workflowId}/run/${runId}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Title and Filters */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">{title}</h1>
|
||||||
|
<FilterBuilder
|
||||||
|
availableAttributes={availableAttributes}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
onApplyFilters={onApplyFilters}
|
||||||
|
onClearFilters={onClearFilters}
|
||||||
|
isExecuting={isExecutingFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="animate-pulse">Loading workflow runs...</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Workflow Runs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{subtitle || `Showing ${runs.length} of ${totalCount} total runs`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableHead className="font-semibold">ID</TableHead>
|
||||||
|
<TableHead className="font-semibold">Status</TableHead>
|
||||||
|
<TableHead className="font-semibold">Created At</TableHead>
|
||||||
|
<TableHead className="font-semibold">Call Type</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="font-semibold cursor-pointer hover:bg-muted/50 select-none"
|
||||||
|
onClick={() => onSort?.('duration')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Duration
|
||||||
|
{sortBy === 'duration' ? (
|
||||||
|
sortOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="font-semibold">Disposition</TableHead>
|
||||||
|
<TableHead className="font-semibold">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<TableRow
|
||||||
|
key={run.id}
|
||||||
|
className={`cursor-pointer hover:bg-muted/50 ${selectedRowId === run.id ? "bg-primary/20 ring-1 ring-primary/50" : ""}`}
|
||||||
|
onClick={() => handleRowClick(run.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={run.is_completed ? "default" : "secondary"}>
|
||||||
|
{run.is_completed ? "Completed" : "In Progress"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
|
||||||
|
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{typeof run.cost_info?.call_duration_seconds === 'number'
|
||||||
|
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{run.gathered_context?.mapped_call_disposition ? (
|
||||||
|
<Badge variant="default">
|
||||||
|
{run.gathered_context.mapped_call_disposition as string}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<MediaPreviewButtons
|
||||||
|
recordingUrl={run.recording_url}
|
||||||
|
transcriptUrl={run.transcript_url}
|
||||||
|
runId={run.id}
|
||||||
|
onOpenAudio={mediaPreview.openAudioModal}
|
||||||
|
onOpenTranscript={mediaPreview.openTranscriptModal}
|
||||||
|
onSelect={setSelectedRowId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Media Preview Dialog */}
|
||||||
|
{mediaPreview.dialog}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
ui/src/components/workflow-runs/index.ts
Normal file
3
ui/src/components/workflow-runs/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { CampaignRuns } from "./CampaignRuns";
|
||||||
|
export type { WorkflowRunsTableProps } from "./WorkflowRunsTable";
|
||||||
|
export { WorkflowRunsTable } from "./WorkflowRunsTable";
|
||||||
|
|
@ -3,23 +3,13 @@
|
||||||
* Update this array when adding new disposition codes
|
* Update this array when adding new disposition codes
|
||||||
*/
|
*/
|
||||||
export const DISPOSITION_CODES = [
|
export const DISPOSITION_CODES = [
|
||||||
'CALLBK',
|
'end_call_tool',
|
||||||
|
'user_hangup',
|
||||||
'call_duration_exceeded',
|
'call_duration_exceeded',
|
||||||
'DAIR',
|
'user_idle_max_duration_exceeded',
|
||||||
'DNC',
|
|
||||||
'HU',
|
|
||||||
'LB',
|
|
||||||
'ND',
|
|
||||||
'NIBP',
|
|
||||||
'NQ',
|
|
||||||
'system_connect_error',
|
'system_connect_error',
|
||||||
'unknown',
|
'unknown',
|
||||||
'user_disqualified',
|
'voicemail_detected'
|
||||||
'user_idle_max_duration_exceeded',
|
|
||||||
'VM',
|
|
||||||
'voicemail_detected',
|
|
||||||
'WN',
|
|
||||||
'XFER',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type DispositionCode = typeof DISPOSITION_CODES[number];
|
export type DispositionCode = typeof DISPOSITION_CODES[number];
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// Color variants for disposition code
|
|
||||||
export const getDispositionBadgeVariant = (code: string | undefined): "default" | "secondary" | "destructive" | "outline" | "success" => {
|
|
||||||
if (!code) return "outline";
|
|
||||||
|
|
||||||
const upperCode = code.toUpperCase();
|
|
||||||
switch (upperCode) {
|
|
||||||
case "XFER":
|
|
||||||
return "success"; // Green color for transfers
|
|
||||||
case "HU":
|
|
||||||
case "NIBP":
|
|
||||||
return "destructive"; // Red color for hang up and NIBP
|
|
||||||
case "VM":
|
|
||||||
return "secondary";
|
|
||||||
default:
|
|
||||||
return "default"; // Default color for all other codes
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue