Merge branch 'dev' into q53n42-codex/add-list-operation-blocks

This commit is contained in:
Toran Bruce Richards
2025-06-27 21:33:09 +01:00
committed by GitHub
42 changed files with 1827 additions and 819 deletions

View File

@@ -6,6 +6,7 @@ from backend.data.model import SchemaField
from backend.util import json
from backend.util.file import store_media_file
from backend.util.mock import MockObject
from backend.util.prompt import estimate_token_count_str
from backend.util.type import MediaFileType, convert
@@ -466,6 +467,11 @@ class CreateListBlock(Block):
description="Maximum size of the list. If provided, the list will be yielded in chunks of this size.",
advanced=True,
)
max_tokens: int | None = SchemaField(
default=None,
description="Maximum tokens for the list. If provided, the list will be yielded in chunks that fit within this token limit.",
advanced=True,
)
class Output(BlockSchema):
list: List[Any] = SchemaField(
@@ -476,7 +482,7 @@ class CreateListBlock(Block):
def __init__(self):
super().__init__(
id="a912d5c7-6e00-4542-b2a9-8034136930e4",
description="Creates a list with the specified values. Use this when you know all the values you want to add upfront.",
description="Creates a list with the specified values. Use this when you know all the values you want to add upfront. This block can also yield the list in batches based on a maximum size or token limit.",
categories={BlockCategory.DATA},
input_schema=CreateListBlock.Input,
output_schema=CreateListBlock.Output,
@@ -501,12 +507,30 @@ class CreateListBlock(Block):
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
try:
max_size = input_data.max_size or len(input_data.values)
for i in range(0, len(input_data.values), max_size):
yield "list", input_data.values[i : i + max_size]
except Exception as e:
yield "error", f"Failed to create list: {str(e)}"
chunk = []
cur_tokens, max_tokens = 0, input_data.max_tokens
cur_size, max_size = 0, input_data.max_size
for value in input_data.values:
if max_tokens:
tokens = estimate_token_count_str(value)
else:
tokens = 0
# Check if adding this value would exceed either limit
if (max_tokens and (cur_tokens + tokens > max_tokens)) or (
max_size and (cur_size + 1 > max_size)
):
yield "list", chunk
chunk = [value]
cur_size, cur_tokens = 1, tokens
else:
chunk.append(value)
cur_size, cur_tokens = cur_size + 1, cur_tokens + tokens
# Yield final chunk if any
if chunk:
yield "list", chunk
class TypeOptions(enum.Enum):

View File

@@ -0,0 +1,5 @@
from .graph import NodeModel
from .integrations import Webhook # noqa: F401
# Resolve Webhook <- NodeModel forward reference
NodeModel.model_rebuild()

View File

@@ -77,10 +77,6 @@ class WebhookWithRelations(Webhook):
)
# Fix Webhook <- NodeModel relations
NodeModel.model_rebuild()
# --------------------- CRUD functions --------------------- #

View File

@@ -3,6 +3,7 @@ import logging
import os
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
@@ -14,13 +15,16 @@ from apscheduler.triggers.cron import CronTrigger
from autogpt_libs.utils.cache import thread_cached
from dotenv import load_dotenv
from prisma.enums import NotificationType
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import MetaData, create_engine
from backend.data.block import BlockInput
from backend.data.execution import ExecutionStatus
from backend.data.model import CredentialsMetaInput
from backend.executor import utils as execution_utils
from backend.notifications.notifications import NotificationManagerClient
from backend.util.exceptions import NotAuthorizedError, NotFoundError
from backend.util.logging import PrefixFilter
from backend.util.metrics import sentry_capture_error
from backend.util.service import (
AppService,
@@ -52,19 +56,19 @@ def _extract_schema_from_url(database_url) -> tuple[str, str]:
logger = logging.getLogger(__name__)
logger.addFilter(PrefixFilter("[Scheduler]"))
apscheduler_logger = logger.getChild("apscheduler")
apscheduler_logger.addFilter(PrefixFilter("[Scheduler] [APScheduler]"))
config = Config()
def log(msg, **kwargs):
logger.info("[Scheduler] " + msg, **kwargs)
def job_listener(event):
"""Logs job execution outcomes for better monitoring."""
if event.exception:
log(f"Job {event.job_id} failed.")
logger.error(f"Job {event.job_id} failed.")
else:
log(f"Job {event.job_id} completed successfully.")
logger.info(f"Job {event.job_id} completed successfully.")
@thread_cached
@@ -84,16 +88,17 @@ def execute_graph(**kwargs):
async def _execute_graph(**kwargs):
args = GraphExecutionJobArgs(**kwargs)
try:
log(f"Executing recurring job for graph #{args.graph_id}")
logger.info(f"Executing recurring job for graph #{args.graph_id}")
await execution_utils.add_graph_execution(
graph_id=args.graph_id,
inputs=args.input_data,
user_id=args.user_id,
graph_id=args.graph_id,
graph_version=args.graph_version,
inputs=args.input_data,
graph_credentials_inputs=args.input_credentials,
use_db_query=False,
)
except Exception as e:
logger.exception(f"Error executing graph {args.graph_id}: {e}")
logger.error(f"Error executing graph {args.graph_id}: {e}")
class LateExecutionException(Exception):
@@ -137,20 +142,20 @@ def report_late_executions() -> str:
def process_existing_batches(**kwargs):
args = NotificationJobArgs(**kwargs)
try:
log(
logger.info(
f"Processing existing batches for notification type {args.notification_types}"
)
get_notification_client().process_existing_batches(args.notification_types)
except Exception as e:
logger.exception(f"Error processing existing batches: {e}")
logger.error(f"Error processing existing batches: {e}")
def process_weekly_summary(**kwargs):
try:
log("Processing weekly summary")
logger.info("Processing weekly summary")
get_notification_client().queue_weekly_summary()
except Exception as e:
logger.exception(f"Error processing weekly summary: {e}")
logger.error(f"Error processing weekly summary: {e}")
class Jobstores(Enum):
@@ -160,11 +165,12 @@ class Jobstores(Enum):
class GraphExecutionJobArgs(BaseModel):
graph_id: str
input_data: BlockInput
user_id: str
graph_id: str
graph_version: int
cron: str
input_data: BlockInput
input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
class GraphExecutionJobInfo(GraphExecutionJobArgs):
@@ -247,7 +253,8 @@ class Scheduler(AppService):
),
# These don't really need persistence
Jobstores.WEEKLY_NOTIFICATIONS.value: MemoryJobStore(),
}
},
logger=apscheduler_logger,
)
if self.register_system_tasks:
@@ -285,34 +292,40 @@ class Scheduler(AppService):
def cleanup(self):
super().cleanup()
logger.info(f"[{self.service_name}] ⏳ Shutting down scheduler...")
logger.info("⏳ Shutting down scheduler...")
if self.scheduler:
self.scheduler.shutdown(wait=False)
@expose
def add_graph_execution_schedule(
self,
user_id: str,
graph_id: str,
graph_version: int,
cron: str,
input_data: BlockInput,
user_id: str,
input_credentials: dict[str, CredentialsMetaInput],
name: Optional[str] = None,
) -> GraphExecutionJobInfo:
job_args = GraphExecutionJobArgs(
graph_id=graph_id,
input_data=input_data,
user_id=user_id,
graph_id=graph_id,
graph_version=graph_version,
cron=cron,
input_data=input_data,
input_credentials=input_credentials,
)
job = self.scheduler.add_job(
execute_graph,
CronTrigger.from_crontab(cron),
kwargs=job_args.model_dump(),
replace_existing=True,
name=name,
trigger=CronTrigger.from_crontab(cron),
jobstore=Jobstores.EXECUTION.value,
replace_existing=True,
)
logger.info(
f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}"
)
log(f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}")
return GraphExecutionJobInfo.from_db(job_args, job)
@expose
@@ -321,14 +334,13 @@ class Scheduler(AppService):
) -> GraphExecutionJobInfo:
job = self.scheduler.get_job(schedule_id, jobstore=Jobstores.EXECUTION.value)
if not job:
log(f"Job {schedule_id} not found.")
raise ValueError(f"Job #{schedule_id} not found.")
raise NotFoundError(f"Job #{schedule_id} not found.")
job_args = GraphExecutionJobArgs(**job.kwargs)
if job_args.user_id != user_id:
raise ValueError("User ID does not match the job's user ID.")
raise NotAuthorizedError("User ID does not match the job's user ID")
log(f"Deleting job {schedule_id}")
logger.info(f"Deleting job {schedule_id}")
job.remove()
return GraphExecutionJobInfo.from_db(job_args, job)

View File

@@ -27,6 +27,7 @@ async def test_agent_schedule(server: SpinTestServer):
graph_version=1,
cron="0 0 * * *",
input_data={"input": "data"},
input_credentials={},
)
assert schedule

View File

