mirror of https://github.com/kortix-ai/suna.git
show upcoming run in agent page
This commit is contained in:
parent
df29b41a2f
commit
414eb23949
|
@ -12,7 +12,7 @@ class ExternalUserIdGeneratorService(ABC):
|
|||
|
||||
|
||||
class MCPQualifiedNameService(ABC):
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, app_slug: AppSlug) -> str:
|
||||
pass
|
||||
|
|
|
@ -3,7 +3,9 @@ from fastapi.responses import JSONResponse
|
|||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import croniter
|
||||
import pytz
|
||||
|
||||
from .support.factory import TriggerModuleFactory
|
||||
from .support.exceptions import TriggerError, ConfigurationError, ProviderError
|
||||
|
@ -53,6 +55,7 @@ class TriggerResponse(BaseModel):
|
|||
webhook_url: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
config: Dict[str, Any]
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
|
@ -65,10 +68,29 @@ class ProviderResponse(BaseModel):
|
|||
config_schema: Dict[str, Any]
|
||||
|
||||
|
||||
class UpcomingRun(BaseModel):
|
||||
trigger_id: str
|
||||
trigger_name: str
|
||||
trigger_type: str
|
||||
next_run_time: str
|
||||
next_run_time_local: str
|
||||
timezone: str
|
||||
cron_expression: str
|
||||
execution_type: str
|
||||
agent_prompt: Optional[str] = None
|
||||
workflow_id: Optional[str] = None
|
||||
is_active: bool
|
||||
human_readable: str
|
||||
|
||||
|
||||
class UpcomingRunsResponse(BaseModel):
|
||||
upcoming_runs: List[UpcomingRun]
|
||||
total_count: int
|
||||
|
||||
|
||||
def initialize(database: DBConnection):
|
||||
global db, trigger_service, execution_service, provider_service
|
||||
db = database
|
||||
# Initialize workflows API with DB connection
|
||||
set_workflows_db_connection(database)
|
||||
|
||||
|
||||
|
@ -170,7 +192,8 @@ async def get_agent_triggers(
|
|||
is_active=trigger.is_active,
|
||||
webhook_url=webhook_url,
|
||||
created_at=trigger.metadata.created_at.isoformat(),
|
||||
updated_at=trigger.metadata.updated_at.isoformat()
|
||||
updated_at=trigger.metadata.updated_at.isoformat(),
|
||||
config=trigger.config.config
|
||||
))
|
||||
|
||||
return responses
|
||||
|
@ -179,6 +202,159 @@ async def get_agent_triggers(
|
|||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}/upcoming-runs", response_model=UpcomingRunsResponse)
|
||||
async def get_agent_upcoming_runs(
|
||||
agent_id: str,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
if not await is_enabled("agent_triggers"):
|
||||
raise HTTPException(status_code=403, detail="Agent triggers are not enabled")
|
||||
|
||||
await verify_agent_access(agent_id, user_id)
|
||||
|
||||
try:
|
||||
trigger_svc, _, _ = await get_services()
|
||||
triggers = await trigger_svc.get_agent_triggers(agent_id)
|
||||
schedule_triggers = [
|
||||
trigger for trigger in triggers
|
||||
if trigger.is_active and trigger.trigger_type.value == "schedule"
|
||||
]
|
||||
|
||||
upcoming_runs = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for trigger in schedule_triggers:
|
||||
config = trigger.config.config
|
||||
cron_expression = config.get('cron_expression')
|
||||
user_timezone = config.get('timezone', 'UTC')
|
||||
|
||||
if not cron_expression:
|
||||
continue
|
||||
|
||||
try:
|
||||
next_run = _get_next_run_time(cron_expression, user_timezone)
|
||||
if not next_run:
|
||||
continue
|
||||
|
||||
local_tz = pytz.timezone(user_timezone)
|
||||
next_run_local = next_run.astimezone(local_tz)
|
||||
|
||||
human_readable = _get_human_readable_schedule(cron_expression, user_timezone)
|
||||
|
||||
upcoming_runs.append(UpcomingRun(
|
||||
trigger_id=trigger.trigger_id,
|
||||
trigger_name=trigger.config.name,
|
||||
trigger_type=trigger.trigger_type.value,
|
||||
next_run_time=next_run.isoformat(),
|
||||
next_run_time_local=next_run_local.isoformat(),
|
||||
timezone=user_timezone,
|
||||
cron_expression=cron_expression,
|
||||
execution_type=config.get('execution_type', 'agent'),
|
||||
agent_prompt=config.get('agent_prompt'),
|
||||
workflow_id=config.get('workflow_id'),
|
||||
is_active=trigger.is_active,
|
||||
human_readable=human_readable
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error calculating next run for trigger {trigger.trigger_id}: {e}")
|
||||
continue
|
||||
|
||||
upcoming_runs.sort(key=lambda x: x.next_run_time)
|
||||
upcoming_runs = upcoming_runs[:limit]
|
||||
|
||||
return UpcomingRunsResponse(
|
||||
upcoming_runs=upcoming_runs,
|
||||
total_count=len(upcoming_runs)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting upcoming runs: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
def _get_next_run_time(cron_expression: str, user_timezone: str) -> Optional[datetime]:
|
||||
try:
|
||||
tz = pytz.timezone(user_timezone)
|
||||
now_local = datetime.now(tz)
|
||||
|
||||
cron = croniter.croniter(cron_expression, now_local)
|
||||
|
||||
next_run_local = cron.get_next(datetime)
|
||||
next_run_utc = next_run_local.astimezone(timezone.utc)
|
||||
|
||||
return next_run_utc
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating next run time: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_human_readable_schedule(cron_expression: str, user_timezone: str) -> str:
|
||||
try:
|
||||
patterns = {
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/10 * * * *': 'Every 10 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Every hour',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */4 * * *': 'Every 4 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 9 * * *': 'Daily at 9:00 AM',
|
||||
'0 12 * * *': 'Daily at 12:00 PM',
|
||||
'0 18 * * *': 'Daily at 6:00 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
|
||||
'0 9 * * 1': 'Every Monday at 9:00 AM',
|
||||
'0 9 * * 2': 'Every Tuesday at 9:00 AM',
|
||||
'0 9 * * 3': 'Every Wednesday at 9:00 AM',
|
||||
'0 9 * * 4': 'Every Thursday at 9:00 AM',
|
||||
'0 9 * * 5': 'Every Friday at 9:00 AM',
|
||||
'0 9 * * 6': 'Every Saturday at 9:00 AM',
|
||||
'0 9 * * 0': 'Every Sunday at 9:00 AM',
|
||||
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
|
||||
'0 9 15 * *': 'Monthly on the 15th at 9:00 AM',
|
||||
'0 9,17 * * *': 'Daily at 9:00 AM and 5:00 PM',
|
||||
'0 10 * * 0,6': 'Weekends at 10:00 AM',
|
||||
}
|
||||
|
||||
if cron_expression in patterns:
|
||||
description = patterns[cron_expression]
|
||||
if user_timezone != 'UTC':
|
||||
description += f" ({user_timezone})"
|
||||
return description
|
||||
|
||||
parts = cron_expression.split()
|
||||
if len(parts) != 5:
|
||||
return f"Custom schedule: {cron_expression}"
|
||||
|
||||
minute, hour, day, month, weekday = parts
|
||||
|
||||
if minute.isdigit() and hour == '*' and day == '*' and month == '*' and weekday == '*':
|
||||
return f"Every hour at :{minute.zfill(2)}"
|
||||
|
||||
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and weekday == '*':
|
||||
time_str = f"{hour.zfill(2)}:{minute.zfill(2)}"
|
||||
description = f"Daily at {time_str}"
|
||||
if user_timezone != 'UTC':
|
||||
description += f" ({user_timezone})"
|
||||
return description
|
||||
|
||||
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and weekday == '1-5':
|
||||
time_str = f"{hour.zfill(2)}:{minute.zfill(2)}"
|
||||
description = f"Weekdays at {time_str}"
|
||||
if user_timezone != 'UTC':
|
||||
description += f" ({user_timezone})"
|
||||
return description
|
||||
|
||||
return f"Custom schedule: {cron_expression}"
|
||||
|
||||
except Exception:
|
||||
return f"Custom schedule: {cron_expression}"
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/triggers", response_model=TriggerResponse)
|
||||
async def create_agent_trigger(
|
||||
agent_id: str,
|
||||
|
@ -217,7 +393,8 @@ async def create_agent_trigger(
|
|||
is_active=trigger.is_active,
|
||||
webhook_url=webhook_url,
|
||||
created_at=trigger.metadata.created_at.isoformat(),
|
||||
updated_at=trigger.metadata.updated_at.isoformat()
|
||||
updated_at=trigger.metadata.updated_at.isoformat(),
|
||||
config=trigger.config.config
|
||||
)
|
||||
|
||||
except (ValueError, ConfigurationError, ProviderError) as e:
|
||||
|
@ -261,7 +438,8 @@ async def get_trigger(
|
|||
is_active=trigger.is_active,
|
||||
webhook_url=webhook_url,
|
||||
created_at=trigger.metadata.created_at.isoformat(),
|
||||
updated_at=trigger.metadata.updated_at.isoformat()
|
||||
updated_at=trigger.metadata.updated_at.isoformat(),
|
||||
config=trigger.config.config
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting trigger: {e}")
|
||||
|
@ -311,7 +489,8 @@ async def update_trigger(
|
|||
is_active=updated_trigger.is_active,
|
||||
webhook_url=webhook_url,
|
||||
created_at=updated_trigger.metadata.created_at.isoformat(),
|
||||
updated_at=updated_trigger.metadata.updated_at.isoformat()
|
||||
updated_at=updated_trigger.metadata.updated_at.isoformat(),
|
||||
config=updated_trigger.config.config
|
||||
)
|
||||
|
||||
except (ValueError, ConfigurationError, ProviderError) as e:
|
||||
|
@ -331,7 +510,6 @@ async def delete_trigger(
|
|||
|
||||
try:
|
||||
trigger_svc, _, _ = await get_services()
|
||||
|
||||
trigger = await trigger_svc.get_trigger(trigger_id)
|
||||
if not trigger:
|
||||
raise HTTPException(status_code=404, detail="Trigger not found")
|
||||
|
@ -339,7 +517,6 @@ async def delete_trigger(
|
|||
await verify_agent_access(trigger.agent_id, user_id)
|
||||
|
||||
success = await trigger_svc.delete_trigger(trigger_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Trigger not found")
|
||||
|
||||
|
@ -360,7 +537,6 @@ async def trigger_webhook(
|
|||
|
||||
try:
|
||||
trigger_svc, execution_svc, _ = await get_services()
|
||||
|
||||
try:
|
||||
raw_data = await request.json()
|
||||
except:
|
||||
|
@ -368,9 +544,6 @@ async def trigger_webhook(
|
|||
|
||||
result = await trigger_svc.process_trigger_event(trigger_id, raw_data)
|
||||
|
||||
logger.info(f"Webhook trigger result: success={result.success}, should_execute_agent={result.should_execute_agent}, should_execute_workflow={result.should_execute_workflow}")
|
||||
logger.info(f"Trigger result details: workflow_id={result.workflow_id}, agent_prompt={result.agent_prompt}")
|
||||
|
||||
if not result.success:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
|
@ -427,96 +600,3 @@ async def trigger_webhook(
|
|||
status_code=500,
|
||||
content={"success": False, "error": "Internal server error"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/qstash/webhook")
|
||||
async def qstash_webhook(request: Request):
|
||||
if not await is_enabled("agent_triggers"):
|
||||
raise HTTPException(status_code=403, detail="Agent triggers are not enabled")
|
||||
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
logger.info(f"QStash webhook received with headers: {headers}")
|
||||
|
||||
try:
|
||||
raw_data = await request.json()
|
||||
except:
|
||||
raw_data = {}
|
||||
|
||||
logger.info(f"QStash webhook payload: {raw_data}")
|
||||
trigger_id = raw_data.get('trigger_id')
|
||||
if not trigger_id:
|
||||
logger.error("No trigger_id found in QStash webhook payload")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "trigger_id is required in webhook payload"}
|
||||
)
|
||||
|
||||
raw_data.update({
|
||||
"webhook_source": "qstash",
|
||||
"webhook_headers": headers,
|
||||
"webhook_timestamp": datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
trigger_svc, execution_svc, _ = await get_services()
|
||||
|
||||
result = await trigger_svc.process_trigger_event(trigger_id, raw_data)
|
||||
|
||||
logger.info(f"QStash trigger result: success={result.success}, should_execute_agent={result.should_execute_agent}, should_execute_workflow={result.should_execute_workflow}")
|
||||
|
||||
if not result.success:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "error": result.error_message}
|
||||
)
|
||||
|
||||
if result.should_execute_agent or result.should_execute_workflow:
|
||||
trigger = await trigger_svc.get_trigger(trigger_id)
|
||||
if trigger:
|
||||
logger.info(f"Executing QStash trigger for agent {trigger.agent_id}")
|
||||
|
||||
from .domain.entities import TriggerEvent
|
||||
event = TriggerEvent(
|
||||
trigger_id=trigger_id,
|
||||
agent_id=trigger.agent_id,
|
||||
trigger_type=trigger.trigger_type,
|
||||
raw_data=raw_data
|
||||
)
|
||||
|
||||
execution_result = await execution_svc.execute_trigger_result(
|
||||
agent_id=trigger.agent_id,
|
||||
trigger_result=result,
|
||||
trigger_event=event
|
||||
)
|
||||
|
||||
logger.info(f"QStash execution result: {execution_result}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"message": "QStash trigger processed and execution started",
|
||||
"execution": execution_result,
|
||||
"trigger_id": trigger_id,
|
||||
"agent_id": trigger.agent_id
|
||||
})
|
||||
else:
|
||||
logger.warning(f"QStash trigger {trigger_id} not found for execution")
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": f"Trigger {trigger_id} not found"}
|
||||
)
|
||||
|
||||
logger.info(f"QStash webhook processed but no execution needed")
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"message": "QStash trigger processed successfully (no execution needed)",
|
||||
"trigger_id": trigger_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing QStash webhook: {e}")
|
||||
import traceback
|
||||
logger.error(f"QStash webhook error traceback: {traceback.format_exc()}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error"}
|
||||
)
|
||||
|
|
|
@ -208,7 +208,7 @@ class TriggerManager:
|
|||
name="Schedule",
|
||||
description="Schedule agent or workflow execution using Cloudflare Workers and cron expressions",
|
||||
trigger_type="schedule",
|
||||
provider_class="triggers.providers.schedule_provider.ScheduleTriggerProvider",
|
||||
provider_class="triggers.infrastructure.providers.schedule_provider.ScheduleTriggerProvider",
|
||||
webhook_enabled=True,
|
||||
config_schema={
|
||||
"type": "object",
|
||||
|
|
|
@ -3,6 +3,7 @@ import json
|
|||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
import pytz
|
||||
from qstash.client import QStash
|
||||
from utils.logger import logger
|
||||
from ...domain.entities import TriggerProvider, TriggerEvent, TriggerResult, Trigger
|
||||
|
@ -41,6 +42,14 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
if 'workflow_id' not in config:
|
||||
raise ValueError("workflow_id is required for workflow execution")
|
||||
|
||||
# Validate timezone if provided
|
||||
user_timezone = config.get('timezone', 'UTC')
|
||||
if user_timezone != 'UTC':
|
||||
try:
|
||||
pytz.timezone(user_timezone)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
raise ValueError(f"Invalid timezone: {user_timezone}")
|
||||
|
||||
try:
|
||||
import croniter
|
||||
croniter.croniter(config['cron_expression'])
|
||||
|
@ -51,6 +60,64 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
|
||||
return config
|
||||
|
||||
def _convert_cron_to_utc(self, cron_expression: str, user_timezone: str) -> str:
|
||||
try:
|
||||
import croniter
|
||||
parts = cron_expression.split()
|
||||
if len(parts) != 5:
|
||||
logger.warning(f"Invalid cron expression format: {cron_expression}")
|
||||
return cron_expression
|
||||
|
||||
minute, hour, day, month, weekday = parts
|
||||
|
||||
if minute.startswith('*/') and hour == '*':
|
||||
return cron_expression
|
||||
|
||||
if hour == '*' or minute == '*':
|
||||
return cron_expression
|
||||
|
||||
try:
|
||||
user_tz = pytz.timezone(user_timezone)
|
||||
utc_tz = pytz.UTC
|
||||
from datetime import datetime as dt
|
||||
now = dt.now(user_tz)
|
||||
|
||||
if hour.isdigit() and minute.isdigit():
|
||||
user_time = user_tz.localize(dt(now.year, now.month, now.day, int(hour), int(minute)))
|
||||
utc_time = user_time.astimezone(utc_tz)
|
||||
utc_minute = str(utc_time.minute)
|
||||
utc_hour = str(utc_time.hour)
|
||||
|
||||
return f"{utc_minute} {utc_hour} {day} {month} {weekday}"
|
||||
|
||||
elif ',' in hour and minute.isdigit():
|
||||
hours = hour.split(',')
|
||||
utc_hours = []
|
||||
for h in hours:
|
||||
if h.isdigit():
|
||||
user_time = user_tz.localize(dt(now.year, now.month, now.day, int(h), int(minute)))
|
||||
utc_time = user_time.astimezone(utc_tz)
|
||||
utc_hours.append(str(utc_time.hour))
|
||||
|
||||
if utc_hours:
|
||||
utc_minute = str(utc_time.minute)
|
||||
return f"{utc_minute} {','.join(utc_hours)} {day} {month} {weekday}"
|
||||
|
||||
elif '-' in hour and minute.isdigit():
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert timezone for cron expression {cron_expression}: {e}")
|
||||
|
||||
return cron_expression
|
||||
|
||||
except ImportError:
|
||||
logger.warning("croniter not available for cron expression validation")
|
||||
return cron_expression
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting cron expression to UTC: {e}")
|
||||
return cron_expression
|
||||
|
||||
async def setup_trigger(self, trigger: Trigger) -> bool:
|
||||
if not self._qstash:
|
||||
logger.error("QStash client not available")
|
||||
|
@ -58,31 +125,45 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
|
||||
try:
|
||||
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
||||
|
||||
cron_expression = trigger.config.config['cron_expression']
|
||||
execution_type = trigger.config.config.get('execution_type', 'agent')
|
||||
user_timezone = trigger.config.config.get('timezone', 'UTC')
|
||||
|
||||
if user_timezone != 'UTC':
|
||||
cron_expression = self._convert_cron_to_utc(cron_expression, user_timezone)
|
||||
logger.info(f"Converted cron expression from {user_timezone} to UTC: {trigger.config.config['cron_expression']} -> {cron_expression}")
|
||||
|
||||
payload = {
|
||||
"trigger_id": trigger.trigger_id,
|
||||
"agent_id": trigger.agent_id,
|
||||
"execution_type": trigger.config.config.get('execution_type', 'agent'),
|
||||
"execution_type": execution_type,
|
||||
"agent_prompt": trigger.config.config.get('agent_prompt'),
|
||||
"workflow_id": trigger.config.config.get('workflow_id'),
|
||||
"workflow_input": trigger.config.config.get('workflow_input', {}),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Trigger-Source": "schedule"
|
||||
}
|
||||
|
||||
if config.ENV_MODE == EnvMode.STAGING:
|
||||
vercel_bypass_key = os.getenv("VERCEL_PROTECTION_BYPASS_KEY", "")
|
||||
if vercel_bypass_key:
|
||||
headers["X-Vercel-Protection-Bypass"] = vercel_bypass_key
|
||||
|
||||
schedule_id = await asyncio.to_thread(
|
||||
self._qstash.schedule.create,
|
||||
destination=webhook_url,
|
||||
cron=cron_expression,
|
||||
body=json.dumps(payload),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Trigger-Source": "schedule"
|
||||
}
|
||||
headers=headers,
|
||||
retries=3,
|
||||
delay="5s"
|
||||
)
|
||||
|
||||
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id}")
|
||||
trigger.config.config['qstash_schedule_id'] = schedule_id
|
||||
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id} with cron: {cron_expression}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
@ -91,9 +172,20 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
|
||||
async def teardown_trigger(self, trigger: Trigger) -> bool:
|
||||
if not self._qstash:
|
||||
logger.warning("QStash client not available, skipping teardown")
|
||||
return True
|
||||
|
||||
try:
|
||||
schedule_id = trigger.config.config.get('qstash_schedule_id')
|
||||
if schedule_id:
|
||||
try:
|
||||
await asyncio.to_thread(self._qstash.schedule.delete, schedule_id)
|
||||
logger.info(f"Deleted QStash schedule {schedule_id} for trigger {trigger.trigger_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete QStash schedule {schedule_id} by ID: {e}")
|
||||
|
||||
logger.info(f"Attempting to find and delete QStash schedule for trigger {trigger.trigger_id} by webhook URL")
|
||||
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
||||
|
||||
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
||||
|
@ -102,8 +194,9 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
if schedule.get('destination') == webhook_url:
|
||||
await asyncio.to_thread(self._qstash.schedule.delete, schedule['scheduleId'])
|
||||
logger.info(f"Deleted QStash schedule {schedule['scheduleId']} for trigger {trigger.trigger_id}")
|
||||
break
|
||||
return True
|
||||
|
||||
logger.warning(f"No QStash schedule found for trigger {trigger.trigger_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
@ -156,16 +249,30 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
|
||||
async def health_check(self, trigger: Trigger) -> bool:
|
||||
if not self._qstash:
|
||||
logger.warning("QStash client not available for health check")
|
||||
return False
|
||||
|
||||
try:
|
||||
schedule_id = trigger.config.config.get('qstash_schedule_id')
|
||||
if schedule_id:
|
||||
try:
|
||||
schedule = await asyncio.to_thread(self._qstash.schedule.get, schedule_id)
|
||||
is_healthy = schedule is not None
|
||||
logger.info(f"Health check for trigger {trigger.trigger_id} using schedule ID {schedule_id}: {'healthy' if is_healthy else 'unhealthy'}")
|
||||
return is_healthy
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check health for QStash schedule {schedule_id} by ID: {e}")
|
||||
|
||||
logger.info(f"Attempting health check for trigger {trigger.trigger_id} by webhook URL")
|
||||
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
||||
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
||||
|
||||
for schedule in schedules:
|
||||
if schedule.get('destination') == webhook_url:
|
||||
logger.info(f"Health check for trigger {trigger.trigger_id}: healthy (found schedule)")
|
||||
return True
|
||||
|
||||
logger.warning(f"Health check for trigger {trigger.trigger_id}: no schedule found")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
|
@ -179,20 +286,43 @@ class ScheduleTriggerProvider(TriggerProvider):
|
|||
return await self.setup_trigger(trigger)
|
||||
|
||||
async def update_trigger(self, trigger: Trigger) -> bool:
|
||||
await self.teardown_trigger(trigger)
|
||||
if trigger.is_active:
|
||||
return await self.setup_trigger(trigger)
|
||||
return True
|
||||
if not self._qstash:
|
||||
logger.warning("QStash client not available for trigger update")
|
||||
return True
|
||||
|
||||
try:
|
||||
logger.info(f"Updating QStash schedule for trigger {trigger.trigger_id}")
|
||||
teardown_success = await self.teardown_trigger(trigger)
|
||||
if not teardown_success:
|
||||
logger.warning(f"Failed to teardown existing schedule for trigger {trigger.trigger_id}, proceeding with setup")
|
||||
|
||||
if trigger.is_active:
|
||||
setup_success = await self.setup_trigger(trigger)
|
||||
if setup_success:
|
||||
logger.info(f"Successfully updated QStash schedule for trigger {trigger.trigger_id}")
|
||||
else:
|
||||
logger.error(f"Failed to setup updated schedule for trigger {trigger.trigger_id}")
|
||||
return setup_success
|
||||
else:
|
||||
logger.info(f"Trigger {trigger.trigger_id} is inactive, skipping schedule setup")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating QStash schedule for trigger {trigger.trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_webhook_url(self, trigger_id: str, base_url: str) -> Optional[str]:
|
||||
return f"{base_url}/api/triggers/{trigger_id}/webhook"
|
||||
|
||||
async def list_schedules(self) -> list:
|
||||
if not self._qstash:
|
||||
logger.warning("QStash client not available for listing schedules")
|
||||
return []
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(self._qstash.schedule.list)
|
||||
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
||||
logger.info(f"Successfully retrieved {len(schedules)} schedules from QStash")
|
||||
return schedules
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list QStash schedules: {e}")
|
||||
return []
|
|
@ -1,5 +0,0 @@
|
|||
from .schedule_provider import ScheduleTriggerProvider
|
||||
|
||||
__all__ = [
|
||||
'ScheduleTriggerProvider'
|
||||
]
|
|
@ -1,294 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from qstash.client import QStash
|
||||
from utils.logger import logger
|
||||
from ..core import TriggerProvider, TriggerType, TriggerEvent, TriggerResult, TriggerConfig, ProviderDefinition
|
||||
from utils.config import config, EnvMode
|
||||
|
||||
class ScheduleTriggerProvider(TriggerProvider):
|
||||
def __init__(self, provider_definition: Optional[ProviderDefinition] = None):
|
||||
super().__init__(TriggerType.SCHEDULE, provider_definition)
|
||||
|
||||
self.qstash_token = os.getenv("QSTASH_TOKEN")
|
||||
self.webhook_base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:3000")
|
||||
|
||||
if not self.qstash_token:
|
||||
logger.warning("QSTASH_TOKEN not found. QStash provider will not work without it.")
|
||||
self.qstash = None
|
||||
else:
|
||||
self.qstash = QStash(token=self.qstash_token)
|
||||
|
||||
async def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self.qstash:
|
||||
raise ValueError("QSTASH_TOKEN environment variable is required for QStash scheduling")
|
||||
|
||||
if 'cron_expression' not in config:
|
||||
raise ValueError("cron_expression is required for QStash schedule triggers")
|
||||
|
||||
execution_type = config.get('execution_type', 'agent')
|
||||
if execution_type not in ['agent', 'workflow']:
|
||||
raise ValueError("execution_type must be either 'agent' or 'workflow'")
|
||||
|
||||
if execution_type == 'agent':
|
||||
if 'agent_prompt' not in config:
|
||||
raise ValueError("agent_prompt is required for agent execution")
|
||||
elif execution_type == 'workflow':
|
||||
if 'workflow_id' not in config:
|
||||
raise ValueError("workflow_id is required for workflow execution")
|
||||
|
||||
try:
|
||||
import croniter
|
||||
croniter.croniter(config['cron_expression'])
|
||||
except ImportError:
|
||||
raise ValueError("croniter package is required for cron expressions. Please install it with: pip install croniter")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid cron expression: {str(e)}")
|
||||
|
||||
return config
|
||||
|
||||
async def setup_trigger(self, trigger_config: TriggerConfig) -> bool:
|
||||
if config.ENV_MODE == EnvMode.STAGING:
|
||||
vercel_bypass_key = os.getenv("VERCEL_PROTECTION_BYPASS_KEY", "")
|
||||
else:
|
||||
vercel_bypass_key = ""
|
||||
try:
|
||||
webhook_url = f"{self.webhook_base_url}/api/triggers/qstash/webhook"
|
||||
execution_type = trigger_config.config.get('execution_type', 'agent')
|
||||
|
||||
webhook_payload = {
|
||||
"trigger_id": trigger_config.trigger_id,
|
||||
"agent_id": trigger_config.agent_id,
|
||||
"execution_type": execution_type,
|
||||
"schedule_name": trigger_config.name,
|
||||
"cron_expression": trigger_config.config['cron_expression'],
|
||||
"event_type": "scheduled",
|
||||
"provider": "qstash"
|
||||
}
|
||||
|
||||
if execution_type == 'agent':
|
||||
webhook_payload["agent_prompt"] = trigger_config.config['agent_prompt']
|
||||
elif execution_type == 'workflow':
|
||||
webhook_payload["workflow_id"] = trigger_config.config['workflow_id']
|
||||
webhook_payload["workflow_input"] = trigger_config.config.get('workflow_input', {})
|
||||
schedule_id = await asyncio.to_thread(
|
||||
self.qstash.schedule.create,
|
||||
destination=webhook_url,
|
||||
cron=trigger_config.config['cron_expression'],
|
||||
body=json.dumps(webhook_payload),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Schedule-Provider": "qstash",
|
||||
"X-Trigger-ID": trigger_config.trigger_id,
|
||||
"X-Agent-ID": trigger_config.agent_id,
|
||||
"X-Vercel-Protection-Bypass": vercel_bypass_key
|
||||
},
|
||||
retries=3,
|
||||
delay="5s"
|
||||
)
|
||||
trigger_config.config['qstash_schedule_id'] = schedule_id
|
||||
logger.info(f"Successfully created QStash schedule {schedule_id} for trigger {trigger_config.trigger_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up QStash scheduled trigger {trigger_config.trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def teardown_trigger(self, trigger_config: TriggerConfig) -> bool:
|
||||
try:
|
||||
schedule_id = trigger_config.config.get('qstash_schedule_id')
|
||||
if not schedule_id:
|
||||
logger.warning(f"No QStash schedule ID found for trigger {trigger_config.trigger_id}")
|
||||
return True
|
||||
await asyncio.to_thread(
|
||||
self.qstash.schedule.delete,
|
||||
schedule_id=schedule_id
|
||||
)
|
||||
logger.info(f"Successfully deleted QStash schedule {schedule_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing QStash scheduled trigger {trigger_config.trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def process_event(self, event: TriggerEvent) -> TriggerResult:
|
||||
try:
|
||||
raw_data = event.raw_data
|
||||
execution_type = raw_data.get('execution_type', 'agent')
|
||||
|
||||
execution_variables = {
|
||||
'scheduled_at': event.timestamp.isoformat(),
|
||||
'trigger_id': event.trigger_id,
|
||||
'agent_id': event.agent_id,
|
||||
'schedule_name': raw_data.get('schedule_name', 'Scheduled Task'),
|
||||
'execution_source': 'qstash',
|
||||
'execution_type': execution_type,
|
||||
'cron_expression': raw_data.get('cron_expression'),
|
||||
'qstash_message_id': raw_data.get('messageId')
|
||||
}
|
||||
|
||||
if execution_type == 'workflow':
|
||||
workflow_id = raw_data.get('workflow_id')
|
||||
workflow_input = raw_data.get('workflow_input', {})
|
||||
|
||||
return TriggerResult(
|
||||
success=True,
|
||||
should_execute_workflow=True,
|
||||
workflow_id=workflow_id,
|
||||
workflow_input=workflow_input,
|
||||
execution_variables=execution_variables
|
||||
)
|
||||
else:
|
||||
agent_prompt = raw_data.get('agent_prompt', 'Execute scheduled task')
|
||||
|
||||
return TriggerResult(
|
||||
success=True,
|
||||
should_execute_agent=True,
|
||||
agent_prompt=agent_prompt,
|
||||
execution_variables=execution_variables
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return TriggerResult(
|
||||
success=False,
|
||||
error_message=f"Error processing QStash scheduled trigger event: {str(e)}"
|
||||
)
|
||||
|
||||
async def health_check(self, trigger_config: TriggerConfig) -> bool:
|
||||
try:
|
||||
schedule_id = trigger_config.config.get('qstash_schedule_id')
|
||||
if not schedule_id:
|
||||
return False
|
||||
|
||||
schedule = await asyncio.to_thread(
|
||||
self.qstash.schedule.get,
|
||||
schedule_id=schedule_id
|
||||
)
|
||||
|
||||
return getattr(schedule, 'is_active', False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed for QStash scheduled trigger {trigger_config.trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def pause_trigger(self, trigger_config: TriggerConfig) -> bool:
|
||||
try:
|
||||
schedule_id = trigger_config.config.get('qstash_schedule_id')
|
||||
if not schedule_id:
|
||||
return False
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.qstash.schedules.pause,
|
||||
schedule_id=schedule_id
|
||||
)
|
||||
|
||||
logger.info(f"Successfully paused QStash schedule {schedule_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing QStash schedule: {e}")
|
||||
return False
|
||||
|
||||
async def resume_trigger(self, trigger_config: TriggerConfig) -> bool:
|
||||
try:
|
||||
schedule_id = trigger_config.config.get('qstash_schedule_id')
|
||||
if not schedule_id:
|
||||
return False
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.qstash.schedules.resume,
|
||||
schedule_id=schedule_id
|
||||
)
|
||||
|
||||
logger.info(f"Successfully resumed QStash schedule {schedule_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming QStash schedule: {e}")
|
||||
return False
|
||||
|
||||
async def update_trigger(self, trigger_config: TriggerConfig) -> bool:
|
||||
try:
|
||||
schedule_id = trigger_config.config.get('qstash_schedule_id')
|
||||
webhook_url = f"{self.webhook_base_url}/api/triggers/qstash/webhook"
|
||||
execution_type = trigger_config.config.get('execution_type', 'agent')
|
||||
|
||||
webhook_payload = {
|
||||
"trigger_id": trigger_config.trigger_id,
|
||||
"agent_id": trigger_config.agent_id,
|
||||
"execution_type": execution_type,
|
||||
"schedule_name": trigger_config.name,
|
||||
"cron_expression": trigger_config.config['cron_expression'],
|
||||
"event_type": "scheduled",
|
||||
"provider": "qstash"
|
||||
}
|
||||
|
||||
if execution_type == 'agent':
|
||||
webhook_payload["agent_prompt"] = trigger_config.config['agent_prompt']
|
||||
elif execution_type == 'workflow':
|
||||
webhook_payload["workflow_id"] = trigger_config.config['workflow_id']
|
||||
webhook_payload["workflow_input"] = trigger_config.config.get('workflow_input', {})
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.qstash_base_url}/schedules",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.qstash_token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"scheduleId": schedule_id,
|
||||
"destination": webhook_url,
|
||||
"cron": trigger_config.config['cron_expression'],
|
||||
"body": webhook_payload,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Schedule-Provider": "qstash",
|
||||
"X-Trigger-ID": trigger_config.trigger_id,
|
||||
"X-Agent-ID": trigger_config.agent_id
|
||||
},
|
||||
"retries": 3,
|
||||
"delay": "5s"
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Successfully updated QStash schedule {schedule_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to update QStash schedule: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating QStash schedule: {e}")
|
||||
return False
|
||||
|
||||
def get_webhook_url(self, trigger_id: str, base_url: str) -> Optional[str]:
|
||||
base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:3000")
|
||||
return f"{base_url}/api/triggers/qstash/webhook"
|
||||
|
||||
async def list_schedules(self) -> list:
|
||||
try:
|
||||
schedules_data = await asyncio.to_thread(
|
||||
self.qstash.schedules.list
|
||||
)
|
||||
|
||||
schedules = []
|
||||
for schedule in schedules_data:
|
||||
schedules.append({
|
||||
'id': getattr(schedule, 'schedule_id', None),
|
||||
'destination': getattr(schedule, 'destination', None),
|
||||
'cron': getattr(schedule, 'cron', None),
|
||||
'is_active': getattr(schedule, 'is_active', False),
|
||||
'created_at': getattr(schedule, 'created_at', None),
|
||||
'next_delivery': getattr(schedule, 'next_delivery', None)
|
||||
})
|
||||
|
||||
return schedules
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing QStash schedules: {e}")
|
||||
return []
|
|
@ -1,51 +0,0 @@
|
|||
import warnings
|
||||
warnings.warn(
|
||||
"triggers.registry is deprecated. Use triggers.domain.services.ProviderRegistryService instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from .core import TriggerProvider, TriggerType
|
||||
from .providers import ScheduleTriggerProvider
|
||||
|
||||
class TriggerRegistry:
|
||||
"""Registry for trigger providers."""
|
||||
|
||||
def __init__(self):
|
||||
self._providers: Dict[TriggerType, TriggerProvider] = {}
|
||||
self._initialize_default_providers()
|
||||
|
||||
def _initialize_default_providers(self):
|
||||
"""Initialize default trigger providers."""
|
||||
self.register_provider(ScheduleTriggerProvider())
|
||||
|
||||
def register_provider(self, provider: TriggerProvider):
|
||||
"""Register a trigger provider."""
|
||||
self._providers[provider.trigger_type] = provider
|
||||
|
||||
def get_provider(self, trigger_type: TriggerType) -> Optional[TriggerProvider]:
|
||||
"""Get a trigger provider by type."""
|
||||
return self._providers.get(trigger_type)
|
||||
|
||||
def get_all_providers(self) -> Dict[TriggerType, TriggerProvider]:
|
||||
"""Get all registered providers."""
|
||||
return self._providers.copy()
|
||||
|
||||
def get_supported_types(self) -> List[TriggerType]:
|
||||
"""Get list of supported trigger types."""
|
||||
return list(self._providers.keys())
|
||||
|
||||
def get_provider_schemas(self) -> Dict[str, Dict]:
|
||||
"""Get configuration schemas for all providers."""
|
||||
schemas = {}
|
||||
for trigger_type, provider in self._providers.items():
|
||||
trigger_type_str = trigger_type.value if hasattr(trigger_type, 'value') else str(trigger_type)
|
||||
schemas[trigger_type_str] = provider.get_config_schema()
|
||||
return schemas
|
||||
|
||||
def is_supported(self, trigger_type: TriggerType) -> bool:
|
||||
"""Check if a trigger type is supported."""
|
||||
return trigger_type in self._providers
|
||||
|
||||
trigger_registry = TriggerRegistry()
|
|
@ -19,6 +19,7 @@ import { useAgentVersionStore } from '../../../../../lib/stores/agent-version-st
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { AgentHeader, VersionAlert, AgentBuilderTab, ConfigurationTab } from '@/components/agents/config';
|
||||
import { UpcomingRunsDropdown } from '@/components/agents/upcoming-runs-dropdown';
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
|
@ -268,6 +269,7 @@ export default function AgentConfigurationPageRefactored() {
|
|||
setOriginalData(formData);
|
||||
}}
|
||||
/>
|
||||
<UpcomingRunsDropdown agentId={agentId} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnsavedChanges && !isViewingOldVersion && (
|
||||
|
@ -341,7 +343,101 @@ export default function AgentConfigurationPageRefactored() {
|
|||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col h-full w-full">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentVersionSwitcher
|
||||
agentId={agentId}
|
||||
currentVersionId={agent?.current_version_id}
|
||||
currentFormData={{
|
||||
system_prompt: formData.system_prompt,
|
||||
configured_mcps: formData.configured_mcps,
|
||||
custom_mcps: formData.custom_mcps,
|
||||
agentpress_tools: formData.agentpress_tools
|
||||
}}
|
||||
/>
|
||||
<CreateVersionButton
|
||||
agentId={agentId}
|
||||
currentFormData={{
|
||||
system_prompt: formData.system_prompt,
|
||||
configured_mcps: formData.configured_mcps,
|
||||
custom_mcps: formData.custom_mcps,
|
||||
agentpress_tools: formData.agentpress_tools
|
||||
}}
|
||||
hasChanges={hasUnsavedChanges && !isViewingOldVersion}
|
||||
onVersionCreated={() => {
|
||||
setOriginalData(formData);
|
||||
}}
|
||||
/>
|
||||
<UpcomingRunsDropdown agentId={agentId} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnsavedChanges && !isViewingOldVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isViewingOldVersion && (
|
||||
<VersionAlert
|
||||
versionData={versionData}
|
||||
isActivating={activateVersionMutation.isPending}
|
||||
onActivateVersion={handleActivateVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AgentHeader
|
||||
agentId={agentId}
|
||||
displayData={displayData}
|
||||
currentStyle={currentStyle}
|
||||
activeTab={activeTab}
|
||||
isViewingOldVersion={isViewingOldVersion}
|
||||
onFieldChange={handleFieldChange}
|
||||
onStyleChange={handleStyleChange}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
<TabsContent value="agent-builder" className="flex-1 h-0 m-0">
|
||||
<AgentBuilderTab
|
||||
agentId={agentId}
|
||||
displayData={displayData}
|
||||
currentStyle={currentStyle}
|
||||
isViewingOldVersion={isViewingOldVersion}
|
||||
onFieldChange={handleFieldChange}
|
||||
onStyleChange={handleStyleChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="configuration" className="flex-1 h-0 m-0 overflow-y-auto">
|
||||
<ConfigurationTab
|
||||
agentId={agentId}
|
||||
displayData={displayData}
|
||||
versionData={versionData}
|
||||
isViewingOldVersion={isViewingOldVersion}
|
||||
onFieldChange={handleFieldChange}
|
||||
onMCPChange={handleMCPChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
|
|
|
@ -49,17 +49,7 @@ export const AgentToolsConfiguration = ({ tools, onToolsChange }: AgentToolsConf
|
|||
{getSelectedToolsCount()} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="gap-4 grid grid-cols-1 md:grid-cols-2">
|
||||
{getFilteredTools().map(([toolName, toolInfo]) => (
|
||||
|
|
|
@ -58,6 +58,7 @@ export function AgentHeader({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={onTabChange}>
|
||||
<TabsList className="grid grid-cols-2 bg-muted/50 h-9">
|
||||
<TabsTrigger
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Zap, MessageSquare, Webhook, Plus, Settings } from 'lucide-react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { ConfiguredTriggersList } from './configured-triggers-list';
|
||||
import { TriggerConfigDialog } from './trigger-config-dialog';
|
||||
|
@ -30,6 +29,7 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
|||
const [editingTrigger, setEditingTrigger] = useState<TriggerConfiguration | null>(null);
|
||||
|
||||
const { data: triggers = [], isLoading, error } = useAgentTriggers(agentId);
|
||||
const { data: providers = [] } = useTriggerProviders();
|
||||
const createTriggerMutation = useCreateTrigger();
|
||||
const updateTriggerMutation = useUpdateTrigger();
|
||||
const deleteTriggerMutation = useDeleteTrigger();
|
||||
|
@ -37,19 +37,28 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
|||
|
||||
const handleEditTrigger = (trigger: TriggerConfiguration) => {
|
||||
setEditingTrigger(trigger);
|
||||
setConfiguringProvider({
|
||||
provider_id: trigger.provider_id,
|
||||
name: trigger.trigger_type,
|
||||
description: '',
|
||||
trigger_type: trigger.trigger_type,
|
||||
webhook_enabled: !!trigger.webhook_url,
|
||||
config_schema: {}
|
||||
});
|
||||
|
||||
const provider = providers.find(p => p.provider_id === trigger.provider_id);
|
||||
if (provider) {
|
||||
setConfiguringProvider(provider);
|
||||
} else {
|
||||
setConfiguringProvider({
|
||||
provider_id: trigger.provider_id,
|
||||
name: trigger.trigger_type,
|
||||
description: '',
|
||||
trigger_type: trigger.trigger_type,
|
||||
webhook_enabled: !!trigger.webhook_url,
|
||||
config_schema: {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTrigger = async (trigger: TriggerConfiguration) => {
|
||||
try {
|
||||
await deleteTriggerMutation.mutateAsync(trigger.trigger_id);
|
||||
await deleteTriggerMutation.mutateAsync({
|
||||
triggerId: trigger.trigger_id,
|
||||
agentId: trigger.agent_id
|
||||
});
|
||||
toast.success('Trigger deleted successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete trigger');
|
||||
|
@ -123,23 +132,13 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
|||
<OneClickIntegrations agentId={agentId} />
|
||||
|
||||
{triggers.length > 0 && (
|
||||
<div className="bg-card rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border bg-muted/30">
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Configured Triggers
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-2 divide-y divide-border">
|
||||
<ConfiguredTriggersList
|
||||
triggers={triggers}
|
||||
onEdit={handleEditTrigger}
|
||||
onRemove={handleRemoveTrigger}
|
||||
onToggle={handleToggleTrigger}
|
||||
isLoading={deleteTriggerMutation.isPending || toggleTriggerMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConfiguredTriggersList
|
||||
triggers={triggers}
|
||||
onEdit={handleEditTrigger}
|
||||
onRemove={handleRemoveTrigger}
|
||||
onToggle={handleToggleTrigger}
|
||||
isLoading={deleteTriggerMutation.isPending || toggleTriggerMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && triggers.length === 0 && (
|
||||
|
|
|
@ -38,6 +38,26 @@ const copyToClipboard = async (text: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getCronDescription = (cron: string): string => {
|
||||
const cronDescriptions: Record<string, string> = {
|
||||
'0 9 * * *': 'Daily at 9:00 AM',
|
||||
'0 18 * * *': 'Daily at 6:00 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
|
||||
'0 10 * * 1-5': 'Weekdays at 10:00 AM',
|
||||
'0 9 * * 1': 'Every Monday at 9:00 AM',
|
||||
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
|
||||
'0 9 1 1 *': 'Yearly on Jan 1st at 9:00 AM',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 9 * * 0': 'Every Sunday at 9:00 AM',
|
||||
'0 9 * * 6': 'Every Saturday at 9:00 AM',
|
||||
};
|
||||
|
||||
return cronDescriptions[cron] || cron;
|
||||
};
|
||||
|
||||
export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
|
||||
triggers,
|
||||
onEdit,
|
||||
|
@ -76,7 +96,16 @@ export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
|
|||
{truncateString(trigger.description, 50)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trigger.trigger_type === 'schedule' && trigger.config && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{trigger.config.execution_type === 'agent' && trigger.config.agent_prompt && (
|
||||
<p>Prompt: {truncateString(trigger.config.agent_prompt, 40)}</p>
|
||||
)}
|
||||
{trigger.config.execution_type === 'workflow' && trigger.config.workflow_id && (
|
||||
<p>Workflow: {trigger.config.workflow_id}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{trigger.webhook_url && (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono max-w-xs truncate">
|
||||
|
@ -118,7 +147,6 @@ export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
@ -66,7 +66,10 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
|
|||
const handleUninstall = async (provider: ProviderKey, triggerId?: string) => {
|
||||
if (provider === 'schedule' && triggerId) {
|
||||
try {
|
||||
await deleteTriggerMutation.mutateAsync(triggerId);
|
||||
await deleteTriggerMutation.mutateAsync({
|
||||
triggerId,
|
||||
agentId
|
||||
});
|
||||
toast.success('Schedule trigger removed successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove schedule trigger');
|
||||
|
@ -120,7 +123,6 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
|
|||
const scheduleProvider: TriggerProvider = {
|
||||
provider_id: 'schedule',
|
||||
name: 'Schedule',
|
||||
description: 'Schedule agent execution using cron expressions',
|
||||
trigger_type: 'schedule',
|
||||
webhook_enabled: true,
|
||||
config_schema: {}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/
|
|||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Clock, Calendar as CalendarIcon, Info, Zap, Repeat, Timer, Target } from 'lucide-react';
|
||||
import { format, startOfDay } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
@ -23,6 +24,12 @@ interface ScheduleTriggerConfigFormProps {
|
|||
onChange: (config: ScheduleTriggerConfig) => void;
|
||||
errors: Record<string, string>;
|
||||
agentId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
onNameChange: (name: string) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
isActive: boolean;
|
||||
onActiveChange: (active: boolean) => void;
|
||||
}
|
||||
|
||||
type ScheduleType = 'quick' | 'recurring' | 'advanced' | 'one-time';
|
||||
|
@ -102,6 +109,12 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
|||
onChange,
|
||||
errors,
|
||||
agentId,
|
||||
name,
|
||||
description,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
isActive,
|
||||
onActiveChange,
|
||||
}) => {
|
||||
const { data: workflows = [], isLoading: isLoadingWorkflows } = useAgentWorkflows(agentId);
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('quick');
|
||||
|
@ -116,7 +129,34 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
|||
const [selectedDate, setSelectedDate] = useState<Date>();
|
||||
const [oneTimeTime, setOneTimeTime] = useState<{ hour: string; minute: string }>({ hour: '09', minute: '00' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.timezone) {
|
||||
try {
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
onChange({
|
||||
...config,
|
||||
timezone: detectedTimezone,
|
||||
});
|
||||
} catch (error) {
|
||||
onChange({
|
||||
...config,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.cron_expression) {
|
||||
const preset = QUICK_PRESETS.find(p => p.cron === config.cron_expression);
|
||||
if (preset) {
|
||||
setScheduleType('quick');
|
||||
setSelectedPreset(config.cron_expression);
|
||||
} else {
|
||||
setScheduleType('advanced');
|
||||
}
|
||||
}
|
||||
}, [config.cron_expression]);
|
||||
|
||||
const generateCronExpression = () => {
|
||||
if (scheduleType === 'quick' && selectedPreset) {
|
||||
|
@ -179,6 +219,29 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
|||
});
|
||||
};
|
||||
|
||||
const getSchedulePreview = () => {
|
||||
if (!config.cron_expression) return null;
|
||||
|
||||
try {
|
||||
const descriptions: Record<string, string> = {
|
||||
'0 9 * * *': 'Every day at 9:00 AM',
|
||||
'0 18 * * *': 'Every day at 6:00 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
|
||||
'0 10 * * 1-5': 'Weekdays at 10:00 AM',
|
||||
'0 9 * * 1': 'Every Monday at 9:00 AM',
|
||||
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 0 * * *': 'Every day at midnight',
|
||||
'0 12 * * *': 'Every day at noon',
|
||||
};
|
||||
|
||||
return descriptions[config.cron_expression] || config.cron_expression;
|
||||
} catch {
|
||||
return config.cron_expression;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutionTypeChange = (value: 'agent' | 'workflow') => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
|
@ -206,8 +269,6 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
|||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleWeekdayToggle = (weekday: string) => {
|
||||
setSelectedWeekdays(prev =>
|
||||
prev.includes(weekday)
|
||||
|
@ -238,392 +299,508 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-none bg-transparent shadow-none p-0">
|
||||
<CardHeader className='p-0 -mt-2'>
|
||||
<CardHeader className='p-0'>
|
||||
<CardDescription>
|
||||
Configure when your agent should be triggered automatically. Choose from quick presets, recurring schedules, or set up advanced cron expressions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Tabs value={scheduleType} onValueChange={(value) => setScheduleType(value as ScheduleType)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="quick" className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
Quick
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recurring" className="flex items-center gap-2">
|
||||
<Repeat className="h-4 w-4" />
|
||||
Recurring
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="one-time" className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
One-time
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<CardContent className="p-0 pt-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Trigger Details
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-name">Name *</Label>
|
||||
<Input
|
||||
id="trigger-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Enter a name for this trigger"
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-description">Description</Label>
|
||||
<Textarea
|
||||
id="trigger-description"
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Optional description for this trigger"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="trigger-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={onActiveChange}
|
||||
/>
|
||||
<Label htmlFor="trigger-active">
|
||||
Enable trigger immediately
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="quick" className="space-y-4 mt-6">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedPresets).map(([category, presets]) => (
|
||||
<div key={category}>
|
||||
<h4 className="text-sm font-medium mb-3 capitalize">{category} Schedules</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{presets.map((preset) => (
|
||||
<Card
|
||||
key={preset.cron}
|
||||
className={cn(
|
||||
"p-0 cursor-pointer transition-colors hover:bg-accent",
|
||||
selectedPreset === preset.cron && "ring-2 ring-primary bg-accent"
|
||||
)}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-primary">{preset.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
Execution Configuration
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">
|
||||
Execution Type *
|
||||
</Label>
|
||||
<RadioGroup value={config.execution_type || 'agent'} onValueChange={handleExecutionTypeChange}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="agent" id="execution-agent" />
|
||||
<Label htmlFor="execution-agent">Execute Agent</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="workflow" id="execution-workflow" />
|
||||
<Label htmlFor="execution-workflow">Execute Workflow</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose whether to execute the agent directly or run a specific workflow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.execution_type === 'workflow' ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="workflow_id" className="text-sm font-medium">
|
||||
Workflow *
|
||||
</Label>
|
||||
<Select value={config.workflow_id || ''} onValueChange={handleWorkflowChange}>
|
||||
<SelectTrigger className={errors.workflow_id ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="Select a workflow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingWorkflows ? (
|
||||
<SelectItem value="__loading__" disabled>Loading workflows...</SelectItem>
|
||||
) : workflows.length === 0 ? (
|
||||
<SelectItem value="__no_workflows__" disabled>No workflows available</SelectItem>
|
||||
) : (
|
||||
workflows.filter(w => w.status === 'active').map((workflow) => (
|
||||
<SelectItem key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.workflow_id && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.workflow_id}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select the workflow to execute when triggered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="workflow_input" className="text-sm font-medium">
|
||||
Instructions for Workflow
|
||||
</Label>
|
||||
<Textarea
|
||||
id="workflow_input"
|
||||
value={config.workflow_input?.prompt || config.workflow_input?.message || ''}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...config,
|
||||
workflow_input: { prompt: e.target.value },
|
||||
});
|
||||
}}
|
||||
placeholder="Write what you want the workflow to do..."
|
||||
rows={4}
|
||||
className={errors.workflow_input ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.workflow_input && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.workflow_input}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Simply describe what you want the workflow to accomplish. The workflow will interpret your instructions naturally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="agent_prompt" className="text-sm font-medium">
|
||||
Agent Prompt *
|
||||
</Label>
|
||||
<Textarea
|
||||
id="agent_prompt"
|
||||
value={config.agent_prompt || ''}
|
||||
onChange={(e) => handleAgentPromptChange(e.target.value)}
|
||||
placeholder="Enter the prompt that will be sent to your agent when triggered..."
|
||||
rows={4}
|
||||
className={errors.agent_prompt ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.agent_prompt && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.agent_prompt}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This prompt will be sent to your agent each time the schedule triggers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Schedule Configuration
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="timezone" className="text-sm font-medium">
|
||||
Timezone
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(Auto-detected: {Intl.DateTimeFormat().resolvedOptions().timeZone})
|
||||
</span>
|
||||
</Label>
|
||||
<Select value={config.timezone || 'UTC'} onValueChange={handleTimezoneChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{tz.label}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{new Date().toLocaleTimeString('en-US', {
|
||||
timeZone: tz.value,
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.timezone && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Current time: {new Date().toLocaleString('en-US', {
|
||||
timeZone: config.timezone,
|
||||
hour12: true,
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs value={scheduleType} onValueChange={(value) => setScheduleType(value as ScheduleType)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="quick" className="flex items-center gap-1 px-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Quick</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recurring" className="flex items-center gap-1 px-2">
|
||||
<Repeat className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Recurring</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="one-time" className="flex items-center gap-1 px-2">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">One-time</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="flex items-center gap-1 px-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Advanced</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quick" className="space-y-4 mt-6">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedPresets).map(([category, presets]) => (
|
||||
<div key={category}>
|
||||
<h4 className="text-sm font-medium mb-3 capitalize">{category} Schedules</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{presets.map((preset) => (
|
||||
<Card
|
||||
key={preset.cron}
|
||||
className={cn(
|
||||
"p-0 cursor-pointer transition-colors hover:bg-accent",
|
||||
selectedPreset === preset.cron && "ring-2 ring-primary bg-accent"
|
||||
)}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-primary">{preset.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recurring" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Schedule Type</Label>
|
||||
<RadioGroup value={recurringType} onValueChange={(value) => setRecurringType(value as any)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="daily" id="daily" />
|
||||
<Label htmlFor="daily">Daily</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="weekly" id="weekly" />
|
||||
<Label htmlFor="weekly">Weekly</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="monthly" id="monthly" />
|
||||
<Label htmlFor="monthly">Monthly</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{recurringType === 'weekly' && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Days of Week</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<Button
|
||||
key={day.value}
|
||||
variant={selectedWeekdays.includes(day.value) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleWeekdayToggle(day.value)}
|
||||
>
|
||||
{day.short}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recurringType === 'monthly' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Day of Month</Label>
|
||||
<Select value={dayOfMonth} onValueChange={setDayOfMonth}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 31 }, (_, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{i + 1}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Months</Label>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant={selectedMonths.includes('*') ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleMonthToggle('*')}
|
||||
>
|
||||
All Months
|
||||
</Button>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTHS.map((month) => (
|
||||
<Button
|
||||
key={month.value}
|
||||
variant={selectedMonths.includes(month.value) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleMonthToggle(month.value)}
|
||||
disabled={selectedMonths.includes('*')}
|
||||
>
|
||||
{month.label.slice(0, 3)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Time</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={scheduleTime.hour} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, hour: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>:</span>
|
||||
<Select value={scheduleTime.minute} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, minute: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="one-time" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!selectedDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
disabled={(date) => date < startOfDay(new Date())}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Time</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={oneTimeTime.hour} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, hour: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>:</span>
|
||||
<Select value={oneTimeTime.minute} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, minute: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced" className="space-y-4 mt-6">
|
||||
<div>
|
||||
<Label htmlFor="cron_expression" className="text-sm font-medium">
|
||||
Cron Expression *
|
||||
</Label>
|
||||
<Input
|
||||
id="cron_expression"
|
||||
type="text"
|
||||
value={config.cron_expression || ''}
|
||||
onChange={(e) => onChange({ ...config, cron_expression: e.target.value })}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className={errors.cron_expression ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.cron_expression && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.cron_expression}</p>
|
||||
)}
|
||||
{config.cron_expression && !errors.cron_expression && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
✓ {getSchedulePreview()}
|
||||
</p>
|
||||
)}
|
||||
<Card className="mt-3 p-0 py-4">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Cron Format</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Format: <code className="bg-muted px-1 rounded text-xs">minute hour day month weekday</code></div>
|
||||
<div>Example: <code className="bg-muted px-1 rounded text-xs">0 9 * * 1-5</code> = Weekdays at 9 AM</div>
|
||||
<div>Use <code className="bg-muted px-1 rounded text-xs">*</code> for any value, <code className="bg-muted px-1 rounded text-xs">*/5</code> for every 5 units</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recurring" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Schedule Type</Label>
|
||||
<RadioGroup value={recurringType} onValueChange={(value) => setRecurringType(value as any)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="daily" id="daily" />
|
||||
<Label htmlFor="daily">Daily</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="weekly" id="weekly" />
|
||||
<Label htmlFor="weekly">Weekly</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="monthly" id="monthly" />
|
||||
<Label htmlFor="monthly">Monthly</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{recurringType === 'weekly' && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Days of Week</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<Button
|
||||
key={day.value}
|
||||
variant={selectedWeekdays.includes(day.value) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleWeekdayToggle(day.value)}
|
||||
>
|
||||
{day.short}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recurringType === 'monthly' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Day of Month</Label>
|
||||
<Select value={dayOfMonth} onValueChange={setDayOfMonth}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 31 }, (_, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{i + 1}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Months</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{config.cron_expression && (
|
||||
<div className="border rounded-lg p-4 bg-muted/30">
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Schedule Preview
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant={selectedMonths.includes('*') ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleMonthToggle('*')}
|
||||
>
|
||||
All Months
|
||||
</Button>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTHS.map((month) => (
|
||||
<Button
|
||||
key={month.value}
|
||||
variant={selectedMonths.includes(month.value) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleMonthToggle(month.value)}
|
||||
disabled={selectedMonths.includes('*')}
|
||||
>
|
||||
{month.label.slice(0, 3)}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{getSchedulePreview()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{config.timezone || 'UTC'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm capitalize">{config.execution_type || 'agent'} execution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Time</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={scheduleTime.hour} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, hour: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>:</span>
|
||||
<Select value={scheduleTime.minute} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, minute: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="one-time" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!selectedDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
disabled={(date) => date < startOfDay(new Date())}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Time</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={oneTimeTime.hour} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, hour: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>:</span>
|
||||
<Select value={oneTimeTime.minute} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, minute: value }))}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
|
||||
{i.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-6">
|
||||
<div>
|
||||
<Label htmlFor="cron_expression" className="text-sm font-medium">
|
||||
Cron Expression *
|
||||
</Label>
|
||||
<Input
|
||||
id="cron_expression"
|
||||
type="text"
|
||||
value={config.cron_expression || ''}
|
||||
onChange={(e) => onChange({ ...config, cron_expression: e.target.value })}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className={errors.cron_expression ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.cron_expression && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.cron_expression}</p>
|
||||
)}
|
||||
<Card className="mt-3 p-0 py-4">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Cron Format</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Format: <code className="bg-muted px-1 rounded text-xs">minute hour day month weekday</code></div>
|
||||
<div>Example: <code className="bg-muted px-1 rounded text-xs">0 9 * * 1-5</code> = Weekdays at 9 AM</div>
|
||||
<div>Use <code className="bg-muted px-1 rounded text-xs">*</code> for any value, <code className="bg-muted px-1 rounded text-xs">*/5</code> for every 5 units</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none bg-transparent shadow-none p-0">
|
||||
<CardContent className="space-y-4 p-0">
|
||||
<div>
|
||||
<Label htmlFor="timezone" className="text-sm font-medium">
|
||||
Timezone
|
||||
</Label>
|
||||
<Select value={config.timezone || 'UTC'} onValueChange={handleTimezoneChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">
|
||||
Execution Type *
|
||||
</Label>
|
||||
<RadioGroup value={config.execution_type || 'agent'} onValueChange={handleExecutionTypeChange}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="agent" id="execution-agent" />
|
||||
<Label htmlFor="execution-agent">Execute Agent</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="workflow" id="execution-workflow" />
|
||||
<Label htmlFor="execution-workflow">Execute Workflow</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose whether to execute the agent directly or run a specific workflow.
|
||||
</p>
|
||||
</div>
|
||||
{config.execution_type === 'workflow' ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="workflow_id" className="text-sm font-medium">
|
||||
Workflow *
|
||||
</Label>
|
||||
<Select value={config.workflow_id || ''} onValueChange={handleWorkflowChange}>
|
||||
<SelectTrigger className={errors.workflow_id ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="Select a workflow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingWorkflows ? (
|
||||
<SelectItem value="__loading__" disabled>Loading workflows...</SelectItem>
|
||||
) : workflows.length === 0 ? (
|
||||
<SelectItem value="__no_workflows__" disabled>No workflows available</SelectItem>
|
||||
) : (
|
||||
workflows.filter(w => w.status === 'active').map((workflow) => (
|
||||
<SelectItem key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.workflow_id && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.workflow_id}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select the workflow to execute when triggered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="workflow_input" className="text-sm font-medium">
|
||||
Instructions for Workflow
|
||||
</Label>
|
||||
<Textarea
|
||||
id="workflow_input"
|
||||
value={config.workflow_input?.prompt || config.workflow_input?.message || ''}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...config,
|
||||
workflow_input: { prompt: e.target.value },
|
||||
});
|
||||
}}
|
||||
placeholder="Write what you want the workflow to do..."
|
||||
rows={3}
|
||||
className={errors.workflow_input ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.workflow_input && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.workflow_input}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Simply describe what you want the workflow to accomplish. The workflow will interpret your instructions naturally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="agent_prompt" className="text-sm font-medium">
|
||||
Agent Prompt *
|
||||
</Label>
|
||||
<Textarea
|
||||
id="agent_prompt"
|
||||
value={config.agent_prompt || ''}
|
||||
onChange={(e) => handleAgentPromptChange(e.target.value)}
|
||||
placeholder="Enter the prompt that will be sent to your agent when triggered..."
|
||||
rows={4}
|
||||
className={errors.agent_prompt ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.agent_prompt && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.agent_prompt}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This prompt will be sent to your agent each time the schedule triggers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
@ -105,6 +105,12 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
|||
onChange={setConfig}
|
||||
errors={errors}
|
||||
agentId={agentId}
|
||||
name={name}
|
||||
description={description}
|
||||
onNameChange={setName}
|
||||
onDescriptionChange={setDescription}
|
||||
isActive={isActive}
|
||||
onActiveChange={setIsActive}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
@ -118,7 +124,7 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-muted border">
|
||||
|
@ -133,49 +139,55 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-6 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-name">Name *</Label>
|
||||
<Input
|
||||
id="trigger-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter a name for this trigger"
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-description">Description</Label>
|
||||
<Textarea
|
||||
id="trigger-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description for this trigger"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="trigger-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
<Label htmlFor="trigger-active">
|
||||
Enable trigger immediately
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
{provider.name} Configuration
|
||||
</h3>
|
||||
{renderProviderSpecificConfig()}
|
||||
</div>
|
||||
{provider.provider_id === 'schedule' ? (
|
||||
renderProviderSpecificConfig()
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-name">Name *</Label>
|
||||
<Input
|
||||
id="trigger-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter a name for this trigger"
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger-description">Description</Label>
|
||||
<Textarea
|
||||
id="trigger-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description for this trigger"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="trigger-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
<Label htmlFor="trigger-active">
|
||||
Enable trigger immediately
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
{provider.name} Configuration
|
||||
</h3>
|
||||
{renderProviderSpecificConfig()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{provider.webhook_enabled && existingConfig?.webhook_url && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-medium mb-4">Webhook Information</h3>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export interface TriggerProvider {
|
||||
provider_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
trigger_type: string;
|
||||
webhook_enabled: boolean;
|
||||
config_schema: Record<string, any>;
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Clock, Calendar, ChevronDown, Activity, Zap, AlertCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAgentUpcomingRuns, type UpcomingRun } from '@/hooks/react-query/agents/use-agent-upcoming-runs';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
|
||||
interface UpcomingRunsDropdownProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface RunItemProps {
|
||||
run: UpcomingRun;
|
||||
}
|
||||
|
||||
const RunItem: React.FC<RunItemProps> = ({ run }) => {
|
||||
const nextRunTime = parseISO(run.next_run_time_local);
|
||||
const timeUntilRun = formatDistanceToNow(nextRunTime, { addSuffix: true });
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem className="flex flex-col items-start p-3 cursor-pointer hover:bg-accent/80">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1 bg-primary/10 rounded-lg">
|
||||
<Clock className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium text-sm truncate max-w-32">
|
||||
{run.trigger_name}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{run.execution_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{timeUntilRun}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-80 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
<span className="font-semibold">{run.trigger_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{run.human_readable}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Next run: {nextRunTime.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>Type: {run.execution_type}</span>
|
||||
</div>
|
||||
|
||||
{run.agent_prompt && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
||||
<strong>Prompt:</strong> {run.agent_prompt.substring(0, 100)}
|
||||
{run.agent_prompt.length > 100 && '...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.workflow_id && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
|
||||
<strong>Workflow:</strong> {run.workflow_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Timezone: {run.timezone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpcomingRunsDropdown: React.FC<UpcomingRunsDropdownProps> = ({ agentId }) => {
|
||||
const { data: upcomingRuns, isLoading, error } = useAgentUpcomingRuns(agentId, 5);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasRuns = upcomingRuns?.upcoming_runs?.length > 0;
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Clock className="h-4 w-4" />
|
||||
{hasRuns && (
|
||||
<div className="absolute -top-1 -right-1 h-2 w-2 bg-green-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm">Upcoming</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Upcoming Runs</span>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading && (
|
||||
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
|
||||
<div className="flex items-center space-x-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to load runs</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !hasRuns && (
|
||||
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<Clock className="h-6 w-6" />
|
||||
<span className="text-sm">No upcoming runs</span>
|
||||
<span className="text-xs text-center">
|
||||
Create a schedule trigger to see upcoming runs
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasRuns && upcomingRuns.upcoming_runs.map((run) => (
|
||||
<RunItem key={run.trigger_id} run={run} />
|
||||
))}
|
||||
|
||||
{hasRuns && upcomingRuns.total_count > 5 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-center text-sm text-muted-foreground">
|
||||
Showing 5 of {upcomingRuns.total_count} upcoming runs
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient, backendApi } from '@/lib/api-client';
|
||||
|
||||
interface UpcomingRun {
|
||||
trigger_id: string;
|
||||
trigger_name: string;
|
||||
trigger_type: string;
|
||||
next_run_time: string;
|
||||
next_run_time_local: string;
|
||||
timezone: string;
|
||||
cron_expression: string;
|
||||
execution_type: string;
|
||||
agent_prompt?: string;
|
||||
workflow_id?: string;
|
||||
is_active: boolean;
|
||||
human_readable: string;
|
||||
}
|
||||
|
||||
interface UpcomingRunsResponse {
|
||||
upcoming_runs: UpcomingRun[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export const useAgentUpcomingRuns = (agentId: string, limit: number = 10) => {
|
||||
return useQuery({
|
||||
queryKey: ['agent-upcoming-runs', agentId, limit],
|
||||
queryFn: async (): Promise<UpcomingRunsResponse> => {
|
||||
const response = await backendApi.get(`/triggers/agents/${agentId}/upcoming-runs?limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!agentId,
|
||||
refetchInterval: 60000,
|
||||
staleTime: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
export type { UpcomingRun, UpcomingRunsResponse };
|
|
@ -81,13 +81,13 @@ const updateTrigger = async (data: {
|
|||
return response.json();
|
||||
};
|
||||
|
||||
const deleteTrigger = async (triggerId: string): Promise<void> => {
|
||||
const deleteTrigger = async (data: { triggerId: string; agentId: string }): Promise<void> => {
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
throw new Error('You must be logged in to create a trigger');
|
||||
}
|
||||
const response = await fetch(`${API_URL}/triggers/${triggerId}`, {
|
||||
const response = await fetch(`${API_URL}/triggers/${data.triggerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` },
|
||||
});
|
||||
|
@ -113,6 +113,7 @@ export const useCreateTrigger = () => {
|
|||
return useMutation({
|
||||
mutationFn: createTrigger,
|
||||
onSuccess: (newTrigger) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', newTrigger.agent_id] });
|
||||
queryClient.setQueryData(
|
||||
['agent-triggers', newTrigger.agent_id],
|
||||
(old: TriggerConfiguration[] | undefined) => {
|
||||
|
@ -129,6 +130,7 @@ export const useUpdateTrigger = () => {
|
|||
return useMutation({
|
||||
mutationFn: updateTrigger,
|
||||
onSuccess: (updatedTrigger) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
|
||||
queryClient.setQueryData(
|
||||
['agent-triggers', updatedTrigger.agent_id],
|
||||
(old: TriggerConfiguration[] | undefined) => {
|
||||
|
@ -147,7 +149,8 @@ export const useDeleteTrigger = () => {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: deleteTrigger,
|
||||
onSuccess: (_, triggerId) => {
|
||||
onSuccess: (_, { triggerId, agentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', agentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-triggers'] });
|
||||
},
|
||||
});
|
||||
|
@ -164,6 +167,7 @@ export const useToggleTrigger = () => {
|
|||
});
|
||||
},
|
||||
onSuccess: (updatedTrigger) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
|
||||
queryClient.setQueryData(
|
||||
['agent-triggers', updatedTrigger.agent_id],
|
||||
(old: TriggerConfiguration[] | undefined) => {
|
||||
|
|
Loading…
Reference in New Issue