@@ -9,7 +9,7 @@ import stripe
from autogpt_libs.auth.middleware import auth_middleware
from autogpt_libs.feature_flag.client import feature_flag
from autogpt_libs.utils.cache import thread_cached
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
from starlette.status import HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND
from typing_extensions import Optional, TypedDict
@@ -72,6 +72,7 @@ from backend.server.model import (
UpdatePermissionsRequest,
)
from backend.server.utils import get_user_id
from backend.util.exceptions import NotFoundError
from backend.util.service import get_service_client
from backend.util.settings import Settings
@@ -765,70 +766,94 @@ async def delete_graph_execution(
class ScheduleCreationRequest(pydantic.BaseModel):
graph_version: Optional[int] = None
name: str
cron: str
input_data: dict[Any, Any]
graph_id: str
graph_version: int
inputs: dict[str, Any]
credentials: dict[str, CredentialsMetaInput] = pydantic.Field(default_factory=dict)
@v1_router.post(
path="/schedules",
path="/graphs/{graph_id}/schedules",
summary="Create execution schedule",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def create_schedule(
async def create_graph_execution_schedule(
user_id: Annotated[str, Depends(get_user_id)],
schedule: ScheduleCreationRequest,
graph_id: str = Path(..., description="ID of the graph to schedule"),
schedule_params: ScheduleCreationRequest = Body(),
) -> scheduler.GraphExecutionJobInfo:
graph = await graph_db.get_graph(
schedule.graph_id, schedule.graph_version, user_id=user_id
graph_id=graph_id,
version=schedule_params.graph_version,
user_id=user_id,
)
if not graph:
raise HTTPException(
status_code=404,
detail=f"Graph #{schedule.graph_id} v.{schedule.graph_version} not found.",
detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.",
)
return await execution_scheduler_client().add_execution_schedule(
graph_id=schedule.graph_id,
graph_version=graph.version,
cron=schedule.cron,
input_data=schedule.input_data,
user_id=user_id,
graph_id=graph_id,
graph_version=graph.version,
name=schedule_params.name,
cron=schedule_params.cron,
input_data=schedule_params.inputs,
input_credentials=schedule_params.credentials,
)
@v1_router.get(
path="/graphs/{graph_id}/schedules",
summary="List execution schedules for a graph",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def list_graph_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
graph_id: str = Path(),
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
@v1_router.get(
path="/schedules",
summary="List execution schedules for a user",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def list_all_graphs_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(user_id=user_id)
@v1_router.delete(
path="/schedules/{schedule_id}",
summary="Delete execution schedule",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def delete_schedule(
schedule_id: str,
async def delete_graph_execution_schedule(
user_id: Annotated[str, Depends(get_user_id)],
) -> dict[Any, Any]:
await execution_scheduler_client().delete_schedule(schedule_id, user_id=user_id)
schedule_id: str = Path(..., description="ID of the schedule to delete"),
) -> dict[str, Any]:
try:
await execution_scheduler_client().delete_schedule(schedule_id, user_id=user_id)
except NotFoundError:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Schedule #{schedule_id} not found",
)
return {"id": schedule_id}
@v1_router.get(
path="/schedules",
summary="List execution schedules",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def get_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
graph_id: str | None = None,
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
########################################################
##################### API KEY ##############################
########################################################

View File

@@ -10,6 +10,10 @@ class NeedConfirmation(Exception):
"""The user must explicitly confirm that they want to proceed"""
class NotAuthorizedError(ValueError):
"""The user is not authorized to perform the requested operation"""
class InsufficientBalanceError(ValueError):
user_id: str
message: str

View File

@@ -14,8 +14,37 @@ def to_dict(data) -> dict:
return jsonable_encoder(data)
def dumps(data) -> str:
return json.dumps(to_dict(data))
def dumps(data: Any, *args: Any, **kwargs: Any) -> str:
"""
Serialize data to JSON string with automatic conversion of Pydantic models and complex types.
This function converts the input data to a JSON-serializable format using FastAPI's
jsonable_encoder before dumping to JSON. It handles Pydantic models, complex types,
and ensures proper serialization.
Parameters
----------
data : Any
The data to serialize. Can be any type including Pydantic models, dicts, lists, etc.
*args : Any
Additional positional arguments passed to json.dumps()
**kwargs : Any
Additional keyword arguments passed to json.dumps() (e.g., indent, separators)
Returns
-------
str
JSON string representation of the data
Examples
--------
>>> dumps({"name": "Alice", "age": 30})
'{"name": "Alice", "age": 30}'
>>> dumps(pydantic_model_instance, indent=2)
'{\n "field1": "value1",\n "field2": "value2"\n}'
"""
return json.dumps(to_dict(data), *args, **kwargs)
T = TypeVar("T")

View File

@@ -1,4 +1,4 @@
from logging import Logger
import logging
from backend.util.settings import AppEnvironment, BehaveAs, Settings
@@ -6,8 +6,6 @@ settings = Settings()
def configure_logging():
import logging
import autogpt_libs.logging.config
if (
@@ -25,7 +23,7 @@ def configure_logging():
class TruncatedLogger:
def __init__(
self,
logger: Logger,
logger: logging.Logger,
prefix: str = "",
metadata: dict | None = None,
max_length: int = 1000,
@@ -65,3 +63,13 @@ class TruncatedLogger:
if len(text) > self.max_length:
text = text[: self.max_length] + "..."
return text
class PrefixFilter(logging.Filter):
def __init__(self, prefix: str):
super().__init__()
self.prefix = prefix
def filter(self, record):
record.msg = f"{self.prefix} {record.msg}"
return True

View File

@@ -1,8 +1,10 @@
import json
from copy import deepcopy
from typing import Any
from tiktoken import encoding_for_model
from backend.util import json
# ---------------------------------------------------------------------------#
# INTERNAL UTILITIES #
# ---------------------------------------------------------------------------#
@@ -179,3 +181,26 @@ def estimate_token_count(
"""
enc = encoding_for_model(model) # best-match tokenizer
return sum(_msg_tokens(m, enc) for m in messages)
def estimate_token_count_str(
text: Any,
*,
model: str = "gpt-4o",
) -> int:
"""
Return the true token count of *text* when encoded for *model*.
Parameters
----------
text Input text.
model Model name; passed to tiktoken to pick the right
tokenizer (gpt-4o → 'o200k_base', others fallback).
Returns
-------
int Token count.
"""
enc = encoding_for_model(model) # best-match tokenizer
text = json.dumps(text) if not isinstance(text, str) else text
return _tok_len(text, enc)

View File

@@ -31,7 +31,7 @@ from tenacity import (
wait_exponential_jitter,
)
from backend.util.exceptions import InsufficientBalanceError
import backend.util.exceptions as exceptions
from backend.util.json import to_dict
from backend.util.metrics import sentry_init
from backend.util.process import AppProcess, get_service_name
@@ -106,7 +106,13 @@ EXCEPTION_MAPPING = {
ValueError,
TimeoutError,
ConnectionError,
InsufficientBalanceError,
*[
ErrorType
for _, ErrorType in inspect.getmembers(exceptions)
if inspect.isclass(ErrorType)
and issubclass(ErrorType, Exception)
and ErrorType.__module__ == exceptions.__name__
],
]
}

View File

@@ -5,6 +5,7 @@ const config: StorybookConfig = {
"../src/components/overview.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/tokens/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/atoms/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../src/components/agptui/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [

View File

@@ -89,6 +89,7 @@
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"vaul": "1.1.2",
"zod": "3.25.56"
},
"devDependencies": {

View File

@@ -200,6 +200,9 @@ importers:
uuid:
specifier: 11.1.0
version: 11.1.0
vaul:
specifier: 1.1.2
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
specifier: 3.25.56
version: 3.25.56
@@ -6866,6 +6869,12 @@ packages:
resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
engines: {node: '>= 0.10'}
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
@@ -14701,6 +14710,15 @@ snapshots:
validator@13.15.15: {}
vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3

View File

@@ -3191,6 +3191,48 @@
}
}
},
"/api/library/agents/by-graph/{graph_id}": {
"get": {
"tags": ["v2", "library", "private"],
"summary": "Get Library Agent By Graph Id",
"operationId": "getV2GetLibraryAgentByGraphId",
"parameters": [
{
"name": "graph_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Graph Id" }
},
{
"name": "version",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "integer" }, { "type": "null" }],
"title": "Version"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryAgent" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/library/agents/marketplace/{store_listing_version_id}": {
"get": {
"tags": ["v2", "library", "private", "store, library"],
@@ -5012,6 +5054,7 @@
"AGENT_INPUT",
"CONGRATS",
"GET_RESULTS",
"RUN_AGENTS",
"MARKETPLACE_VISIT",
"MARKETPLACE_ADD_AGENT",
"MARKETPLACE_RUN_AGENT",

View File

@@ -64,9 +64,10 @@ export default function AgentRunsPage(): React.ReactElement {
const [selectedRun, setSelectedRun] = useState<
GraphExecution | GraphExecutionMeta | null
>(null);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(
null,
);
const selectedSchedule =
selectedView.type == "schedule"
? schedules.find((s) => s.id == selectedView.id)
: null;
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
useState<boolean>(false);
@@ -100,9 +101,8 @@ export default function AgentRunsPage(): React.ReactElement {
selectView({ type: "preset", id });
}, []);
const selectSchedule = useCallback((schedule: Schedule) => {
selectView({ type: "schedule", id: schedule.id });
setSelectedSchedule(schedule);
const selectSchedule = useCallback((id: ScheduleID) => {
selectView({ type: "schedule", id });
}, []);
const graphVersions = useRef<Record<number, Graph>>({});
@@ -315,11 +315,8 @@ export default function AgentRunsPage(): React.ReactElement {
const fetchSchedules = useCallback(async () => {
if (!agent) return;
// TODO: filter in backend - https://github.com/Significant-Gravitas/AutoGPT/issues/9183
setSchedules(
(await api.listSchedules()).filter((s) => s.graph_id == agent.graph_id),
);
}, [api, agent]);
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
}, [api, agent?.graph_id]);
useEffect(() => {
fetchSchedules();
@@ -358,8 +355,28 @@ export default function AgentRunsPage(): React.ReactElement {
const deleteSchedule = useCallback(
async (scheduleID: ScheduleID) => {
const removedSchedule = await api.deleteSchedule(scheduleID);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleID);
setSchedules((schedules) => {
const newSchedules = schedules.filter(
(s) => s.id !== removedSchedule.id,
);
if (
selectedView.type == "schedule" &&
selectedView.id == removedSchedule.id
) {
if (newSchedules.length > 0) {
// Select next schedule if available
selectSchedule(newSchedules[0].id);
} else {
// Reset to draft view if current schedule was deleted
openRunDraftView();
}
}
return newSchedules;
});
openRunDraftView();
},
[schedules, api],
);
@@ -417,6 +434,14 @@ export default function AgentRunsPage(): React.ReactElement {
[agent, downloadGraph],
);
const onCreateSchedule = useCallback(
(schedule: Schedule) => {
setSchedules((prev) => [...prev, schedule]);
selectSchedule(schedule.id);
},
[selectView],
);
const onCreatePreset = useCallback(
(preset: LibraryAgentPreset) => {
setAgentPresets((prev) => [...prev, preset]);
@@ -454,9 +479,9 @@ export default function AgentRunsPage(): React.ReactElement {
onSelectPreset={selectPreset}
onSelectSchedule={selectSchedule}
onSelectDraftNewRun={openRunDraftView}
onDeleteRun={setConfirmingDeleteAgentRun}
onDeletePreset={setConfirmingDeleteAgentPreset}
onDeleteSchedule={deleteSchedule}
doDeleteRun={setConfirmingDeleteAgentRun}
doDeletePreset={setConfirmingDeleteAgentPreset}
doDeleteSchedule={deleteSchedule}
/>
<div className="flex-1">
@@ -486,6 +511,7 @@ export default function AgentRunsPage(): React.ReactElement {
<AgentRunDraftView
agent={agent}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
agentActions={agentActions}
/>
@@ -497,6 +523,7 @@ export default function AgentRunsPage(): React.ReactElement {
agentPresets.find((preset) => preset.id == selectedView.id)!
}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onUpdatePreset={onUpdatePreset}
doDeletePreset={setConfirmingDeleteAgentPreset}
agentActions={agentActions}
@@ -506,8 +533,10 @@ export default function AgentRunsPage(): React.ReactElement {
<AgentScheduleDetailsView
graph={graph}
schedule={selectedSchedule}
onForcedRun={selectRun}
// agent={agent}
agentActions={agentActions}
onForcedRun={selectRun}
doDeleteSchedule={deleteSchedule}
/>
)
) : null) || <LoadingBox className="h-[70vh]" />}

View File

@@ -32,12 +32,13 @@ const Monitor = () => {
const api = useBackendAPI();
const fetchSchedules = useCallback(async () => {
setSchedules(await api.listSchedules());
setSchedules(await api.listAllGraphsExecutionSchedules());
}, [api]);
const removeSchedule = useCallback(
async (scheduleId: ScheduleID) => {
const removedSchedule = await api.deleteSchedule(scheduleId);
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleId);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
},
[schedules, api],

View File

@@ -52,7 +52,7 @@ import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@@ -639,8 +639,11 @@ const FlowEditor: React.FC<{
// This function is called after cron expression is created
// So you can collect inputs for scheduling
const afterCronCreation = (cronExpression: string) => {
runnerUIRef.current?.collectInputsForScheduling(cronExpression);
const afterCronCreation = (cronExpression: string, scheduleName: string) => {
runnerUIRef.current?.collectInputsForScheduling(
cronExpression,
scheduleName,
);
};
// This function Opens up form for creating cron expression
@@ -728,10 +731,11 @@ const FlowEditor: React.FC<{
requestStopRun={requestStopRun}
runAgentTooltip={!isRunning ? "Run Agent" : "Stop Agent"}
/>
<CronScheduler
afterCronCreation={afterCronCreation}
<CronSchedulerDialog
open={openCron}
setOpen={setOpenCron}
afterCronCreation={afterCronCreation}
defaultScheduleName={agentName}
/>
</ReactFlow>
</div>

View File

@@ -36,14 +36,21 @@ interface RunnerUIWrapperProps {
isRunning: boolean;
isScheduling: boolean;
requestSaveAndRun: () => void;
scheduleRunner: (cronExpression: string, input: InputItem[]) => Promise<void>;
scheduleRunner: (
cronExpression: string,
input: InputItem[],
scheduleName: string,
) => Promise<void>;
}
export interface RunnerUIWrapperRef {
openRunnerInput: () => void;
openRunnerOutput: () => void;
runOrOpenInput: () => void;
collectInputsForScheduling: (cronExpression: string) => void;
collectInputsForScheduling: (
cronExpression: string,
scheduleName: string,
) => void;
}
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
@@ -63,6 +70,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
const [scheduledInput, setScheduledInput] = useState(false);
const [cronExpression, setCronExpression] = useState("");
const [scheduleName, setScheduleName] = useState("");
const getBlockInputsAndOutputs = useCallback((): {
inputs: InputItem[];
@@ -149,15 +157,19 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
}
};
const collectInputsForScheduling = (cron_exp: string) => {
const collectInputsForScheduling = (
cronExpression: string,
scheduleName: string,
) => {
const { inputs } = getBlockInputsAndOutputs();
setCronExpression(cron_exp);
setCronExpression(cronExpression);
setScheduleName(scheduleName);
if (inputs.length > 0) {
setScheduledInput(true);
setIsRunnerInputOpen(true);
} else {
scheduleRunner(cron_exp, []);
scheduleRunner(cronExpression, [], scheduleName);
}
};
@@ -186,6 +198,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
await scheduleRunner(
cronExpression,
getBlockInputsAndOutputs().inputs,
scheduleName,
);
setIsScheduling(false);
setIsRunnerInputOpen(false);

View File

@@ -9,17 +9,19 @@ import {
LibraryAgentPreset,
LibraryAgentPresetID,
LibraryAgentPresetUpdatable,
Schedule,
} from "@/lib/autogpt-server-api";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/components/integrations/credentials-input";
import { TypeBasedInput } from "@/components/type-based-input";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Trash2Icon } from "lucide-react";
import SchemaTooltip from "@/components/SchemaTooltip";
import { useToast } from "@/components/ui/use-toast";
import { isEmpty } from "lodash";
@@ -32,11 +34,13 @@ export default function AgentRunDraftView({
onCreatePreset,
onUpdatePreset,
doDeletePreset,
onCreateSchedule,
agentActions,
}: {
agent: LibraryAgent;
agentActions: ButtonAction[];
onRun: (runID: GraphExecutionID) => void;
onCreateSchedule: (schedule: Schedule) => void;
} & (
| {
onCreatePreset: (preset: LibraryAgentPreset) => void;
@@ -66,6 +70,7 @@ export default function AgentRunDraftView({
>(new Set());
const { state: onboardingState, completeStep: completeOnboardingStep } =
useOnboarding();
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
// Update values if agentPreset parameter is changed
useEffect(() => {
@@ -314,6 +319,43 @@ export default function AgentRunDraftView({
completeOnboardingStep,
]);
const openScheduleDialog = useCallback(() => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
if (!allRequiredInputsAreSet || !allCredentialsAreSet) {
notifyMissingInputs(false);
return;
}
setCronScheduleDialogOpen(true);
}, [
agent,
allRequiredInputsAreSet,
allCredentialsAreSet,
notifyMissingInputs,
]);
const doSetupSchedule = useCallback(
(cronExpression: string, scheduleName: string) => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
api
.createGraphExecutionSchedule({
graph_id: agent.graph_id,
graph_version: agent.graph_version,
name: scheduleName || agent.name,
cron: cronExpression,
inputs: inputValues,
credentials: inputCredentials,
})
.then((schedule) => onCreateSchedule(schedule))
.catch(toastOnFail("set up agent run schedule"));
},
[api, agent, inputValues, inputCredentials, onCreateSchedule, toastOnFail],
);
const runActions: ButtonAction[] = useMemo(
() => [
// "Regular" agent: [run] + [save as preset] buttons
@@ -328,6 +370,14 @@ export default function AgentRunDraftView({
variant: "accent",
callback: doRun,
},
{
label: (
<>
<CalendarClockIcon className="mr-2 size-4" /> Schedule
</>
),
callback: openScheduleDialog,
},
// {
// label: (
// <>
@@ -418,12 +468,12 @@ export default function AgentRunDraftView({
[
agent.has_external_trigger,
agentPreset,
api,
doRun,
doSetupTrigger,
doCreatePreset,
doUpdatePreset,
doDeletePreset,
openScheduleDialog,
changedPresetAttributes,
presetName,
allRequiredInputsAreSet,
@@ -545,6 +595,12 @@ export default function AgentRunDraftView({
title={`${agent.has_external_trigger ? "Trigger" : agentPreset ? "Preset" : "Run"} actions`}
actions={runActions}
/>
<CronSchedulerDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
afterCronCreation={doSetupSchedule}
defaultScheduleName={agent.name}
/>
<ActionButtonGroup title="Agent actions" actions={agentActions} />
</div>

View File

@@ -1,5 +1,5 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -30,11 +30,11 @@ interface AgentRunsSelectorListProps {
allowDraftNewRun?: boolean;
onSelectRun: (id: GraphExecutionID) => void;
onSelectPreset: (preset: LibraryAgentPresetID) => void;
onSelectSchedule: (schedule: Schedule) => void;
onSelectSchedule: (id: ScheduleID) => void;
onSelectDraftNewRun: () => void;
onDeleteRun: (id: GraphExecutionMeta) => void;
onDeletePreset: (id: LibraryAgentPresetID) => void;
onDeleteSchedule: (id: ScheduleID) => void;
doDeleteRun: (id: GraphExecutionMeta) => void;
doDeletePreset: (id: LibraryAgentPresetID) => void;
doDeleteSchedule: (id: ScheduleID) => void;
className?: string;
}
@@ -49,15 +49,23 @@ export default function AgentRunsSelectorList({
onSelectPreset,
onSelectSchedule,
onSelectDraftNewRun,
onDeleteRun,
onDeletePreset,
onDeleteSchedule,
doDeleteRun,
doDeletePreset,
doDeleteSchedule,
className,
}: AgentRunsSelectorListProps): React.ReactElement {
const [activeListTab, setActiveListTab] = useState<"runs" | "scheduled">(
"runs",
);
useEffect(() => {
if (selectedView.type === "schedule") {
setActiveListTab("scheduled");
} else {
setActiveListTab("runs");
}
}, [selectedView]);
const listItemClasses = "h-28 w-72 lg:h-32 xl:w-80";
return (
@@ -94,9 +102,7 @@ export default function AgentRunsSelectorList({
onClick={() => setActiveListTab("scheduled")}
>
<span>Scheduled</span>
<span className="text-neutral-600">
{schedules.filter((s) => s.graph_id === agent.graph_id).length}
</span>
<span className="text-neutral-600">{schedules.length}</span>
</Badge>
</div>
@@ -136,7 +142,7 @@ export default function AgentRunsSelectorList({
// timestamp={preset.last_run_time} // TODO: implement this
selected={selectedView.id === preset.id}
onClick={() => onSelectPreset(preset.id)}
onDelete={() => onDeletePreset(preset.id)}
onDelete={() => doDeletePreset(preset.id)}
/>
))}
{agentPresets.length > 0 && <Separator className="my-1" />}
@@ -158,26 +164,24 @@ export default function AgentRunsSelectorList({
timestamp={run.started_at}
selected={selectedView.id === run.id}
onClick={() => onSelectRun(run.id)}
onDelete={() => onDeleteRun(run)}
onDelete={() => doDeleteRun(run)}
/>
))}
</>
) : (
schedules
.filter((schedule) => schedule.graph_id === agent.graph_id)
.map((schedule) => (
<AgentRunSummaryCard
className={listItemClasses}
key={schedule.id}
type="schedule"
status="scheduled" // TODO: implement active/inactive status for schedules
title={schedule.name}
timestamp={schedule.next_run_time}
selected={selectedView.id === schedule.id}
onClick={() => onSelectSchedule(schedule)}
onDelete={() => onDeleteSchedule(schedule.id)}
/>
))
schedules.map((schedule) => (
<AgentRunSummaryCard
className={listItemClasses}
key={schedule.id}
type="schedule"
status="scheduled" // TODO: implement active/inactive status for schedules
title={schedule.name}
timestamp={schedule.next_run_time}
selected={selectedView.id === schedule.id}
onClick={() => onSelectSchedule(schedule.id)}
onDelete={() => doDeleteSchedule(schedule.id)}
/>
))
)}
</div>
</ScrollArea>

View File

@@ -5,27 +5,33 @@ import {
GraphExecutionID,
GraphMeta,
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { IconCross } from "@/components/ui/icons";
import { PlayIcon } from "lucide-react";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
export default function AgentScheduleDetailsView({
graph,
schedule,
onForcedRun,
agentActions,
onForcedRun,
doDeleteSchedule,
}: {
graph: GraphMeta;
schedule: Schedule;
onForcedRun: (runID: GraphExecutionID) => void;
agentActions: ButtonAction[];
onForcedRun: (runID: GraphExecutionID) => void;
doDeleteSchedule: (scheduleID: ScheduleID) => void;
}): React.ReactNode {
const api = useBackendAPI();
@@ -42,7 +48,11 @@ export default function AgentScheduleDetailsView({
selectedRunStatus.slice(1),
},
{
label: "Scheduled for",
label: "Schedule",
value: humanizeCronExpression(schedule.cron),
},
{
label: "Next run",
value: schedule.next_run_time.toLocaleString(),
},
];
@@ -70,14 +80,39 @@ export default function AgentScheduleDetailsView({
const runNow = useCallback(
() =>
api
.executeGraph(graph.id, graph.version, schedule.input_data)
.executeGraph(
graph.id,
graph.version,
schedule.input_data,
schedule.input_credentials,
)
.then((run) => onForcedRun(run.graph_exec_id))
.catch(toastOnFail("execute agent")),
[api, graph, schedule, onForcedRun, toastOnFail],
);
const runActions: ButtonAction[] = useMemo(
() => [{ label: "Run now", callback: () => runNow() }],
() => [
{
label: (
<>
<PlayIcon className="mr-2 size-4" />
Run now
</>
),
callback: runNow,
},
{
label: (
<>
<IconCross className="mr-2 size-4 px-0.5" />
Delete schedule
</>
),
callback: () => doDeleteSchedule(schedule.id),
variant: "destructive",
},
],
[runNow],
);

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { Separator } from "@/components/ui/separator";
import { CronScheduler } from "@/components/cron-scheduler";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
type CronSchedulerDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
afterCronCreation: (cronExpression: string, scheduleName: string) => void;
defaultScheduleName?: string;
};
export function CronSchedulerDialog({
open,
setOpen,
afterCronCreation,
defaultScheduleName = "",
}: CronSchedulerDialogProps) {
const { toast } = useToast();
const [cronExpression, setCronExpression] = useState<string>("");
const [scheduleName, setScheduleName] = useState<string>(defaultScheduleName);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setScheduleName(defaultScheduleName);
setCronExpression("");
}
}, [open]);
const handleDone = () => {
if (!scheduleName.trim()) {
toast({
title: "Please enter a schedule name",
variant: "destructive",
});
return;
}
afterCronCreation(cronExpression, scheduleName);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Schedule Task</DialogTitle>
<div className="p-2">
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<Input
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
/>
</div>
<CronScheduler onCronExpressionChange={setCronExpression} />
</div>
<Separator className="my-4" />
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleDone}>Done</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,338 @@
import React, { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CronFrequency, makeCronExpression } from "@/lib/cron-expression-utils";
const weekDays = [
{ label: "Su", value: 0 },
{ label: "Mo", value: 1 },
{ label: "Tu", value: 2 },
{ label: "We", value: 3 },
{ label: "Th", value: 4 },
{ label: "Fr", value: 5 },
{ label: "Sa", value: 6 },
];
const months = [
{ label: "Jan", value: "January" },
{ label: "Feb", value: "February" },
{ label: "Mar", value: "March" },
{ label: "Apr", value: "April" },
{ label: "May", value: "May" },
{ label: "Jun", value: "June" },
{ label: "Jul", value: "July" },
{ label: "Aug", value: "August" },
{ label: "Sep", value: "September" },
{ label: "Oct", value: "October" },
{ label: "Nov", value: "November" },
{ label: "Dec", value: "December" },
];
type CronSchedulerProps = {
onCronExpressionChange: (cronExpression: string) => void;
};
export function CronScheduler({
onCronExpressionChange,
}: CronSchedulerProps): React.ReactElement {
const [frequency, setFrequency] = useState<CronFrequency>("daily");
const [selectedMinute, setSelectedMinute] = useState<string>("0");
const [selectedTime, setSelectedTime] = useState<string>("09:00");
const [selectedWeekDays, setSelectedWeekDays] = useState<number[]>([]);
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>([]);
const [selectedMonths, setSelectedMonths] = useState<number[]>([]);
const [customInterval, setCustomInterval] = useState<{
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
const [showCustomDays, setShowCustomDays] = useState<boolean>(false);
useEffect(() => {
const cronExpr = makeCronExpression({
frequency,
minute:
frequency === "hourly"
? parseInt(selectedMinute)
: parseInt(selectedTime.split(":")[1]),
hour: parseInt(selectedTime.split(":")[0]),
days:
frequency === "weekly"
? selectedWeekDays
: frequency === "monthly"
? selectedMonthDays
: [],
months: frequency === "yearly" ? selectedMonths : [],
customInterval:
frequency === "custom" ? customInterval : { unit: "minutes", value: 1 },
});
onCronExpressionChange(cronExpr);
}, [
frequency,
selectedMinute,
selectedTime,
selectedWeekDays,
selectedMonthDays,
selectedMonths,
customInterval,
onCronExpressionChange,
]);
return (
<div className="max-w-md space-y-6">
<div className="space-y-4">
<Label className="text-base font-medium">Repeat</Label>
<Select
value={frequency}
onValueChange={(value: CronFrequency) => setFrequency(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="every minute">Every Minute</SelectItem>
<SelectItem value="hourly">Every Hour</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{frequency === "hourly" && (
<div className="flex items-center gap-2">
<Label>At minute</Label>
<Select value={selectedMinute} onValueChange={setSelectedMinute}>
<SelectTrigger className="w-24">
<SelectValue placeholder="Select minute" />
</SelectTrigger>
<SelectContent>
{[0, 15, 30, 45].map((min) => (
<SelectItem key={min} value={min.toString()}>
{min}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{frequency === "custom" && (
<div className="flex items-center gap-2">
<Label>Every</Label>
<Input
type="number"
min="1"
className="w-20"
value={customInterval.value}
onChange={(e) =>
setCustomInterval({
...customInterval,
value: parseInt(e.target.value),
})
}
/>
<Select
value={customInterval.unit}
onValueChange={(value: any) =>
setCustomInterval({ ...customInterval, unit: value })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{frequency === "weekly" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>On</Label>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedWeekDays.length === weekDays.length) {
setSelectedWeekDays([]);
} else {
setSelectedWeekDays(weekDays.map((day) => day.value));
}
}}
>
{selectedWeekDays.length === weekDays.length
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedWeekDays([1, 2, 3, 4, 5])}
>
Weekdays
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedWeekDays([0, 6])}
>
Weekends
</Button>
</div>
<div className="flex flex-wrap gap-2">
{weekDays.map((day) => (
<Button
key={day.value}
variant={
selectedWeekDays.includes(day.value) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedWeekDays((prev) =>
prev.includes(day.value)
? prev.filter((d) => d !== day.value)
: [...prev, day.value],
);
}}
>
{day.label}
</Button>
))}
</div>
</div>
)}
{frequency === "monthly" && (
<div className="space-y-4">
<Label>Days of Month</Label>
<div className="flex gap-2">
<Button
variant={!showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(false);
setSelectedMonthDays(
Array.from({ length: 31 }, (_, i) => i + 1),
);
}}
>
All Days
</Button>
<Button
variant={showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(true);
setSelectedMonthDays([]);
}}
>
Customize
</Button>
<Button
variant="outline"
onClick={() => setSelectedMonthDays([15])}
>
15th
</Button>
<Button
variant="outline"
onClick={() => setSelectedMonthDays([31])}
>
Last Day
</Button>
</div>
{showCustomDays && (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 31 }, (_, i) => (
<Button
key={i + 1}
variant={
selectedMonthDays.includes(i + 1) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedMonthDays((prev) =>
prev.includes(i + 1)
? prev.filter((d) => d !== i + 1)
: [...prev, i + 1],
);
}}
>
{i + 1}
</Button>
))}
</div>
)}
</div>
)}
{frequency === "yearly" && (
<div className="space-y-4">
<Label>Months</Label>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedMonths.length === months.length) {
setSelectedMonths([]);
} else {
setSelectedMonths(Array.from({ length: 12 }, (_, i) => i));
}
}}
>
{selectedMonths.length === months.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{months.map((month, i) => {
const monthNumber = i + 1;
return (
<Button
key={i}
variant={
selectedMonths.includes(monthNumber) ? "default" : "outline"
}
className="px-2 py-1"
onClick={() => {
setSelectedMonths((prev) =>
prev.includes(monthNumber)
? prev.filter((m) => m !== monthNumber)
: [...prev, monthNumber],
);
}}
>
{month.label}
</Button>
);
})}
</div>
</div>
)}
{frequency !== "every minute" && frequency !== "hourly" && (
<div className="flex items-center gap-4 space-y-2">
<Label className="pt-2">At</Label>
<Input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
/>
</div>
)}
</div>
);
}

View File

@@ -1,417 +0,0 @@
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "./ui/separator";
import { CronExpressionManager } from "@/lib/monitor/cronExpressionManager";
interface CronSchedulerProps {
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
open: boolean;
afterCronCreation: (cronExpression: string) => void;
}
export function CronScheduler({
setOpen,
open,
afterCronCreation,
}: CronSchedulerProps) {
const [frequency, setFrequency] = useState<
"minute" | "hour" | "daily" | "weekly" | "monthly" | "yearly" | "custom"
>("daily");
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [selectedTime, setSelectedTime] = useState<string>("09:00");
const [showCustomDays, setShowCustomDays] = useState<boolean>(false);
const [selectedMinute, setSelectedMinute] = useState<string>("0");
const [customInterval, setCustomInterval] = useState<{
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
// const [endType, setEndType] = useState<"never" | "on" | "after">("never");
// const [endDate, setEndDate] = useState<Date | undefined>();
// const [occurrences, setOccurrences] = useState<number>(1);
const weekDays = [
{ label: "Su", value: 0 },
{ label: "Mo", value: 1 },
{ label: "Tu", value: 2 },
{ label: "We", value: 3 },
{ label: "Th", value: 4 },
{ label: "Fr", value: 5 },
{ label: "Sa", value: 6 },
];
const months = [
{ label: "Jan", value: "January" },
{ label: "Feb", value: "February" },
{ label: "Mar", value: "March" },
{ label: "Apr", value: "April" },
{ label: "May", value: "May" },
{ label: "Jun", value: "June" },
{ label: "Jul", value: "July" },
{ label: "Aug", value: "August" },
{ label: "Sep", value: "September" },
{ label: "Oct", value: "October" },
{ label: "Nov", value: "November" },
{ label: "Dec", value: "December" },
];
const cron_manager = new CronExpressionManager();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Schedule Task</DialogTitle>
<div className="max-w-md space-y-6 p-2">
<div className="space-y-4">
<Label className="text-base font-medium">Repeat</Label>
<Select
onValueChange={(value: any) => setFrequency(value)}
defaultValue="daily"
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="minute">Every Minute</SelectItem>
<SelectItem value="hour">Every Hour</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{frequency === "hour" && (
<div className="flex items-center gap-2">
<Label>At minute</Label>
<Select
value={selectedMinute}
onValueChange={setSelectedMinute}
>
<SelectTrigger className="w-24">
<SelectValue placeholder="Select minute" />
</SelectTrigger>
<SelectContent>
{[0, 15, 30, 45].map((min) => (
<SelectItem key={min} value={min.toString()}>
{min}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{frequency === "custom" && (
<div className="flex items-center gap-2">
<Label>Every</Label>
<Input
type="number"
min="1"
className="w-20"
value={customInterval.value}
onChange={(e) =>
setCustomInterval({
...customInterval,
value: parseInt(e.target.value),
})
}
/>
<Select
value={customInterval.unit}
onValueChange={(value: any) =>
setCustomInterval({ ...customInterval, unit: value })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{frequency === "weekly" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>On</Label>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedDays.length === weekDays.length) {
setSelectedDays([]);
} else {
setSelectedDays(weekDays.map((day) => day.value));
}
}}
>
{selectedDays.length === weekDays.length
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedDays([1, 2, 3, 4, 5])}
>
Weekdays
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedDays([0, 6])}
>
Weekends
</Button>
</div>
<div className="flex flex-wrap gap-2">
{weekDays.map((day) => (
<Button
key={day.value}
variant={
selectedDays.includes(day.value) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(day.value)
? prev.filter((d) => d !== day.value)
: [...prev, day.value],
);
}}
>
{day.label}
</Button>
))}
</div>
</div>
)}
{frequency === "monthly" && (
<div className="space-y-4">
<Label>Days of Month</Label>
<div className="flex gap-2">
<Button
variant={!showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(false);
setSelectedDays(
Array.from({ length: 31 }, (_, i) => i + 1),
);
}}
>
All Days
</Button>
<Button
variant={showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(true);
setSelectedDays([]);
}}
>
Customize
</Button>
<Button variant="outline" onClick={() => setSelectedDays([15])}>
15th
</Button>
<Button variant="outline" onClick={() => setSelectedDays([31])}>
Last Day
</Button>
</div>
{showCustomDays && (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 31 }, (_, i) => (
<Button
key={i + 1}
variant={
selectedDays.includes(i + 1) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(i + 1)
? prev.filter((d) => d !== i + 1)
: [...prev, i + 1],
);
}}
>
{i + 1}
</Button>
))}
</div>
)}
</div>
)}
{frequency === "yearly" && (
<div className="space-y-4">
<Label>Months</Label>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedDays.length === months.length) {
setSelectedDays([]);
} else {
setSelectedDays(Array.from({ length: 12 }, (_, i) => i));
}
}}
>
{selectedDays.length === months.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{months.map((month, index) => (
<Button
key={index}
variant={
selectedDays.includes(index) ? "default" : "outline"
}
className="px-2 py-1"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(index)
? prev.filter((m) => m !== index)
: [...prev, index],
);
}}
>
{month.label}
</Button>
))}
</div>
</div>
)}
{frequency !== "minute" && frequency !== "hour" && (
<div className="flex items-center gap-4 space-y-2">
<Label className="pt-2">At</Label>
<Input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
/>
</div>
)}
<Separator />
{/*
On the backend, we are using standard cron expressions,
which makes it challenging to add an end date or stop execution
after a certain time using only cron expressions.
(since standard cron expressions have limitations, like the lack of a year field or more...).
We could also use ranges in cron expression for end dates but It doesm't cover all cases (sometimes break)
To automatically end the scheduler, we need to store the end date and time occurrence in the database
and modify scheduler.add_job. Currently, we can only stop the scheduler manually from the monitor tab.
*/}
{/* <div className="space-y-6">
<Label className="text-lg font-medium">Ends</Label>
<RadioGroup
value={endType}
onValueChange={(value: "never" | "on" | "after") =>
setEndType(value)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="never" id="never" />
<Label htmlFor="never">Never</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="on" id="on" />
<Label htmlFor="on" className="w-[50px]">
On
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full"
disabled={endType !== "on"}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={endDate}
onSelect={setEndDate}
disabled={(date) => date < new Date()}
fromDate={new Date()}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="after" id="after" />
<Label htmlFor="after" className="ml-2 w-[50px]">
After
</Label>
<Input
type="number"
className="ml-2 w-[100px]"
value={occurrences}
onChange={(e) => setOccurrences(Number(e.target.value))}
/>
<span>times</span>
</div>
</RadioGroup>
</div> */}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
const cronExpr = cron_manager.generateCronExpression(
frequency,
selectedTime,
selectedDays,
selectedMinute,
customInterval,
);
setFrequency("minute");
setSelectedDays([]);
setSelectedTime("00:00");
setShowCustomDays(false);
setSelectedMinute("0");
setCustomInterval({ value: 1, unit: "minutes" });
setOpen(false);
afterCronCreation(cronExpr);
}}
>
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,197 @@
import { Button } from "@/components/atoms/Button/Button";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { Dialog } from "./Dialog";
const meta: Meta<typeof Dialog> = {
title: "Molecules/Dialog",
component: Dialog,
parameters: {
layout: "centered",
docs: {
description: {
component:
"A responsive dialog component that automatically switches between modal dialog (desktop) and drawer (mobile). Built on top of Radix UI Dialog and Vaul drawer with custom styling. Supports compound components: Dialog.Trigger, Dialog.Content, and Dialog.Footer.",
},
},
},
argTypes: {
title: {
control: "text",
description: "Dialog title - can be string or React node",
},
forceOpen: {
control: "boolean",
description: "Force the dialog to stay open (useful for previewing)",
},
styling: {
control: "object",
description: "Custom CSS styles object",
},
onClose: {
action: "closed",
description: "Callback fired when dialog closes",
},
},
args: {
title: "Dialog Title",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
render: renderBasicDialog,
};
export const WithoutTitle: Story = {
render: renderDialogWithoutTitle,
};
export const ForceOpen: Story = {
args: {
forceOpen: true,
title: "Preview Dialog",
},
render: renderForceOpenDialog,
};
export const WithFooter: Story = {
render: renderDialogWithFooter,
};
export const Controlled: Story = {
render: renderControlledDialog,
};
export const CustomStyling: Story = {
render: renderCustomStyledDialog,
};
function renderBasicDialog() {
return (
<Dialog title="Basic Dialog">
<Dialog.Trigger>
<Button variant="primary">Open Dialog</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This is a basic dialog with some content.</p>
<p>
It automatically adapts to screen size - modal on desktop, drawer on
mobile.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderDialogWithoutTitle() {
return (
<Dialog>
<Dialog.Trigger>
<Button variant="primary">Open Dialog (no title)</Button>
</Dialog.Trigger>
<Dialog.Content>
<p className="flex min-h-[100px] flex-row items-center justify-center">
This dialog doesn&apos;t use the title prop, allowing for no header or
a custom header.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderForceOpenDialog(args: any) {
return (
<Dialog {...args}>
<Dialog.Content>
<p>This dialog is forced open for preview purposes.</p>
<p>
In real usage, you&apos;d typically use a trigger or controlled state.
</p>
</Dialog.Content>
</Dialog>
);
}
function renderDialogWithFooter() {
return (
<Dialog title="Dialog with Footer">
<Dialog.Trigger>
<Button variant="primary">Open Dialog with Footer</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This dialog includes a footer with action buttons.</p>
<p>Use the footer for primary and secondary actions.</p>
<Dialog.Footer>
<Button variant="ghost" size="small">
Cancel
</Button>
<Button variant="primary" size="small">
Confirm
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
function renderControlledDialog() {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = async () => {
setIsOpen(!isOpen);
};
return (
<div className="space-y-4">
<Button variant="primary" onClick={handleToggle}>
{isOpen ? "Close" : "Open"} Controlled Dialog
</Button>
<Dialog
title="Controlled Dialog"
controlled={{
isOpen,
set: setIsOpen,
}}
onClose={() => console.log("Dialog closed")}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<p>This dialog is controlled by external state.</p>
<p>
Open state:{" "}
<span className="font-bold">{isOpen ? "Open" : "Closed"}</span>
</p>
</div>
<Button onClick={handleToggle} className="mt-8" size="small">
Close this modal
</Button>
</Dialog.Content>
</Dialog>
</div>
);
}
function renderCustomStyledDialog() {
return (
<Dialog
title="Custom Styled Dialog"
styling={{
maxWidth: "800px",
backgroundColor: "rgb(248 250 252)",
border: "2px solid rgb(59 130 246)",
}}
>
<Dialog.Trigger>
<Button variant="primary">Open Custom Styled Dialog</Button>
</Dialog.Trigger>
<Dialog.Content>
<p>This dialog has custom styling applied.</p>
<p>You can customize dimensions, colors, and other CSS properties.</p>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import * as RXDialog from "@radix-ui/react-dialog";
import { CSSProperties, PropsWithChildren } from "react";
import { Drawer } from "vaul";
import { BaseContent } from "./components/BaseContent";
import { BaseFooter } from "./components/BaseFooter";
import { BaseTrigger } from "./components/BaseTrigger";
import { DialogCtx, useDialogCtx } from "./useDialogCtx";
import { useDialogInternal } from "./useDialogInternal";
interface Props extends PropsWithChildren {
title?: React.ReactNode;
styling?: CSSProperties;
forceOpen?: boolean;
onClose?: (() => void) | undefined;
controlled?: {
isOpen: boolean;
set: (open: boolean) => Promise<void> | void;
};
}
Dialog.Trigger = BaseTrigger;
Dialog.Content = BaseContent;
Dialog.Footer = BaseFooter;
function Dialog({
children,
title,
styling,
forceOpen = false,
onClose,
controlled,
}: Props) {
const config = useDialogInternal({ controlled });
const isOpen = forceOpen || config.isOpen;
return (
<DialogCtx.Provider
value={{
title: title || "",
styling,
isOpen,
isForceOpen: forceOpen,
isLargeScreen: config.isLgScreenUp,
handleOpen: config.handleOpen,
handleClose: async () => {
await config.handleClose();
onClose?.();
},
}}
>
{config.isLgScreenUp ? (
<RXDialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
config.handleClose();
onClose?.();
}
}}
>
{children}
</RXDialog.Root>
) : (
<Drawer.Root
shouldScaleBackground
open={isOpen}
onOpenChange={(open) => {
if (!open) {
config.handleClose();
onClose?.();
}
}}
>
{children}
</Drawer.Root>
)}
</DialogCtx.Provider>
);
}
export { Dialog, useDialogCtx };

View File

@@ -0,0 +1,17 @@
import { PropsWithChildren } from "react";
import { useDialogCtx } from "../useDialogCtx";
import { DialogWrap } from "./DialogWrap";
import { DrawerWrap } from "./DrawerWrap";
type Props = PropsWithChildren;
export function BaseContent({ children }: Props) {
const ctx = useDialogCtx();
return ctx.isLargeScreen ? (
<DialogWrap {...ctx}>{children}</DialogWrap>
) : (
<DrawerWrap {...ctx}>{children}</DrawerWrap>
);
}

View File

@@ -0,0 +1,31 @@
import { useDialogCtx } from "../useDialogCtx";
interface Props {
children: React.ReactNode;
testId?: string;
className?: string;
}
export function BaseFooter({
children,
testId = "modal-footer",
className = "",
}: Props) {
const ctx = useDialogCtx();
return ctx.isLargeScreen ? (
<div
className={`flex justify-end gap-4 pt-6 ${className}`}
data-testid={testId}
>
{children}
</div>
) : (
<div
className={`flex w-full items-end justify-between gap-4 pt-6 ${className}`}
data-testid={testId}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import React, { PropsWithChildren } from "react";
import { useDialogCtx } from "../useDialogCtx";
export function BaseTrigger({ children }: PropsWithChildren) {
const ctx = useDialogCtx();
return React.cloneElement(children as React.ReactElement, {
onClick: ctx.handleOpen,
className: "cursor-pointer",
});
}

View File

@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
import * as RXDialog from "@radix-ui/react-dialog";
import { CSSProperties, PropsWithChildren } from "react";
import { DialogCtx } from "../useDialogCtx";
import { modalStyles } from "./styles";
type BaseProps = DialogCtx & PropsWithChildren;
interface Props extends BaseProps {
title: React.ReactNode;
styling: CSSProperties | undefined;
withGradient?: boolean;
}
export function DialogWrap({
children,
title,
styling = {},
isForceOpen,
handleClose,
}: Props) {
return (
<RXDialog.Portal>
<RXDialog.Overlay className={modalStyles.overlay} />
<RXDialog.Content
onInteractOutside={handleClose}
onEscapeKeyDown={handleClose}
aria-describedby={undefined}
className={cn(modalStyles.content)}
style={{
...styling,
}}
>
<div
className={`flex items-center justify-between ${
title ? "pb-6" : "pb-0"
}`}
>
{title ? (
<RXDialog.Title className={modalStyles.title}>
{title}
</RXDialog.Title>
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<RXDialog.Title>{title}</RXDialog.Title>
</span>
)}
{isForceOpen && !handleClose ? null : (
<button
type="button"
onClick={handleClose}
aria-label="Close"
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-stone-900`}
>
<X className={modalStyles.icon} />
</button>
)}
</div>
<div className="overflow-y-auto">{children}</div>
</RXDialog.Content>
</RXDialog.Portal>
);
}

View File

@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import { X } from "@phosphor-icons/react";
import { PropsWithChildren } from "react";
import { Drawer } from "vaul";
import { DialogCtx } from "../useDialogCtx";
import { drawerStyles, modalStyles } from "./styles";
type BaseProps = DialogCtx & PropsWithChildren;
interface Props extends BaseProps {
testId?: string;
title: React.ReactNode;
handleClose: () => void;
}
export function DrawerWrap({
children,
title,
testId,
handleClose,
isForceOpen,
}: Props) {
const closeBtn = (
<Button variant="link" aria-label="Close" onClick={handleClose}>
<X width="1.5rem" />
</Button>
);
return (
<Drawer.Portal>
<Drawer.Overlay className={drawerStyles.overlay} />
<Drawer.Content
aria-describedby={undefined}
className={drawerStyles.content}
data-testid={testId}
onInteractOutside={handleClose}
>
<div
className={`flex w-full items-center justify-between ${
title ? "pb-6" : "pb-0"
}`}
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : null}
{!isForceOpen ? (
title ? (
closeBtn
) : (
<div
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700`}
>
{closeBtn}
</div>
)
) : null}
</div>
<div className="overflow-auto">{children}</div>
</Drawer.Content>
</Drawer.Portal>
);
}

View File

@@ -0,0 +1,23 @@
// Common styles as Tailwind class strings
const commonStyles = {
title: "font-poppins text-md md:text-lg leading-none",
overlay:
"fixed inset-0 z-50 bg-stone-500/20 dark:bg-black/50 backdrop-blur-md animate-fade-in",
content:
"bg-stone-100 dark:bg-stone-800 p-6 fixed rounded-xl flex flex-col z-50 w-full overflow-hidden",
};
// Modal specific styles
export const modalStyles = {
...commonStyles,
content: `${commonStyles.content} p-6 border border-stone-200 dark:border-stone-700 overflow-y-auto min-w-[40vw] max-w-[60vw] max-h-[95vh] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-fadein`,
iconWrap:
"absolute top-2 right-3 bg-transparent p-2 rounded-full transition-colors duration-300 ease-in-out outline-none border-none",
icon: "w-4 h-4 text-stone-800 dark:text-stone-300",
};
// Drawer specific styles
export const drawerStyles = {
...commonStyles,
content: `${commonStyles.content} max-h-[90vh] w-full bottom-0 rounded-br-none rounded-bl-none`,
};

View File

@@ -0,0 +1,27 @@
import { CSSProperties, createContext, useContext } from "react";
export function useDialogCtx() {
const modalContext = useContext(DialogCtx);
return modalContext;
}
export interface DialogCtx {
title: React.ReactNode;
handleOpen: () => void;
handleClose: () => void;
isOpen: boolean;
isForceOpen: boolean;
isLargeScreen: boolean;
styling: CSSProperties | undefined;
}
export const DialogCtx = createContext<DialogCtx>({
title: "",
isOpen: false,
isForceOpen: false,
isLargeScreen: true,
handleOpen: () => undefined,
handleClose: () => undefined,
styling: {},
});

View File

@@ -0,0 +1,51 @@
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useEffect, useState } from "react";
type Args = {
controlled:
| {
isOpen: boolean;
set: (open: boolean) => Promise<void> | void;
}
| undefined;
};
export function useDialogInternal({ controlled }: Args) {
const [isOpen, setIsOpen] = useState(false);
const breakpoint = useBreakpoint();
const [isLgScreenUp, setIsLgScreenUp] = useState(isLargeScreen(breakpoint));
useEffect(() => {
setIsLgScreenUp(isLargeScreen(breakpoint));
}, [breakpoint]);
// if first opened as modal, or drawer - we need to keep it this way
// because, given the current implementation, we can't switch between modal and drawer without a full remount
async function handleOpen() {
setIsLgScreenUp(isLargeScreen(breakpoint));
if (controlled) {
await controlled.set(true);
} else {
setIsOpen(true);
}
}
async function handleClose() {
if (controlled) {
await controlled.set(false);
} else {
setIsOpen(false);
}
}
return {
isOpen: controlled ? controlled.isOpen : isOpen,
handleOpen,
handleClose,
isLgScreenUp,
};
}

View File

@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ClockIcon, Loader2 } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { CronExpressionManager } from "@/lib/monitor/cronExpressionManager";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import {
Select,
SelectContent,
@@ -52,7 +52,6 @@ export const SchedulesTable = ({
}: SchedulesTableProps) => {
const { toast } = useToast();
const router = useRouter();
const cron_manager = new CronExpressionManager();
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
const [maxVersion, setMaxVersion] = useState<number>(0);
@@ -246,7 +245,7 @@ export const SchedulesTable = ({
</TableCell>
<TableCell>
<Badge variant="secondary">
{cron_manager.generateDescription(schedule.cron || "")}
{humanizeCronExpression(schedule.cron)}
</Badge>
</TableCell>

View File

@@ -1073,16 +1073,21 @@ export default function useAgentGraph(
// runs after saving cron expression and inputs (if exists)
const scheduleRunner = useCallback(
async (cronExpression: string, inputs: InputItem[]) => {
async (
cronExpression: string,
inputs: InputItem[],
scheduleName: string,
) => {
await saveAgent();
try {
if (flowID) {
await api.createSchedule({
await api.createGraphExecutionSchedule({
graph_id: flowID,
// flowVersion is always defined here because scheduling is opened for a specific version
graph_version: flowVersion!,
name: scheduleName,
cron: cronExpression,
input_data: inputs.reduce(
inputs: inputs.reduce(
(acc, input) => ({
...acc,
[input.hardcodedValues.name]: input.hardcodedValues.value,

View File

@@ -742,22 +742,35 @@ export default class BackendAPI {
/////////// SCHEDULES ////////////
//////////////////////////////////
async createSchedule(schedule: ScheduleCreatable): Promise<Schedule> {
return this._request("POST", `/schedules`, schedule).then(
parseScheduleTimestamp,
async createGraphExecutionSchedule(
params: ScheduleCreatable,
): Promise<Schedule> {
return this._request(
"POST",
`/graphs/${params.graph_id}/schedules`,
params,
).then(parseScheduleTimestamp);
}
async listGraphExecutionSchedules(graphID: GraphID): Promise<Schedule[]> {
return this._get(`/graphs/${graphID}/schedules`).then((schedules) =>
schedules.map(parseScheduleTimestamp),
);
}
async deleteSchedule(scheduleId: ScheduleID): Promise<{ id: string }> {
return this._request("DELETE", `/schedules/${scheduleId}`);
}
async listSchedules(): Promise<Schedule[]> {
/** @deprecated only used in legacy `Monitor` */
async listAllGraphsExecutionSchedules(): Promise<Schedule[]> {
return this._get(`/schedules`).then((schedules) =>
schedules.map(parseScheduleTimestamp),
);
}
async deleteGraphExecutionSchedule(
scheduleID: ScheduleID,
): Promise<{ id: ScheduleID }> {
return this._request("DELETE", `/schedules/${scheduleID}`);
}
//////////////////////////////////
////////////// OTTO //////////////
//////////////////////////////////

View File

@@ -771,6 +771,7 @@ export type ProfileDetails = {
avatar_url: string;
};
/* Mirror of backend/executor/scheduler.py:GraphExecutionJobInfo */
export type Schedule = {
id: ScheduleID;
name: string;
@@ -778,17 +779,21 @@ export type Schedule = {
user_id: UserID;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
input_data: Record<string, any>;
input_credentials: Record<string, CredentialsMetaInput>;
next_run_time: Date;
};
export type ScheduleID = Brand<string, "ScheduleID">;
/* Mirror of backend/server/routers/v1.py:ScheduleCreationRequest */
export type ScheduleCreatable = {
cron: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
name: string;
cron: string;
inputs: Record<string, any>;
credentials?: Record<string, CredentialsMetaInput>;
};
export type MyAgent = {

View File

@@ -0,0 +1,265 @@
export type CronFrequency =
| "every minute"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "custom";
export type CronExpressionParams =
| { frequency: "every minute" }
| {
frequency: "hourly";
minute: number;
}
| ((
| {
frequency: "daily";
}
| {
frequency: "weekly";
/** 0-based list of weekdays: 0 = Monday ... 6 = Sunday */
days: number[];
}
| {
frequency: "monthly";
/** 1-based list of month days */
days: number[];
}
| {
frequency: "yearly";
/** 1-based list of months (1-12) */
months: number[];
}
| {
frequency: "custom";
customInterval: { unit: string; value: number };
}
) & {
minute: number;
hour: number;
});
export function makeCronExpression(params: CronExpressionParams): string {
const frequency = params.frequency;
if (frequency === "every minute") return "* * * * *";
if (frequency === "hourly") return `${params.minute} * * * *`;
if (frequency === "daily") return `${params.minute} ${params.hour} * * *`;
if (frequency === "weekly") {
const { minute, hour, days } = params;
const weekDaysExpr = days.sort((a, b) => a - b).join(",");
return `${minute} ${hour} * * ${weekDaysExpr}`;
}
if (frequency === "monthly") {
const { minute, hour, days } = params;
const monthDaysExpr = days.sort((a, b) => a - b).join(",");
return `${minute} ${hour} ${monthDaysExpr} * *`;
}
if (frequency === "yearly") {
const { minute, hour, months } = params;
const monthList = months.sort((a, b) => a - b).join(",");
return `${minute} ${hour} 1 ${monthList} *`;
}
if (frequency === "custom") {
const { minute, hour, customInterval } = params;
if (customInterval.unit === "minutes") {
return `*/${customInterval.value} * * * *`;
} else if (customInterval.unit === "hours") {
return `0 */${customInterval.value} * * *`;
} else {
return `${minute} ${hour} */${customInterval.value} * *`;
}
}
return "";
}
export function humanizeCronExpression(cronExpression: string): string {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Invalid cron expression format.");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Handle every minute
if (cronExpression === "* * * * *") {
return "Every minute";
}
// Handle minute intervals (e.g., */5 * * * *)
if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.substring(2);
return `Every ${interval} minutes`;
}
// Handle hour intervals (e.g., 30 * * * *)
if (
hour === "*" &&
!minute.includes("/") &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
return `Every hour at minute ${minute}`;
}
// Handle every N hours (e.g., 0 */2 * * *)
if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.substring(2);
return `Every ${interval} hours`;
}
// Handle daily (e.g., 30 14 * * *)
if (
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
return `Every day at ${formatTime(hour, minute)}`;
}
// Handle weekly (e.g., 30 14 * * 1,3,5)
if (
dayOfWeek !== "*" &&
dayOfMonth === "*" &&
month === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = getDayNames(dayOfWeek);
return `Every ${days} at ${formatTime(hour, minute)}`;
}
// Handle monthly (e.g., 30 14 1,15 * *)
if (
dayOfMonth !== "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = dayOfMonth.split(",").map(Number);
const dayList = days.join(", ");
return `On day ${dayList} of every month at ${formatTime(hour, minute)}`;
}
// Handle yearly (e.g., 30 14 1 1,6,12 *)
if (
dayOfMonth !== "*" &&
month !== "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const months = getMonthNames(month);
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute)}`;
}
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
if (
minute.includes("/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.split("/")[1];
return `Every ${interval} minutes`;
}
// Handle custom hour intervals with other fields as * (e.g., every N hours)
if (
hour.includes("/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.split("/")[1];
return `Every ${interval} hours`;
}
// Handle specific days with custom intervals (e.g., every N days)
if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const interval = dayOfMonth.substring(2);
return `Every ${interval} days at ${formatTime(hour, minute)}`;
}
return `Cron Expression: ${cronExpression}`;
}
function formatTime(hour: string, minute: string): string {
const formattedHour = padZero(hour);
const formattedMinute = padZero(minute);
return `${formattedHour}:${formattedMinute}`;
}
function padZero(value: string): string {
return value.padStart(2, "0");
}
function getDayNames(dayOfWeek: string): string {
const days = dayOfWeek.split(",").map(Number);
const dayNames = days
.map((d) => {
const names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
return names[d] || `Unknown(${d})`;
})
.join(", ");
return dayNames;
}
function getMonthNames(month: string): string {
const months = month.split(",").map(Number);
const monthNames = months
.map((m) => {
const names = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[m - 1] || `Unknown(${m})`;
})
.join(", ");
return monthNames;
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
export type Breakpoint = "base" | "sm" | "md" | "lg" | "xl" | "2xl";
// Explicitly maps to tailwind breakpoints
const breakpoints: Record<Breakpoint, number> = {
base: 0,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
};
export function useBreakpoint(): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
useEffect(() => {
const getBreakpoint = () => {
const width = window.innerWidth;
if (width < breakpoints.sm) return "base";
if (width < breakpoints.md) return "sm";
if (width < breakpoints.lg) return "md";
if (width < breakpoints.xl) return "lg";
if (width < breakpoints["2xl"]) return "xl";
return "2xl";
};
const handleResize = () => {
const current = getBreakpoint();
setBreakpoint(current);
};
window.addEventListener("resize", handleResize);
handleResize(); // initial call
return () => window.removeEventListener("resize", handleResize);
}, []);
return breakpoint;
}
export function isLargeScreen(bp: Breakpoint) {
if (bp === "sm") return false;
if (bp === "md") return false;
if (bp === "lg") return true;
if (bp === "xl") return true;
if (bp === "2xl") return true;
return false;
}

View File

@@ -1,239 +0,0 @@
export class CronExpressionManager {
generateCronExpression(
frequency: string,
selectedTime: string,
selectedDays: number[],
selectedMinute: string,
customInterval: { unit: string; value: number },
): string {
const [hours, minutes] = selectedTime.split(":").map(Number);
let expression = "";
switch (frequency) {
case "minute":
expression = "* * * * *";
break;
case "hour":
expression = `${selectedMinute} * * * *`;
break;
case "daily":
expression = `${minutes} ${hours} * * *`;
break;
case "weekly":
const days = selectedDays.join(",");
expression = `${minutes} ${hours} * * ${days}`;
break;
case "monthly":
const monthDays = selectedDays.sort((a, b) => a - b).join(",");
expression = `${minutes} ${hours} ${monthDays} * *`;
break;
case "yearly":
const monthList = selectedDays
.map((d) => d + 1)
.sort((a, b) => a - b)
.join(",");
expression = `${minutes} ${hours} 1 ${monthList} *`;
break;
case "custom":
if (customInterval.unit === "minutes") {
expression = `*/${customInterval.value} * * * *`;
} else if (customInterval.unit === "hours") {
expression = `0 */${customInterval.value} * * *`;
} else {
expression = `${minutes} ${hours} */${customInterval.value} * *`;
}
break;
default:
expression = "";
}
return expression;
}
generateDescription(cronExpression: string): string {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Invalid cron expression format.");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Handle every minute
if (cronExpression === "* * * * *") {
return "Every minute";
}
// Handle minute intervals (e.g., */5 * * * *)
if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.substring(2);
return `Every ${interval} minutes`;
}
// Handle hour intervals (e.g., 30 * * * *)
if (
hour === "*" &&
!minute.includes("/") &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
return `Every hour at minute ${minute}`;
}
// Handle every N hours (e.g., 0 */2 * * *)
if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.substring(2);
return `Every ${interval} hours`;
}
// Handle daily (e.g., 30 14 * * *)
if (
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
return `Every day at ${this.formatTime(hour, minute)}`;
}
// Handle weekly (e.g., 30 14 * * 1,3,5)
if (
dayOfWeek !== "*" &&
dayOfMonth === "*" &&
month === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = this.getDayNames(dayOfWeek);
return `Every ${days} at ${this.formatTime(hour, minute)}`;
}
// Handle monthly (e.g., 30 14 1,15 * *)
if (
dayOfMonth !== "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = dayOfMonth.split(",").map(Number);
const dayList = days.join(", ");
return `On day ${dayList} of every month at ${this.formatTime(hour, minute)}`;
}
// Handle yearly (e.g., 30 14 1 1,6,12 *)
if (
dayOfMonth !== "*" &&
month !== "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const months = this.getMonthNames(month);
return `Every year on the 1st day of ${months} at ${this.formatTime(hour, minute)}`;
}
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
if (
minute.includes("/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.split("/")[1];
return `Every ${interval} minutes`;
}
// Handle custom hour intervals with other fields as * (e.g., every N hours)
if (
hour.includes("/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.split("/")[1];
return `Every ${interval} hours`;
}
// Handle specific days with custom intervals (e.g., every N days)
if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const interval = dayOfMonth.substring(2);
return `Every ${interval} days at ${this.formatTime(hour, minute)}`;
}
return `Cron Expression: ${cronExpression}`;
}
private formatTime(hour: string, minute: string): string {
const formattedHour = this.padZero(hour);
const formattedMinute = this.padZero(minute);
return `${formattedHour}:${formattedMinute}`;
}
private padZero(value: string): string {
return value.padStart(2, "0");
}
private getDayNames(dayOfWeek: string): string {
const days = dayOfWeek.split(",").map(Number);
const dayNames = days
.map((d) => {
const names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
return names[d] || `Unknown(${d})`;
})
.join(", ");
return dayNames;
}
private getMonthNames(month: string): string {
const months = month.split(",").map(Number);
const monthNames = months
.map((m) => {
const names = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[m - 1] || `Unknown(${m})`;
})
.join(", ");
return monthNames;
}
}