mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
feat: add frontend schedule panel to workflow editor (#SKY-8184) (#5146)
Some checks failed
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Failing after 6s
Auto Create GitHub Release on Version Change / check-version-change (push) Successful in 1m45s
Run tests and pre-commit / Frontend Lint and Build (push) Successful in 3m48s
Publish Fern Docs / run (push) Failing after 2m43s
Auto Create GitHub Release on Version Change / create-release (push) Has been skipped
Build Skyvern SDK and publish to PyPI / check-version-change (push) Successful in 3m14s
Some checks failed
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Failing after 6s
Auto Create GitHub Release on Version Change / check-version-change (push) Successful in 1m45s
Run tests and pre-commit / Frontend Lint and Build (push) Successful in 3m48s
Publish Fern Docs / run (push) Failing after 2m43s
Auto Create GitHub Release on Version Change / create-release (push) Has been skipped
Build Skyvern SDK and publish to PyPI / check-version-change (push) Successful in 3m14s
This commit is contained in:
parent
b76de94e5f
commit
cfe01b0abe
34 changed files with 3330 additions and 73 deletions
35
skyvern-frontend/package-lock.json
generated
35
skyvern-frontend/package-lock.json
generated
|
|
@ -45,6 +45,8 @@
|
|||
"cmdk": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"country-state-city": "^3.2.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.13.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"embla-carousel-react": "^8.0.0",
|
||||
"express": "^4.21.2",
|
||||
|
|
@ -5148,6 +5150,25 @@
|
|||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
|
||||
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cronstrue": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
|
||||
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
|
||||
"bin": {
|
||||
"cronstrue": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -6077,9 +6098,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
|
|
@ -6910,6 +6931,14 @@
|
|||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@
|
|||
"cors": "^2.8.5",
|
||||
"country-state-city": "^3.2.1",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.13.0",
|
||||
"embla-carousel-react": "^8.0.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"express": "^4.21.2",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ export const ArtifactType = {
|
|||
|
||||
export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
|
||||
|
||||
export const TriggerType = {
|
||||
Manual: "manual",
|
||||
Api: "api",
|
||||
Scheduled: "scheduled",
|
||||
} as const;
|
||||
|
||||
export type TriggerType = (typeof TriggerType)[keyof typeof TriggerType];
|
||||
|
||||
export const Status = {
|
||||
Created: "created",
|
||||
Running: "running",
|
||||
|
|
@ -394,6 +402,11 @@ export type WorkflowRunApiResponse = {
|
|||
script_run: boolean | null;
|
||||
status: Status;
|
||||
title?: string;
|
||||
trigger_type?: TriggerType | null;
|
||||
schedule_id?: string | null;
|
||||
schedule_name?: string | null;
|
||||
schedule_cron?: string | null;
|
||||
scheduled_for?: string | null;
|
||||
webhook_callback_url: string;
|
||||
workflow_id: string;
|
||||
workflow_permanent_id: string;
|
||||
|
|
|
|||
48
skyvern-frontend/src/components/TriggerTypeBadge.tsx
Normal file
48
skyvern-frontend/src/components/TriggerTypeBadge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { TriggerType } from "@/api/types";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CursorArrowIcon,
|
||||
LightningBoltIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Tip } from "@/components/Tip";
|
||||
|
||||
type Props = {
|
||||
triggerType: TriggerType | null | undefined;
|
||||
};
|
||||
|
||||
const triggerConfig: Record<
|
||||
TriggerType,
|
||||
{ icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
[TriggerType.Manual]: {
|
||||
icon: <CursorArrowIcon className="size-3.5 text-slate-400" />,
|
||||
label: "Manual",
|
||||
},
|
||||
[TriggerType.Api]: {
|
||||
icon: <LightningBoltIcon className="size-3.5 text-amber-400" />,
|
||||
label: "API",
|
||||
},
|
||||
[TriggerType.Scheduled]: {
|
||||
icon: <CalendarIcon className="size-3.5 text-blue-400" />,
|
||||
label: "Scheduled",
|
||||
},
|
||||
};
|
||||
|
||||
function TriggerTypeBadge({ triggerType }: Props) {
|
||||
if (!triggerType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = triggerConfig[triggerType];
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tip content={config.label}>
|
||||
<span className="inline-flex shrink-0 items-center">{config.icon}</span>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
export { TriggerTypeBadge };
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { CalendarIcon, ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { TriggerType } from "@/api/types";
|
||||
|
||||
type TriggerTypeDropdownItem = {
|
||||
label: string;
|
||||
value: TriggerType;
|
||||
};
|
||||
|
||||
const triggerTypeDropdownItems: Array<TriggerTypeDropdownItem> = [
|
||||
{
|
||||
label: "Manual",
|
||||
value: TriggerType.Manual,
|
||||
},
|
||||
{
|
||||
label: "API",
|
||||
value: TriggerType.Api,
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
value: TriggerType.Scheduled,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
values: Array<TriggerType>;
|
||||
onChange: (values: Array<TriggerType>) => void;
|
||||
};
|
||||
|
||||
function TriggerTypeFilterDropdown({ values, onChange }: Props) {
|
||||
const label =
|
||||
values.length === 0
|
||||
? "All Runs"
|
||||
: values.length === 1
|
||||
? triggerTypeDropdownItems.find((i) => i.value === values[0])?.label ??
|
||||
"All Runs"
|
||||
: `${values.length} types`;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<CalendarIcon className="mr-2 size-4" />
|
||||
{label}
|
||||
<ChevronDownIcon className="ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{triggerTypeDropdownItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className="flex items-center gap-2 p-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={`trigger-${item.value}`}
|
||||
checked={values.includes(item.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...values, item.value]);
|
||||
} else {
|
||||
onChange(values.filter((value) => value !== item.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`trigger-${item.value}`}>{item.label}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export { TriggerTypeFilterDropdown };
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Status, Task, WorkflowRunApiResponse } from "@/api/types";
|
||||
import { Status, Task, TriggerType, WorkflowRunApiResponse } from "@/api/types";
|
||||
|
||||
type QueryReturnType = Array<Task | WorkflowRunApiResponse>;
|
||||
type UseQueryOptions = Omit<
|
||||
|
|
@ -13,6 +13,7 @@ type Props = {
|
|||
page?: number;
|
||||
pageSize?: number;
|
||||
statusFilters?: Array<Status>;
|
||||
triggerTypeFilters?: Array<TriggerType>;
|
||||
search?: string;
|
||||
} & UseQueryOptions;
|
||||
|
||||
|
|
@ -20,11 +21,18 @@ function useRunsQuery({
|
|||
page = 1,
|
||||
pageSize = 10,
|
||||
statusFilters,
|
||||
triggerTypeFilters,
|
||||
search,
|
||||
}: Props) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
return useQuery<Array<Task | WorkflowRunApiResponse>>({
|
||||
queryKey: ["runs", { statusFilters }, page, pageSize, search],
|
||||
queryKey: [
|
||||
"runs",
|
||||
{ statusFilters, triggerTypeFilters },
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -35,6 +43,11 @@ function useRunsQuery({
|
|||
params.append("status", status);
|
||||
});
|
||||
}
|
||||
if (triggerTypeFilters) {
|
||||
triggerTypeFilters.forEach((triggerType) => {
|
||||
params.append("trigger_type", triggerType);
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
params.append("search_key", search);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import { WorkflowRunCode } from "@/routes/workflows/workflowRun/WorkflowRunCode"
|
|||
import { DebugStoreProvider } from "@/store/DebugStoreContext";
|
||||
import { CredentialsPage } from "@/routes/credentials/CredentialsPage.tsx";
|
||||
import { RunRouter } from "@/routes/runs/RunRouter";
|
||||
import { SchedulesRoute } from "@/routes/schedules/SchedulesRoute";
|
||||
import { ScheduleDetailRoute } from "@/routes/schedules/ScheduleDetailRoute";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
|
|
@ -66,6 +68,20 @@ const router = createBrowserRouter([
|
|||
path: "runs/:runId/*",
|
||||
element: <RunRouter />,
|
||||
},
|
||||
{
|
||||
path: "schedules",
|
||||
element: <PageLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SchedulesRoute />,
|
||||
},
|
||||
{
|
||||
path: ":workflowPermanentId/:scheduleId",
|
||||
element: <ScheduleDetailRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "browser-sessions",
|
||||
element: <BrowserSessions />,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { Tip } from "@/components/Tip";
|
|||
import {
|
||||
Status,
|
||||
Task,
|
||||
TriggerType,
|
||||
WorkflowRunApiResponse,
|
||||
WorkflowRunStatusApiResponse,
|
||||
} from "@/api/types";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusFilterDropdown } from "@/components/StatusFilterDropdown";
|
||||
import { TriggerTypeBadge } from "@/components/TriggerTypeBadge";
|
||||
import { TriggerTypeFilterDropdown } from "@/components/TriggerTypeFilterDropdown";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
|
|
@ -66,6 +69,9 @@ function RunHistory() {
|
|||
? Number(searchParams.get("page_size"))
|
||||
: 10;
|
||||
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
|
||||
const [triggerTypeFilters, setTriggerTypeFilters] = useState<
|
||||
Array<TriggerType>
|
||||
>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
|
|
@ -73,13 +79,20 @@ function RunHistory() {
|
|||
page,
|
||||
pageSize: itemsPerPage,
|
||||
statusFilters,
|
||||
triggerTypeFilters,
|
||||
search: debouncedSearch,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: nextPageRuns } = useQuery<Array<Task | WorkflowRunApiResponse>>(
|
||||
{
|
||||
queryKey: ["runs", { statusFilters }, page + 1, itemsPerPage],
|
||||
queryKey: [
|
||||
"runs",
|
||||
{ statusFilters, triggerTypeFilters },
|
||||
page + 1,
|
||||
itemsPerPage,
|
||||
debouncedSearch,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -90,6 +103,14 @@ function RunHistory() {
|
|||
params.append("status", status);
|
||||
});
|
||||
}
|
||||
if (triggerTypeFilters) {
|
||||
triggerTypeFilters.forEach((triggerType) => {
|
||||
params.append("trigger_type", triggerType);
|
||||
});
|
||||
}
|
||||
if (debouncedSearch) {
|
||||
params.append("search_key", debouncedSearch);
|
||||
}
|
||||
return client.get("/runs", { params }).then((res) => res.data);
|
||||
},
|
||||
enabled: runs && runs.length === itemsPerPage,
|
||||
|
|
@ -154,7 +175,7 @@ function RunHistory() {
|
|||
<header>
|
||||
<h1 className="text-2xl">Run History</h1>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
|
|
@ -166,9 +187,23 @@ function RunHistory() {
|
|||
placeholder="Search by run ID or parameter..."
|
||||
className="w-48 lg:w-72"
|
||||
/>
|
||||
<TriggerTypeFilterDropdown
|
||||
values={triggerTypeFilters}
|
||||
onChange={(values) => {
|
||||
setTriggerTypeFilters(values);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
<StatusFilterDropdown
|
||||
values={statusFilters}
|
||||
onChange={setStatusFilters}
|
||||
onChange={(values) => {
|
||||
setStatusFilters(values);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border">
|
||||
|
|
@ -241,17 +276,17 @@ function RunHistory() {
|
|||
);
|
||||
}
|
||||
|
||||
const workflowTitle =
|
||||
run.script_run === true ? (
|
||||
<div className="flex items-center gap-2">
|
||||
const workflowTitle = (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{run.workflow_title ?? ""}</span>
|
||||
{run.script_run === true && (
|
||||
<Tip content="Ran with code">
|
||||
<LightningBoltIcon className="text-[gold]" />
|
||||
</Tip>
|
||||
<span>{run.workflow_title ?? ""}</span>
|
||||
</div>
|
||||
) : (
|
||||
run.workflow_title ?? ""
|
||||
);
|
||||
)}
|
||||
<TriggerTypeBadge triggerType={run.trigger_type} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const isExpanded = expandedRows.has(run.workflow_run_id);
|
||||
const workflowExecutionTime = formatExecutionTime(
|
||||
|
|
@ -292,9 +327,25 @@ function RunHistory() {
|
|||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate"
|
||||
title={basicTimeFormat(run.created_at)}
|
||||
title={
|
||||
run.trigger_type === TriggerType.Scheduled &&
|
||||
run.scheduled_for
|
||||
? `Scheduled: ${basicTimeFormat(run.scheduled_for)}\nStarted: ${basicTimeFormat(run.created_at)}`
|
||||
: basicTimeFormat(run.created_at)
|
||||
}
|
||||
>
|
||||
{basicLocalTimeFormat(run.created_at)}
|
||||
<div className="flex flex-col">
|
||||
<span>{basicLocalTimeFormat(run.created_at)}</span>
|
||||
{run.trigger_type === TriggerType.Scheduled &&
|
||||
run.schedule_name && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{run.schedule_name}
|
||||
{run.schedule_cron
|
||||
? ` (${run.schedule_cron})`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{workflowExecutionTime ?? "-"}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@ import { NavLinkGroup } from "@/components/NavLinkGroup";
|
|||
import { useSidebarStore } from "@/store/SidebarStore";
|
||||
import { cn } from "@/util/utils";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CounterClockwiseClockIcon,
|
||||
GearIcon,
|
||||
GlobeIcon,
|
||||
LightningBoltIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { KeyIcon } from "@/components/icons/KeyIcon.tsx";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
|
||||
function SideNav() {
|
||||
const { collapsed } = useSidebarStore();
|
||||
const schedulesEnabled = useFeatureFlag("WORKFLOW_SCHEDULES");
|
||||
|
||||
return (
|
||||
<nav
|
||||
|
|
@ -37,6 +40,15 @@ function SideNav() {
|
|||
to: "/runs",
|
||||
icon: <CounterClockwiseClockIcon className="size-6" />,
|
||||
},
|
||||
...(schedulesEnabled
|
||||
? [
|
||||
{
|
||||
label: "Schedules",
|
||||
to: "/schedules",
|
||||
icon: <CalendarIcon className="size-6" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Browsers",
|
||||
to: "/browser-sessions",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||
import {
|
||||
CRON_PRESETS,
|
||||
cronToHumanReadable,
|
||||
formatNextRun,
|
||||
getLocalTimezone,
|
||||
getNextRuns,
|
||||
getTimezones,
|
||||
isValidCron,
|
||||
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
import { useCreateOrgScheduleMutation } from "./useCreateOrgScheduleMutation";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
|
||||
const navigate = useNavigate();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const createMutation = useCreateOrgScheduleMutation();
|
||||
|
||||
const [workflowSearch, setWorkflowSearch] = useState("");
|
||||
const [workflowPickerOpen, setWorkflowPickerOpen] = useState(false);
|
||||
const [selectedWorkflow, setSelectedWorkflow] =
|
||||
useState<WorkflowApiResponse | null>(null);
|
||||
const [cronExpression, setCronExpression] = useState("0 9 * * *");
|
||||
const [timezone, setTimezone] = useState(getLocalTimezone);
|
||||
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
|
||||
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [scheduleDescription, setScheduleDescription] = useState("");
|
||||
|
||||
const { data: workflows = [] } = useQuery<Array<WorkflowApiResponse>>({
|
||||
queryKey: ["workflows", "scheduleDialogPicker", workflowSearch],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", "1");
|
||||
params.append("page_size", "20");
|
||||
params.append("only_workflows", "true");
|
||||
if (workflowSearch) {
|
||||
params.append("search_key", workflowSearch);
|
||||
}
|
||||
return client.get("/workflows", { params }).then((r) => r.data);
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const allTimezones = useMemo(() => getTimezones(), []);
|
||||
const filteredTimezones = useMemo(() => {
|
||||
if (timezoneFilter === null) return allTimezones;
|
||||
if (!timezoneFilter) return allTimezones;
|
||||
const lower = timezoneFilter.toLowerCase();
|
||||
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
|
||||
}, [allTimezones, timezoneFilter]);
|
||||
|
||||
const valid = isValidCron(cronExpression);
|
||||
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
|
||||
const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : [];
|
||||
|
||||
function resetForm() {
|
||||
setWorkflowSearch("");
|
||||
setWorkflowPickerOpen(false);
|
||||
setSelectedWorkflow(null);
|
||||
setCronExpression("0 9 * * *");
|
||||
setTimezone(getLocalTimezone());
|
||||
setTimezoneFilter(null);
|
||||
setScheduleName("");
|
||||
setScheduleDescription("");
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!valid || !selectedWorkflow) return;
|
||||
createMutation.mutate(
|
||||
{
|
||||
workflowPermanentId: selectedWorkflow.workflow_permanent_id,
|
||||
request: {
|
||||
cron_expression: cronExpression,
|
||||
timezone,
|
||||
...(scheduleName && { name: scheduleName }),
|
||||
...(scheduleDescription && { description: scheduleDescription }),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
navigate(
|
||||
`/schedules/${selectedWorkflow.workflow_permanent_id}/${data.schedule.workflow_schedule_id}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a workflow and configure when it should run automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Workflow Picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Workflow</Label>
|
||||
{selectedWorkflow ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-slate-700 bg-slate-elevation3 px-3 py-2">
|
||||
<span className="text-sm">{selectedWorkflow.title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSelectedWorkflow(null)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={workflowSearch}
|
||||
onChange={(e) => setWorkflowSearch(e.target.value)}
|
||||
onFocus={() => setWorkflowPickerOpen(true)}
|
||||
placeholder="Search workflows..."
|
||||
/>
|
||||
{workflowPickerOpen && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
|
||||
{workflows.map((wf) => (
|
||||
<button
|
||||
key={wf.workflow_permanent_id}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
setSelectedWorkflow(wf);
|
||||
setWorkflowSearch("");
|
||||
}}
|
||||
>
|
||||
{wf.title}
|
||||
</button>
|
||||
))}
|
||||
{workflows.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-slate-500">
|
||||
No workflows found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Name & Description */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input
|
||||
placeholder="Auto-generated if empty"
|
||||
value={scheduleName}
|
||||
onChange={(e) => setScheduleName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
placeholder="Add a description..."
|
||||
value={scheduleDescription}
|
||||
onChange={(e) => setScheduleDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div className="space-y-2">
|
||||
<Label>Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRON_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant={
|
||||
cronExpression === preset.expression
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCronExpression(preset.expression)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Cron Input */}
|
||||
<div className="space-y-2">
|
||||
<Label>Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className={cn(!valid && cronExpression && "border-destructive")}
|
||||
/>
|
||||
{humanReadable && (
|
||||
<p className="text-sm text-slate-400">{humanReadable}</p>
|
||||
)}
|
||||
{!valid && cronExpression && (
|
||||
<p className="text-sm text-destructive">
|
||||
Invalid cron expression
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<Input
|
||||
value={timezoneFilter ?? timezone}
|
||||
onChange={(e) => setTimezoneFilter(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => {
|
||||
if (
|
||||
filteredTimezones.length === 1 &&
|
||||
filteredTimezones[0] !== undefined
|
||||
) {
|
||||
setTimezone(filteredTimezones[0]);
|
||||
}
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
placeholder="Search timezones..."
|
||||
/>
|
||||
{timezoneFilter !== null && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
|
||||
{filteredTimezones.slice(0, 20).map((tz) => (
|
||||
<button
|
||||
key={tz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-left text-sm hover:bg-slate-700",
|
||||
tz === timezone && "bg-slate-700 text-slate-50",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setTimezone(tz);
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
>
|
||||
{tz}
|
||||
</button>
|
||||
))}
|
||||
{filteredTimezones.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-slate-500">
|
||||
No timezones found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">Current: {timezone}</p>
|
||||
</div>
|
||||
|
||||
{/* Next Runs Preview */}
|
||||
{nextRuns.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Next Scheduled Runs</Label>
|
||||
<div className="space-y-1 rounded-md border border-slate-700 bg-slate-elevation3 p-3">
|
||||
{nextRuns.map((run) => (
|
||||
<div
|
||||
key={run.toISOString()}
|
||||
className="text-xs text-slate-400"
|
||||
>
|
||||
{formatNextRun(run, timezone)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!valid || !selectedWorkflow || createMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Schedule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { CreateOrgScheduleDialog };
|
||||
503
skyvern-frontend/src/routes/schedules/ScheduleDetailPage.tsx
Normal file
503
skyvern-frontend/src/routes/schedules/ScheduleDetailPage.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Pencil1Icon,
|
||||
ReloadIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||
import { useScheduleDetailQuery } from "./useScheduleDetailQuery";
|
||||
import {
|
||||
useDeleteOrgScheduleMutation,
|
||||
useDisableScheduleMutation,
|
||||
useEnableScheduleMutation,
|
||||
useUpdateScheduleMutation,
|
||||
} from "./useScheduleActions";
|
||||
import {
|
||||
CRON_PRESETS,
|
||||
cronToHumanReadable,
|
||||
formatNextRun,
|
||||
getNextRuns,
|
||||
getTimezones,
|
||||
isValidCron,
|
||||
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
|
||||
|
||||
function ScheduleDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { workflowPermanentId, scheduleId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const { data, isLoading, isError } = useScheduleDetailQuery(
|
||||
workflowPermanentId,
|
||||
scheduleId,
|
||||
);
|
||||
|
||||
const titleFromState = (location.state as { workflowTitle?: string })
|
||||
?.workflowTitle;
|
||||
const { data: workflow } = useQuery<WorkflowApiResponse>({
|
||||
queryKey: ["workflow", workflowPermanentId],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(`/workflows/${workflowPermanentId}`)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
enabled: !!workflowPermanentId && !titleFromState,
|
||||
});
|
||||
const workflowTitle =
|
||||
titleFromState || workflow?.title || workflowPermanentId || "Schedule";
|
||||
|
||||
const enableMutation = useEnableScheduleMutation();
|
||||
const disableMutation = useDisableScheduleMutation();
|
||||
const deleteMutation = useDeleteOrgScheduleMutation();
|
||||
const updateMutation = useUpdateScheduleMutation();
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Edit mode state
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editCron, setEditCron] = useState("");
|
||||
const [editTimezone, setEditTimezone] = useState("");
|
||||
// TODO - Create shared util
|
||||
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
|
||||
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
|
||||
const allTimezones = useMemo(() => getTimezones(), []);
|
||||
const filteredTimezones = useMemo(() => {
|
||||
if (timezoneFilter === null) return allTimezones;
|
||||
if (!timezoneFilter) return allTimezones;
|
||||
const lower = timezoneFilter.toLowerCase();
|
||||
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
|
||||
}, [allTimezones, timezoneFilter]);
|
||||
// ! end TODO
|
||||
|
||||
const editValid = isValidCron(editCron);
|
||||
const editHumanReadable = editValid ? cronToHumanReadable(editCron) : null;
|
||||
const editNextRuns = editValid ? getNextRuns(editCron, editTimezone, 5) : [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<ReloadIcon className="size-6 animate-spin text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="py-20 text-center text-sm text-red-400">
|
||||
Failed to load schedule details.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { schedule, next_runs } = data;
|
||||
const humanReadable = cronToHumanReadable(schedule.cron_expression);
|
||||
|
||||
function startEditing() {
|
||||
setEditCron(schedule.cron_expression);
|
||||
setEditTimezone(schedule.timezone);
|
||||
setTimezoneFilter(null);
|
||||
setEditName(schedule.name ?? "");
|
||||
setEditDescription(schedule.description ?? "");
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditing(false);
|
||||
setTimezoneFilter(null);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editValid || !workflowPermanentId || !scheduleId) return;
|
||||
updateMutation.mutate(
|
||||
{
|
||||
workflowPermanentId,
|
||||
scheduleId,
|
||||
request: {
|
||||
cron_expression: editCron,
|
||||
timezone: editTimezone,
|
||||
enabled: schedule.enabled,
|
||||
parameters: schedule.parameters,
|
||||
...(editName && { name: editName }),
|
||||
description: editDescription || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => setEditing(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
const item = {
|
||||
workflow_schedule_id: schedule.workflow_schedule_id,
|
||||
organization_id: schedule.organization_id,
|
||||
workflow_permanent_id: schedule.workflow_permanent_id,
|
||||
workflow_title: "",
|
||||
cron_expression: schedule.cron_expression,
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
parameters: schedule.parameters,
|
||||
name: schedule.name ?? null,
|
||||
description: schedule.description ?? null,
|
||||
next_run: null,
|
||||
created_at: schedule.created_at,
|
||||
modified_at: schedule.modified_at,
|
||||
};
|
||||
if (checked) {
|
||||
enableMutation.mutate(item);
|
||||
} else {
|
||||
disableMutation.mutate(item);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
const item = {
|
||||
workflow_schedule_id: schedule.workflow_schedule_id,
|
||||
organization_id: schedule.organization_id,
|
||||
workflow_permanent_id: schedule.workflow_permanent_id,
|
||||
workflow_title: "",
|
||||
cron_expression: schedule.cron_expression,
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
parameters: schedule.parameters,
|
||||
name: schedule.name ?? null,
|
||||
description: schedule.description ?? null,
|
||||
next_run: null,
|
||||
created_at: schedule.created_at,
|
||||
modified_at: schedule.modified_at,
|
||||
};
|
||||
deleteMutation.mutate(item, {
|
||||
onSuccess: () => navigate("/schedules"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
onClick={() => navigate("/schedules")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-normal text-slate-50">
|
||||
{schedule.name ?? workflowTitle}
|
||||
</h1>
|
||||
{schedule.description && (
|
||||
<p className="text-sm text-slate-400">{schedule.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
{humanReadable} · {schedule.timezone}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={schedule.enabled} onCheckedChange={handleToggle} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Schedule Configuration */}
|
||||
<div className="rounded-lg border border-slate-700 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm text-slate-400">Schedule Configuration</h3>
|
||||
{!editing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<Pencil1Icon className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Schedule name"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CRON_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant={
|
||||
editCron === preset.expression ? "default" : "secondary"
|
||||
}
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setEditCron(preset.expression)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Expression */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cron Expression</Label>
|
||||
<Input
|
||||
value={editCron}
|
||||
onChange={(e) => setEditCron(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className={cn(
|
||||
"h-8 text-sm",
|
||||
!editValid && editCron && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
{editHumanReadable && (
|
||||
<p className="text-xs text-slate-400">{editHumanReadable}</p>
|
||||
)}
|
||||
{!editValid && editCron && (
|
||||
<p className="text-xs text-destructive">
|
||||
Invalid cron expression
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Timezone</Label>
|
||||
<Input
|
||||
value={timezoneFilter ?? editTimezone}
|
||||
onChange={(e) => setTimezoneFilter(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => {
|
||||
if (
|
||||
filteredTimezones.length === 1 &&
|
||||
filteredTimezones[0] !== undefined
|
||||
) {
|
||||
setEditTimezone(filteredTimezones[0]);
|
||||
}
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
placeholder="Search timezones..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
{timezoneFilter !== null && (
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
|
||||
{filteredTimezones.slice(0, 15).map((tz) => (
|
||||
<button
|
||||
key={tz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-1 text-left text-xs hover:bg-slate-700",
|
||||
tz === editTimezone && "bg-slate-700 text-slate-50",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setEditTimezone(tz);
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
>
|
||||
{tz}
|
||||
</button>
|
||||
))}
|
||||
{filteredTimezones.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-xs text-slate-500">
|
||||
No timezones found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
Current: {editTimezone}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview next runs */}
|
||||
{editNextRuns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Next Runs Preview</Label>
|
||||
<div className="space-y-0.5">
|
||||
{editNextRuns.map((run) => (
|
||||
<p
|
||||
key={run.toISOString()}
|
||||
className="text-xs text-slate-500"
|
||||
>
|
||||
{formatNextRun(run, editTimezone)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save / Cancel */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!editValid || updateMutation.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={cancelEditing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-slate-400">Frequency</span>
|
||||
<span className="text-sm text-slate-50">{humanReadable}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-slate-400">Timezone</span>
|
||||
<span className="text-sm text-slate-50">
|
||||
{schedule.timezone}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-slate-400">Cron</span>
|
||||
<code className="font-mono text-xs text-slate-50">
|
||||
{schedule.cron_expression}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details + Upcoming Runs */}
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-slate-700 p-4">
|
||||
<h3 className="mb-4 text-sm text-slate-400">Details</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-slate-400">Created</span>
|
||||
<span
|
||||
className="text-sm text-slate-50"
|
||||
title={basicTimeFormat(schedule.created_at)}
|
||||
>
|
||||
{basicLocalTimeFormat(schedule.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-slate-400">Last Modified</span>
|
||||
<span
|
||||
className="text-sm text-slate-50"
|
||||
title={basicTimeFormat(schedule.modified_at)}
|
||||
>
|
||||
{basicLocalTimeFormat(schedule.modified_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-700 p-4">
|
||||
<h3 className="mb-4 text-sm text-slate-400">Upcoming Runs</h3>
|
||||
<p className="mb-2 text-xs text-slate-400">
|
||||
Next {next_runs.length} runs
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{next_runs.map((run) => (
|
||||
<p key={run} className="text-xs text-slate-500">
|
||||
{formatNextRun(new Date(run), schedule.timezone)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteMutation.isPending) {
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScheduleDetailPage };
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
import { ScheduleDetailPage } from "@/routes/schedules/ScheduleDetailPage";
|
||||
|
||||
export function ScheduleDetailRoute() {
|
||||
const enabled = useFeatureFlag("WORKFLOW_SCHEDULES");
|
||||
if (enabled) return <ScheduleDetailPage />;
|
||||
return <Navigate to="/workflows" replace />;
|
||||
}
|
||||
606
skyvern-frontend/src/routes/schedules/SchedulesPage.tsx
Normal file
606
skyvern-frontend/src/routes/schedules/SchedulesPage.tsx
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
DotsHorizontalIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReloadIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { TableSearchInput } from "@/components/TableSearchInput";
|
||||
import { useOrganizationSchedulesQuery } from "./useOrganizationSchedulesQuery";
|
||||
import {
|
||||
useDeleteOrgScheduleMutation,
|
||||
useDisableScheduleMutation,
|
||||
useDuplicateScheduleMutation,
|
||||
useEnableScheduleMutation,
|
||||
} from "./useScheduleActions";
|
||||
import { cronToHumanReadable } from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
|
||||
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
|
||||
import type { OrganizationScheduleItem } from "@/routes/workflows/types/scheduleTypes";
|
||||
import { CreateOrgScheduleDialog } from "./CreateOrgScheduleDialog";
|
||||
|
||||
type ScheduleStatus = "active" | "paused";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ label: string; value: ScheduleStatus }> = [
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Paused", value: "paused" },
|
||||
];
|
||||
|
||||
function StatusDisplay({ enabled }: Readonly<{ enabled: boolean }>) {
|
||||
if (enabled) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-4 text-green-400">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="8" cy="8" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm capitalize text-slate-300">active</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-4 text-amber-400">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm capitalize text-slate-300">paused</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["10", "25", "50"];
|
||||
|
||||
function SchedulesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("page_size") || "10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
const [statusFilters, setStatusFilters] = useState<ScheduleStatus[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const lastSelectedIndex = useRef<number | null>(null);
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean;
|
||||
schedule: OrganizationScheduleItem | null;
|
||||
}>({ open: false, schedule: null });
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
const statusFilter =
|
||||
statusFilters.length === 1 ? statusFilters[0] : undefined;
|
||||
|
||||
const { data, isLoading, isError, error } = useOrganizationSchedulesQuery({
|
||||
page,
|
||||
pageSize,
|
||||
statusFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
const enableMutation = useEnableScheduleMutation();
|
||||
const disableMutation = useDisableScheduleMutation();
|
||||
const deleteMutation = useDeleteOrgScheduleMutation();
|
||||
const duplicateMutation = useDuplicateScheduleMutation();
|
||||
|
||||
const schedules = data?.schedules ?? [];
|
||||
const totalCount = data?.total_count ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
|
||||
const allSelected =
|
||||
schedules.length > 0 &&
|
||||
schedules.every((s) => selected.has(s.workflow_schedule_id));
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(schedules.map((s) => s.workflow_schedule_id)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(index: number, shiftKey: boolean) {
|
||||
const id = schedules[index]!.workflow_schedule_id;
|
||||
if (shiftKey && lastSelectedIndex.current !== null) {
|
||||
const start = Math.min(lastSelectedIndex.current, index);
|
||||
const end = Math.max(lastSelectedIndex.current, index);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(schedules[i]!.workflow_schedule_id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
lastSelectedIndex.current = index;
|
||||
}
|
||||
|
||||
function setPage(p: number) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", String(p));
|
||||
setSearchParams(params);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function setPageSize(size: string) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page_size", size);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
if (!deleteDialog.schedule) return;
|
||||
deleteMutation.mutate(deleteDialog.schedule, {
|
||||
onSettled: () => {
|
||||
setDeleteDialog({ open: false, schedule: null });
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deleteDialog.schedule!.workflow_schedule_id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const selectedSchedules = schedules.filter((s) =>
|
||||
selected.has(s.workflow_schedule_id),
|
||||
);
|
||||
|
||||
function handleBulkActivate() {
|
||||
selectedSchedules
|
||||
.filter((s) => !s.enabled)
|
||||
.forEach((s) => enableMutation.mutate(s));
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function handleBulkPause() {
|
||||
selectedSchedules
|
||||
.filter((s) => s.enabled)
|
||||
.forEach((s) => disableMutation.mutate(s));
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function handleBulkDuplicate() {
|
||||
selectedSchedules.forEach((s) => duplicateMutation.mutate(s));
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function handleBulkDelete() {
|
||||
selectedSchedules.forEach((s) => deleteMutation.mutate(s));
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
const showCheckbox = schedules.length > 1;
|
||||
const columnCount = showCheckbox ? 7 : 6;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl">Schedules</h1>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters + Create */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by workflow or schedule name..."
|
||||
className="w-64"
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
Filter by Status
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 p-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={`schedule-status-${opt.value}`}
|
||||
checked={statusFilters.includes(opt.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
setStatusFilters((prev) =>
|
||||
checked
|
||||
? [...prev, opt.value]
|
||||
: prev.filter((f) => f !== opt.value),
|
||||
);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`schedule-status-${opt.value}`}>
|
||||
{opt.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{statusFilters.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer p-2 text-left text-sm text-slate-400 hover:text-slate-200"
|
||||
onClick={() => {
|
||||
setStatusFilters([]);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
Create Schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-lg border border-slate-700">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="bg-slate-elevation2 text-slate-400 [&_tr]:border-b-0">
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableHead className="w-[3%]">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className={showCheckbox ? "w-[28%]" : "w-[31%]"}>
|
||||
Workflow
|
||||
</TableHead>
|
||||
<TableHead className="w-[20%]">Name</TableHead>
|
||||
<TableHead className="w-[20%]">Schedule</TableHead>
|
||||
<TableHead className="w-[17%]">Next Run</TableHead>
|
||||
<TableHead className="w-[7%]">Status</TableHead>
|
||||
<TableHead className="w-[5%]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount} className="py-8 text-center">
|
||||
<ReloadIcon className="mx-auto size-5 animate-spin text-slate-400" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isError && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnCount}
|
||||
className="py-8 text-center text-sm text-red-400"
|
||||
>
|
||||
Failed to load schedules.
|
||||
{error?.message && (
|
||||
<span className="block text-xs text-slate-500">
|
||||
{error.message}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && !isError && schedules.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnCount}
|
||||
className="py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
No schedules found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{schedules.map((schedule, index) => (
|
||||
<TableRow
|
||||
key={schedule.workflow_schedule_id}
|
||||
className="cursor-pointer select-none hover:bg-muted/50"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/schedules/${schedule.workflow_permanent_id}/${schedule.workflow_schedule_id}`,
|
||||
{ state: { workflowTitle: schedule.workflow_title } },
|
||||
)
|
||||
}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<TableCell
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(index, e.shiftKey);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(schedule.workflow_schedule_id)}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="truncate font-medium">
|
||||
{schedule.workflow_title}
|
||||
</TableCell>
|
||||
<TableCell className="truncate text-slate-400">
|
||||
{schedule.name ?? "\u2014"}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{cronToHumanReadable(schedule.cron_expression)}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{schedule.next_run ? (
|
||||
<span title={basicTimeFormat(schedule.next_run)}>
|
||||
{basicLocalTimeFormat(schedule.next_run)}
|
||||
</span>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusDisplay enabled={schedule.enabled} />
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
>
|
||||
<DotsHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{schedule.enabled ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => disableMutation.mutate(schedule)}
|
||||
>
|
||||
<PauseIcon className="mr-2 size-4" />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => enableMutation.mutate(schedule)}
|
||||
>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Activate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => duplicateMutation.mutate(schedule)}
|
||||
>
|
||||
<CopyIcon className="mr-2 size-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setDeleteDialog({ open: true, schedule })
|
||||
}
|
||||
className="text-destructive"
|
||||
>
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Pagination with Items per page */}
|
||||
<div className="grid grid-cols-3 items-center px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm text-slate-400">
|
||||
Items per page
|
||||
</span>
|
||||
<Select value={String(pageSize)} onValueChange={setPageSize}>
|
||||
<SelectTrigger className="w-[65px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={size}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="justify-self-center">
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
className={
|
||||
page <= 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
onClick={() => setPage(p)}
|
||||
isActive={p === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
className={
|
||||
page >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-select bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed inset-x-0 bottom-6 mx-auto flex w-fit items-center gap-3 rounded-lg border border-slate-700 bg-slate-900 px-6 py-3 shadow-xl">
|
||||
<span className="text-sm text-slate-300">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<div className="h-6 w-px bg-slate-700" />
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-900 text-green-50 hover:bg-green-800"
|
||||
onClick={handleBulkActivate}
|
||||
>
|
||||
<PlayIcon className="mr-1.5 size-3.5" />
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-800 text-amber-50 hover:bg-amber-700"
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
<PauseIcon className="mr-1.5 size-3.5" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-800 text-blue-50 hover:bg-blue-700"
|
||||
onClick={handleBulkDuplicate}
|
||||
>
|
||||
<CopyIcon className="mr-1.5 size-3.5" />
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-red-900 text-red-50 hover:bg-red-800"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<TrashIcon className="mr-1.5 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteMutation.isPending) {
|
||||
setDeleteDialog({ open: false, schedule: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => setDeleteDialog({ open: false, schedule: null })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={handleDeleteConfirm}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Schedule Dialog */}
|
||||
<CreateOrgScheduleDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SchedulesPage };
|
||||
9
skyvern-frontend/src/routes/schedules/SchedulesRoute.tsx
Normal file
9
skyvern-frontend/src/routes/schedules/SchedulesRoute.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
import { SchedulesPage } from "@/routes/schedules/SchedulesPage";
|
||||
|
||||
export function SchedulesRoute() {
|
||||
const enabled = useFeatureFlag("WORKFLOW_SCHEDULES");
|
||||
if (enabled) return <SchedulesPage />;
|
||||
return <Navigate to="/workflows" replace />;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import type {
|
||||
CreateScheduleRequest,
|
||||
WorkflowScheduleResponse,
|
||||
} from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
type CreateOrgScheduleParams = {
|
||||
workflowPermanentId: string;
|
||||
request: CreateScheduleRequest;
|
||||
};
|
||||
|
||||
function useCreateOrgScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowPermanentId,
|
||||
request,
|
||||
}: CreateOrgScheduleParams) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.post<WorkflowScheduleResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules`,
|
||||
request,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
toast({ title: "Schedule created", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to create schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { useCreateOrgScheduleMutation };
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import type { OrganizationScheduleListResponse } from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
function useOrganizationSchedulesQuery(opts: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
statusFilter?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(opts.page));
|
||||
params.set("page_size", String(opts.pageSize));
|
||||
if (opts.statusFilter) {
|
||||
params.set("status", opts.statusFilter);
|
||||
}
|
||||
if (opts.search) {
|
||||
params.set("search", opts.search);
|
||||
}
|
||||
|
||||
return useQuery<OrganizationScheduleListResponse>({
|
||||
queryKey: [
|
||||
"organizationSchedules",
|
||||
opts.page,
|
||||
opts.pageSize,
|
||||
opts.statusFilter,
|
||||
opts.search,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.get<OrganizationScheduleListResponse>(
|
||||
`/schedules?${params.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export { useOrganizationSchedulesQuery };
|
||||
169
skyvern-frontend/src/routes/schedules/useScheduleActions.ts
Normal file
169
skyvern-frontend/src/routes/schedules/useScheduleActions.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import type {
|
||||
OrganizationScheduleItem,
|
||||
UpdateScheduleRequest,
|
||||
WorkflowScheduleResponse,
|
||||
} from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
function useEnableScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (schedule: OrganizationScheduleItem) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
await client.post(
|
||||
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}/enable`,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
|
||||
toast({ title: "Schedule activated", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to activate schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useDisableScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (schedule: OrganizationScheduleItem) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
await client.post(
|
||||
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}/disable`,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
|
||||
toast({ title: "Schedule paused", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to pause schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useDeleteOrgScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (schedule: OrganizationScheduleItem) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
await client.delete(
|
||||
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}`,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
|
||||
toast({ title: "Schedule deleted", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to delete schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useDuplicateScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (schedule: OrganizationScheduleItem) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
await client.post(
|
||||
`/workflows/${schedule.workflow_permanent_id}/schedules`,
|
||||
{
|
||||
cron_expression: schedule.cron_expression,
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
parameters: schedule.parameters,
|
||||
name: `${schedule.name ?? schedule.workflow_title} (copy)`,
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
toast({ title: "Schedule duplicated", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to duplicate schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateScheduleMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowPermanentId,
|
||||
scheduleId,
|
||||
request,
|
||||
}: {
|
||||
workflowPermanentId: string;
|
||||
scheduleId: string;
|
||||
request: UpdateScheduleRequest;
|
||||
}) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.put<WorkflowScheduleResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
|
||||
request,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
|
||||
toast({ title: "Schedule updated", variant: "success" });
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to update schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
useEnableScheduleMutation,
|
||||
useDisableScheduleMutation,
|
||||
useDeleteOrgScheduleMutation,
|
||||
useDuplicateScheduleMutation,
|
||||
useUpdateScheduleMutation,
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import type { WorkflowScheduleResponse } from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
function useScheduleDetailQuery(
|
||||
workflowPermanentId: string | undefined,
|
||||
scheduleId: string | undefined,
|
||||
) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
return useQuery<WorkflowScheduleResponse>({
|
||||
queryKey: ["scheduleDetail", workflowPermanentId, scheduleId],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.get<WorkflowScheduleResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!workflowPermanentId && !!scheduleId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export { useScheduleDetailQuery };
|
||||
|
|
@ -13,6 +13,13 @@ import {
|
|||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -66,7 +73,8 @@ function WorkflowPage() {
|
|||
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const PAGE_SIZE_OPTIONS = ["10", "25", "50"];
|
||||
const pageSize = Number(searchParams.get("page_size") || "10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
const [openRunParams, setOpenRunParams] = useState<string | null>(null);
|
||||
|
|
@ -82,6 +90,7 @@ function WorkflowPage() {
|
|||
workflowPermanentId,
|
||||
statusFilters,
|
||||
page,
|
||||
pageSize,
|
||||
search: debouncedSearch,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
|
@ -310,43 +319,68 @@ function WorkflowPage() {
|
|||
workflowPermanentId={workflowPermanentId}
|
||||
workflowRunId={openRunParams}
|
||||
/>
|
||||
<Pagination className="pt-2">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={cn({ "cursor-not-allowed": page === 1 })}
|
||||
onClick={() => {
|
||||
if (page === 1) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(Math.max(1, page - 1)));
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={cn({
|
||||
"cursor-not-allowed":
|
||||
workflowRuns !== undefined &&
|
||||
workflowRuns.length < PAGE_SIZE,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (workflowRuns && workflowRuns.length < PAGE_SIZE) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page + 1));
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-400">Items per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(size) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page_size", size);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[65px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={size}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={cn({ "cursor-not-allowed": page === 1 })}
|
||||
onClick={() => {
|
||||
if (page === 1) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", String(Math.max(1, page - 1)));
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={cn({
|
||||
"cursor-not-allowed":
|
||||
workflowRuns !== undefined &&
|
||||
workflowRuns.length < pageSize,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (workflowRuns && workflowRuns.length < pageSize) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", String(page + 1));
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
BookmarkIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
PlayIcon,
|
||||
|
|
@ -36,6 +37,7 @@ import { useDebugStore } from "@/store/useDebugStore";
|
|||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
import { cn } from "@/util/utils";
|
||||
import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes";
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ type Props = {
|
|||
isGeneratingCode?: boolean;
|
||||
isTemplate?: boolean;
|
||||
parametersPanelOpen: boolean;
|
||||
schedulesPanelOpen: boolean;
|
||||
saving: boolean;
|
||||
showAllCode: boolean;
|
||||
onCacheKeyValueAccept: (cacheKeyValue: string | null) => void;
|
||||
|
|
@ -57,6 +60,7 @@ type Props = {
|
|||
onCacheKeyValuesFilter: (cacheKeyValue: string) => void;
|
||||
onCacheKeyValuesKeydown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onParametersClick: () => void;
|
||||
onScheduleClick: () => void;
|
||||
onShowAllCodeClick?: () => void;
|
||||
onCacheKeyValuesClick: () => void;
|
||||
onSave: () => void;
|
||||
|
|
@ -71,6 +75,7 @@ function WorkflowHeader({
|
|||
isGeneratingCode,
|
||||
isTemplate,
|
||||
parametersPanelOpen,
|
||||
schedulesPanelOpen,
|
||||
saving,
|
||||
showAllCode,
|
||||
onCacheKeyValueAccept,
|
||||
|
|
@ -78,12 +83,13 @@ function WorkflowHeader({
|
|||
onCacheKeyValuesFilter,
|
||||
onCacheKeyValuesKeydown,
|
||||
onParametersClick,
|
||||
onScheduleClick,
|
||||
onShowAllCodeClick,
|
||||
onCacheKeyValuesClick,
|
||||
onSave,
|
||||
onRun,
|
||||
onHistory,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const { title, setTitle } = useWorkflowTitleStore();
|
||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||
const { workflowPermanentId } = useParams();
|
||||
|
|
@ -93,6 +99,7 @@ function WorkflowHeader({
|
|||
const { data: workflowRun } = useWorkflowRunQuery();
|
||||
const debugStore = useDebugStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const schedulesEnabled = useFeatureFlag("WORKFLOW_SCHEDULES");
|
||||
const workflowRunIsRunningOrQueued =
|
||||
workflowRun && statusIsRunningOrQueued(workflowRun);
|
||||
const [chosenCacheKeyValue, setChosenCacheKeyValue] = useState<string | null>(
|
||||
|
|
@ -393,6 +400,22 @@ function WorkflowHeader({
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{schedulesEnabled && (
|
||||
<Button
|
||||
disabled={isRecording}
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onScheduleClick}
|
||||
>
|
||||
<ClockIcon className="mr-2 h-5 w-5" />
|
||||
<span className="mr-2">Schedule</span>
|
||||
{schedulesPanelOpen ? (
|
||||
<ChevronUpIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={isRecording}
|
||||
variant="tertiary"
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ import {
|
|||
} from "./workflowEditorUtils";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
|
||||
import { WorkflowSchedulePanel } from "./panels/schedulePanel/WorkflowSchedulePanel";
|
||||
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { ProxyLocation } from "@/api/types";
|
||||
|
|
@ -1222,6 +1223,10 @@ function Workspace({
|
|||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
}
|
||||
schedulesPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "schedules"
|
||||
}
|
||||
showAllCode={showAllCode}
|
||||
onCacheKeyValueAccept={(v) => {
|
||||
setExplicitCacheKeyValue(v ?? "");
|
||||
|
|
@ -1260,6 +1265,19 @@ function Workspace({
|
|||
});
|
||||
}
|
||||
}}
|
||||
onScheduleClick={() => {
|
||||
if (
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "schedules"
|
||||
) {
|
||||
closeWorkflowPanel();
|
||||
} else {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "schedules",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSave={async () => await handleOnSave()}
|
||||
onRun={() => {
|
||||
closeWorkflowPanel();
|
||||
|
|
@ -1333,6 +1351,11 @@ function Workspace({
|
|||
<WorkflowParametersPanel />
|
||||
</div>
|
||||
)}
|
||||
{workflowPanelState.content === "schedules" && (
|
||||
<div className="z-30">
|
||||
<WorkflowSchedulePanel />
|
||||
</div>
|
||||
)}
|
||||
{workflowPanelState.content === "history" && (
|
||||
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
|
||||
<WorkflowHistoryPanel
|
||||
|
|
@ -1408,6 +1431,11 @@ function Workspace({
|
|||
<WorkflowParametersPanel />
|
||||
</div>
|
||||
)}
|
||||
{workflowPanelState.content === "schedules" && (
|
||||
<div className="z-30">
|
||||
<WorkflowSchedulePanel />
|
||||
</div>
|
||||
)}
|
||||
{workflowPanelState.content === "history" && (
|
||||
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
|
||||
<WorkflowHistoryPanel
|
||||
|
|
@ -1462,6 +1490,9 @@ function Workspace({
|
|||
{workflowPanelState.content === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
{workflowPanelState.content === "schedules" && (
|
||||
<WorkflowSchedulePanel />
|
||||
)}
|
||||
{workflowPanelState.content === "history" && (
|
||||
<div className="h-[calc(100vh-14rem)]">
|
||||
<WorkflowHistoryPanel
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
CRON_PRESETS,
|
||||
cronToHumanReadable,
|
||||
isValidCron,
|
||||
getNextRuns,
|
||||
formatNextRun,
|
||||
getTimezones,
|
||||
getLocalTimezone,
|
||||
} from "./cronUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (
|
||||
cronExpression: string,
|
||||
timezone: string,
|
||||
name: string,
|
||||
description: string,
|
||||
callbacks: { onSuccess: () => void },
|
||||
) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
function CreateScheduleDialog({ onSubmit, isPending }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cronExpression, setCronExpression] = useState("0 9 * * *");
|
||||
const [timezone, setTimezone] = useState(getLocalTimezone);
|
||||
// TODO - Create shared util
|
||||
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const allTimezones = useMemo(() => getTimezones(), []);
|
||||
const filteredTimezones = useMemo(() => {
|
||||
if (timezoneFilter === null) return allTimezones;
|
||||
if (!timezoneFilter) return allTimezones;
|
||||
const lower = timezoneFilter.toLowerCase();
|
||||
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
|
||||
}, [allTimezones, timezoneFilter]);
|
||||
// ! end TODO
|
||||
|
||||
const valid = isValidCron(cronExpression);
|
||||
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
|
||||
const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : [];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!valid) return;
|
||||
onSubmit(cronExpression, timezone, name, description, {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setCronExpression("0 9 * * *");
|
||||
setTimezone(getLocalTimezone());
|
||||
setTimezoneFilter(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5">
|
||||
<PlusIcon className="size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure when this workflow should run automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Schedule Name & Description */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input
|
||||
placeholder="Auto-generated if empty"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
placeholder="Add a description..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div className="space-y-2">
|
||||
<Label>Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRON_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant={
|
||||
cronExpression === preset.expression
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCronExpression(preset.expression)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Cron Input */}
|
||||
<div className="space-y-2">
|
||||
<Label>Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className={cn(!valid && cronExpression && "border-destructive")}
|
||||
/>
|
||||
{humanReadable && (
|
||||
<p className="text-sm text-slate-400">{humanReadable}</p>
|
||||
)}
|
||||
{!valid && cronExpression && (
|
||||
<p className="text-sm text-destructive">
|
||||
Invalid cron expression
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<Input
|
||||
value={timezoneFilter ?? timezone}
|
||||
onChange={(e) => setTimezoneFilter(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => {
|
||||
if (
|
||||
filteredTimezones.length === 1 &&
|
||||
filteredTimezones[0] !== undefined
|
||||
) {
|
||||
setTimezone(filteredTimezones[0]);
|
||||
}
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
placeholder="Search timezones..."
|
||||
/>
|
||||
{timezoneFilter !== null && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
|
||||
{filteredTimezones.slice(0, 20).map((tz) => (
|
||||
<button
|
||||
key={tz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-left text-sm hover:bg-slate-700",
|
||||
tz === timezone && "bg-slate-700 text-slate-50",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setTimezone(tz);
|
||||
setTimezoneFilter(null);
|
||||
}}
|
||||
>
|
||||
{tz}
|
||||
</button>
|
||||
))}
|
||||
{filteredTimezones.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-slate-500">
|
||||
No timezones found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">Current: {timezone}</p>
|
||||
</div>
|
||||
|
||||
{/* Next Runs Preview */}
|
||||
{nextRuns.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Next Scheduled Runs</Label>
|
||||
<div className="space-y-1 rounded-md border border-slate-700 bg-slate-elevation3 p-3">
|
||||
{nextRuns.map((run) => (
|
||||
<div
|
||||
key={run.toISOString()}
|
||||
className="text-xs text-slate-400"
|
||||
>
|
||||
{formatNextRun(run, timezone)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!valid || isPending} onClick={handleSubmit}>
|
||||
{isPending ? "Creating..." : "Create Schedule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { CreateScheduleDialog };
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WorkflowSchedule } from "@/routes/workflows/types/scheduleTypes";
|
||||
import { cronToHumanReadable, formatNextRun, getNextRuns } from "./cronUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
type Props = {
|
||||
schedule: WorkflowSchedule;
|
||||
isToggling?: boolean;
|
||||
onToggle: (scheduleId: string, enabled: boolean) => void;
|
||||
onDelete: (scheduleId: string) => void;
|
||||
};
|
||||
|
||||
function ScheduleCard({ schedule, isToggling, onToggle, onDelete }: Props) {
|
||||
const humanReadable = cronToHumanReadable(schedule.cron_expression);
|
||||
const nextRuns = getNextRuns(schedule.cron_expression, schedule.timezone, 1);
|
||||
const nextRun = nextRuns[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-slate-700 px-3.5 pb-0.5 pt-3.5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{schedule.name && (
|
||||
<span className="text-sm font-medium text-slate-50">
|
||||
{schedule.name}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
schedule.name ? "text-slate-400" : "text-slate-50",
|
||||
)}
|
||||
>
|
||||
{humanReadable}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">{schedule.timezone}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={schedule.enabled}
|
||||
disabled={isToggling}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggle(schedule.workflow_schedule_id, checked)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => onDelete(schedule.workflow_schedule_id)}
|
||||
>
|
||||
<TrashIcon className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{nextRun && (
|
||||
<div className="text-xs text-slate-500">
|
||||
Next: {formatNextRun(nextRun, schedule.timezone)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScheduleCard };
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useWorkflowSchedulesQuery } from "@/routes/workflows/hooks/useWorkflowSchedulesQuery";
|
||||
import {
|
||||
useCreateScheduleMutation,
|
||||
useToggleScheduleMutation,
|
||||
useDeleteScheduleMutation,
|
||||
} from "@/routes/workflows/hooks/useScheduleMutations";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { CreateScheduleDialog } from "./CreateScheduleDialog";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
function WorkflowSchedulePanel() {
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useWorkflowSchedulesQuery();
|
||||
const createSchedule = useCreateScheduleMutation();
|
||||
const toggleSchedule = useToggleScheduleMutation();
|
||||
const deleteSchedule = useDeleteScheduleMutation();
|
||||
const [deleteDialogState, setDeleteDialogState] = useState<{
|
||||
open: boolean;
|
||||
scheduleId: string | null;
|
||||
}>({ open: false, scheduleId: null });
|
||||
|
||||
const handleCreate = (
|
||||
cronExpression: string,
|
||||
timezone: string,
|
||||
name: string,
|
||||
description: string,
|
||||
callbacks: { onSuccess: () => void },
|
||||
) => {
|
||||
createSchedule.mutate(
|
||||
{
|
||||
cron_expression: cronExpression,
|
||||
timezone,
|
||||
enabled: true,
|
||||
...(name && { name }),
|
||||
...(description && { description }),
|
||||
},
|
||||
{ onSuccess: callbacks.onSuccess },
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggle = (scheduleId: string, enabled: boolean) => {
|
||||
toggleSchedule.mutate({ scheduleId, enabled });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteDialogState.scheduleId) {
|
||||
deleteSchedule.mutate(deleteDialogState.scheduleId, {
|
||||
onSettled: () => {
|
||||
setDeleteDialogState({ open: false, scheduleId: null });
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[22rem] flex-col rounded-lg border border-slate-700 bg-slate-elevation3">
|
||||
<div className="flex items-center justify-between border-b border-slate-700 px-4 py-4">
|
||||
<h3 className="text-sm font-normal text-slate-50">
|
||||
Schedules
|
||||
{schedules && schedules.length > 0 ? ` (${schedules.length})` : ""}
|
||||
</h3>
|
||||
<CreateScheduleDialog
|
||||
onSubmit={handleCreate}
|
||||
isPending={createSchedule.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<ScrollAreaViewport className="max-h-[calc(100vh-16rem)]">
|
||||
<div className="flex flex-col gap-3 px-4 py-2">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<ReloadIcon className="size-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="py-8 text-center text-sm text-red-400">
|
||||
Failed to load schedules.
|
||||
{error?.message && (
|
||||
<span className="block text-xs text-slate-500">
|
||||
{error.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
(!schedules || schedules.length === 0) && (
|
||||
<div className="py-8 text-center text-sm text-slate-500">
|
||||
No schedules configured.
|
||||
<br />
|
||||
Click "Add" to create one.
|
||||
</div>
|
||||
)}
|
||||
{schedules?.map((schedule) => (
|
||||
<ScheduleCard
|
||||
key={schedule.workflow_schedule_id}
|
||||
schedule={schedule}
|
||||
isToggling={toggleSchedule.isPending}
|
||||
onToggle={handleToggle}
|
||||
onDelete={(id) =>
|
||||
setDeleteDialogState({ open: true, scheduleId: id })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
</ScrollArea>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogState.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteSchedule.isPending) {
|
||||
setDeleteDialogState({ open: false, scheduleId: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={deleteSchedule.isPending}
|
||||
onClick={() =>
|
||||
setDeleteDialogState({ open: false, scheduleId: null })
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteSchedule.isPending}
|
||||
onClick={handleDeleteConfirm}
|
||||
>
|
||||
{deleteSchedule.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowSchedulePanel };
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import cronstrue from "cronstrue";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
|
||||
export const CRON_PRESETS = [
|
||||
{ label: "Hourly", expression: "0 * * * *" },
|
||||
{ label: "Daily", expression: "0 9 * * *" },
|
||||
{ label: "Weekdays", expression: "0 9 * * 1-5" },
|
||||
{ label: "Weekly", expression: "0 9 * * 1" },
|
||||
{ label: "Monthly", expression: "0 9 1 * *" },
|
||||
] as const;
|
||||
|
||||
export function cronToHumanReadable(expression: string): string {
|
||||
try {
|
||||
return cronstrue.toString(expression, {
|
||||
use24HourTimeFormat: false,
|
||||
verbose: false,
|
||||
});
|
||||
} catch {
|
||||
return "Invalid expression";
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidCron(expression: string): boolean {
|
||||
try {
|
||||
CronExpressionParser.parse(expression);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextRuns(
|
||||
expression: string,
|
||||
timezone: string,
|
||||
count: number = 5,
|
||||
): Date[] {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(expression, {
|
||||
tz: timezone,
|
||||
});
|
||||
|
||||
const runs: Date[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
runs.push(interval.next().toDate());
|
||||
}
|
||||
return runs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNextRun(date: Date, timezone: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
timeZone: timezone,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getTimezones(): string[] {
|
||||
try {
|
||||
// Intl.supportedValuesOf is available in modern browsers
|
||||
return (
|
||||
Intl as unknown as { supportedValuesOf: (key: string) => string[] }
|
||||
).supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
return [
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Kolkata",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
"UTC",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AxiosError } from "axios";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import type {
|
||||
CreateScheduleRequest,
|
||||
WorkflowScheduleResponse,
|
||||
} from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
function useCreateScheduleMutation() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (request: CreateScheduleRequest) => {
|
||||
if (!workflowPermanentId) {
|
||||
throw new Error("Missing workflowPermanentId");
|
||||
}
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.post<WorkflowScheduleResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules`,
|
||||
request,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflowSchedules", workflowPermanentId],
|
||||
});
|
||||
toast({
|
||||
title: "Schedule created",
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to create schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useToggleScheduleMutation() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
enabled,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
if (!workflowPermanentId) {
|
||||
throw new Error("Missing workflowPermanentId");
|
||||
}
|
||||
const client = await getClient(credentialGetter);
|
||||
const action = enabled ? "enable" : "disable";
|
||||
const response = await client.post<WorkflowScheduleResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules/${scheduleId}/${action}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflowSchedules", workflowPermanentId],
|
||||
});
|
||||
toast({
|
||||
title: "Schedule updated",
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to update schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useDeleteScheduleMutation() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (scheduleId: string) => {
|
||||
if (!workflowPermanentId) {
|
||||
throw new Error("Missing workflowPermanentId");
|
||||
}
|
||||
const client = await getClient(credentialGetter);
|
||||
await client.delete(
|
||||
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflowSchedules", workflowPermanentId],
|
||||
});
|
||||
toast({
|
||||
title: "Schedule deleted",
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Failed to delete schedule",
|
||||
description: detail || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
useCreateScheduleMutation,
|
||||
useToggleScheduleMutation,
|
||||
useDeleteScheduleMutation,
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ type Props = {
|
|||
workflowPermanentId?: string;
|
||||
statusFilters?: Array<Status>;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
} & UseQueryOptions;
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ function useWorkflowRunsQuery({
|
|||
workflowPermanentId,
|
||||
statusFilters,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
...queryOptions
|
||||
}: Props) {
|
||||
|
|
@ -33,6 +35,7 @@ function useWorkflowRunsQuery({
|
|||
{ statusFilters },
|
||||
workflowPermanentId,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
],
|
||||
queryFn: async () => {
|
||||
|
|
@ -42,6 +45,9 @@ function useWorkflowRunsQuery({
|
|||
(workflow) => workflow.workflow_permanent_id === workflowPermanentId,
|
||||
);
|
||||
params.append("page", String(page));
|
||||
if (pageSize) {
|
||||
params.append("page_size", String(pageSize));
|
||||
}
|
||||
if (isGlobalWorkflow) {
|
||||
params.append("template", "true");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import type {
|
||||
WorkflowSchedule,
|
||||
WorkflowScheduleListResponse,
|
||||
} from "@/routes/workflows/types/scheduleTypes";
|
||||
|
||||
function useWorkflowSchedulesQuery() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
return useQuery<Array<WorkflowSchedule>>({
|
||||
queryKey: ["workflowSchedules", workflowPermanentId],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.get<WorkflowScheduleListResponse>(
|
||||
`/workflows/${workflowPermanentId}/schedules`,
|
||||
);
|
||||
return response.data.schedules;
|
||||
},
|
||||
enabled: !!workflowPermanentId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export { useWorkflowSchedulesQuery };
|
||||
65
skyvern-frontend/src/routes/workflows/types/scheduleTypes.ts
Normal file
65
skyvern-frontend/src/routes/workflows/types/scheduleTypes.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
export type WorkflowSchedule = {
|
||||
workflow_schedule_id: string;
|
||||
organization_id: string;
|
||||
workflow_permanent_id: string;
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown> | null;
|
||||
temporal_schedule_id: string | null;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowScheduleResponse = {
|
||||
schedule: WorkflowSchedule;
|
||||
next_runs: Array<string>;
|
||||
};
|
||||
|
||||
export type WorkflowScheduleListResponse = {
|
||||
schedules: Array<WorkflowSchedule>;
|
||||
};
|
||||
|
||||
export type CreateScheduleRequest = {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
enabled?: boolean;
|
||||
parameters?: Record<string, unknown> | null;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type UpdateScheduleRequest = {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
parameters?: Record<string, unknown> | null;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type OrganizationScheduleItem = {
|
||||
workflow_schedule_id: string;
|
||||
organization_id: string;
|
||||
workflow_permanent_id: string;
|
||||
workflow_title: string;
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown> | null;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
next_run: string | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
};
|
||||
|
||||
export type OrganizationScheduleListResponse = {
|
||||
schedules: OrganizationScheduleItem[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
|
@ -16,7 +16,8 @@ type WorkflowPanelState = {
|
|||
| "parameters"
|
||||
| "nodeLibrary"
|
||||
| "history"
|
||||
| "comparison";
|
||||
| "comparison"
|
||||
| "schedules";
|
||||
data?: {
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
|
|
|
|||
|
|
@ -2915,23 +2915,92 @@ class AgentDB(BaseAlchemyDB):
|
|||
LOG.error("UnexpectedError", exc_info=True)
|
||||
raise
|
||||
|
||||
async def soft_delete_workflow_by_permanent_id(
|
||||
async def soft_delete_workflow_and_schedules_by_permanent_id(
|
||||
self,
|
||||
workflow_permanent_id: str,
|
||||
organization_id: str | None = None,
|
||||
) -> None:
|
||||
async with self.Session() as session:
|
||||
# soft delete the workflow by setting the deleted_at field
|
||||
update_deleted_at_query = (
|
||||
update(WorkflowModel)
|
||||
.where(WorkflowModel.workflow_permanent_id == workflow_permanent_id)
|
||||
.where(WorkflowModel.deleted_at.is_(None))
|
||||
)
|
||||
if organization_id:
|
||||
update_deleted_at_query = update_deleted_at_query.filter_by(organization_id=organization_id)
|
||||
update_deleted_at_query = update_deleted_at_query.values(deleted_at=datetime.utcnow())
|
||||
await session.execute(update_deleted_at_query)
|
||||
await session.commit()
|
||||
) -> list[str]:
|
||||
"""Soft-delete a workflow and its active schedules in a single DB transaction."""
|
||||
try:
|
||||
async with self.Session() as session:
|
||||
select_query = (
|
||||
select(WorkflowScheduleModel.workflow_schedule_id)
|
||||
.where(WorkflowScheduleModel.workflow_permanent_id == workflow_permanent_id)
|
||||
.where(WorkflowScheduleModel.deleted_at.is_(None))
|
||||
)
|
||||
if organization_id is not None:
|
||||
select_query = select_query.where(WorkflowScheduleModel.organization_id == organization_id)
|
||||
result = await session.execute(select_query)
|
||||
schedule_ids = list(result.scalars().all())
|
||||
|
||||
deleted_at = datetime.utcnow()
|
||||
if schedule_ids:
|
||||
update_schedules_query = (
|
||||
update(WorkflowScheduleModel)
|
||||
.where(WorkflowScheduleModel.workflow_schedule_id.in_(schedule_ids))
|
||||
.values(deleted_at=deleted_at)
|
||||
)
|
||||
await session.execute(update_schedules_query)
|
||||
|
||||
update_workflow_query = (
|
||||
update(WorkflowModel)
|
||||
.where(WorkflowModel.workflow_permanent_id == workflow_permanent_id)
|
||||
.where(WorkflowModel.deleted_at.is_(None))
|
||||
)
|
||||
if organization_id is not None:
|
||||
update_workflow_query = update_workflow_query.filter_by(organization_id=organization_id)
|
||||
await session.execute(update_workflow_query.values(deleted_at=deleted_at))
|
||||
await session.commit()
|
||||
return schedule_ids
|
||||
except SQLAlchemyError:
|
||||
LOG.error("SQLAlchemyError in soft_delete_workflow_and_schedules_by_permanent_id", exc_info=True)
|
||||
raise
|
||||
|
||||
async def soft_delete_orphaned_schedules(self, limit: int = 500) -> list[tuple[str, str]]:
|
||||
"""Soft-delete orphaned schedules and return their identities.
|
||||
|
||||
Uses a single UPDATE ... RETURNING statement so orphan detection and
|
||||
soft-deletion happen atomically in one DB round-trip.
|
||||
"""
|
||||
try:
|
||||
async with self.Session() as session:
|
||||
active_workflow_exists = (
|
||||
select(WorkflowModel.workflow_permanent_id)
|
||||
.where(WorkflowModel.workflow_permanent_id == WorkflowScheduleModel.workflow_permanent_id)
|
||||
.where(WorkflowModel.deleted_at.is_(None))
|
||||
.correlate(WorkflowScheduleModel)
|
||||
.exists()
|
||||
)
|
||||
orphaned_schedules = (
|
||||
select(
|
||||
WorkflowScheduleModel.workflow_schedule_id.label("workflow_schedule_id"),
|
||||
WorkflowScheduleModel.workflow_permanent_id.label("workflow_permanent_id"),
|
||||
)
|
||||
.where(WorkflowScheduleModel.deleted_at.is_(None))
|
||||
.where(~active_workflow_exists)
|
||||
.limit(limit)
|
||||
.cte("orphaned_schedules")
|
||||
)
|
||||
update_query = (
|
||||
update(WorkflowScheduleModel)
|
||||
.where(
|
||||
WorkflowScheduleModel.workflow_schedule_id.in_(
|
||||
select(orphaned_schedules.c.workflow_schedule_id)
|
||||
)
|
||||
)
|
||||
.where(WorkflowScheduleModel.deleted_at.is_(None))
|
||||
.values(deleted_at=datetime.utcnow())
|
||||
.returning(
|
||||
WorkflowScheduleModel.workflow_schedule_id,
|
||||
WorkflowScheduleModel.workflow_permanent_id,
|
||||
)
|
||||
)
|
||||
result = await session.execute(update_query)
|
||||
await session.commit()
|
||||
return [(row[0], row[1]) for row in result.all()]
|
||||
except SQLAlchemyError:
|
||||
LOG.error("SQLAlchemyError in soft_delete_orphaned_schedules", exc_info=True)
|
||||
raise
|
||||
|
||||
async def add_workflow_template(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -3178,16 +3178,30 @@ class WorkflowService:
|
|||
workflow_permanent_id: str,
|
||||
organization_id: str | None = None,
|
||||
) -> None:
|
||||
await app.DATABASE.soft_delete_workflow_by_permanent_id(
|
||||
# Delete workflow and schedules in one DB transaction so we do not leave
|
||||
# the workflow active if a process exits between separate commits.
|
||||
deleted_schedule_ids = await app.DATABASE.soft_delete_workflow_and_schedules_by_permanent_id(
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if deleted_schedule_ids:
|
||||
LOG.info(
|
||||
"Cascade-deleted schedules during workflow deletion",
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
deleted_schedule_ids=deleted_schedule_ids,
|
||||
count=len(deleted_schedule_ids),
|
||||
)
|
||||
|
||||
async def delete_workflow_by_id(
|
||||
self,
|
||||
workflow_id: str,
|
||||
organization_id: str,
|
||||
) -> None:
|
||||
# This path is rollback-only for a single workflow version created during
|
||||
# save/update flows. It must stay version-scoped and non-cascading because
|
||||
# schedules belong to the permanent workflow and should remain attached to
|
||||
# the previously valid version if the new version creation fails.
|
||||
await app.DATABASE.soft_delete_workflow_by_id(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=organization_id,
|
||||
|
|
|
|||
217
tests/unit/test_schedule_orphan_cleanup.py
Normal file
217
tests/unit/test_schedule_orphan_cleanup.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Tests for orphan prevention and schedule cascade deletion (SKY-8186)."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from skyvern.forge.sdk.db.agent_db import AgentDB
|
||||
from skyvern.forge.sdk.workflow.service import WorkflowService
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, values: list[object]) -> None:
|
||||
self._values = values
|
||||
|
||||
def scalars(self) -> "_FakeResult":
|
||||
return self
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._values
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, execute_side_effect: list[object]) -> None:
|
||||
self.execute = AsyncMock(side_effect=execute_side_effect)
|
||||
self.commit = AsyncMock()
|
||||
|
||||
async def __aenter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_service() -> WorkflowService:
|
||||
return WorkflowService()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_workflow_and_schedules_commits_once() -> None:
|
||||
db = object.__new__(AgentDB)
|
||||
fake_session = _FakeSession(
|
||||
execute_side_effect=[
|
||||
_FakeResult(["wfs_123", "wfs_456"]),
|
||||
None,
|
||||
None,
|
||||
]
|
||||
)
|
||||
db.Session = lambda: fake_session # type: ignore[method-assign]
|
||||
|
||||
deleted_schedule_ids = await db.soft_delete_workflow_and_schedules_by_permanent_id(
|
||||
workflow_permanent_id="wpid_abc",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
assert deleted_schedule_ids == ["wfs_123", "wfs_456"]
|
||||
assert fake_session.execute.await_count == 3
|
||||
fake_session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workflow_uses_atomic_schedule_delete_path(
|
||||
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
|
||||
) -> None:
|
||||
"""Deleting a workflow should use a single DB call for workflow + schedule deletion."""
|
||||
from skyvern.forge import app
|
||||
|
||||
mock_atomic_delete = AsyncMock(return_value=["wfs_123", "wfs_456"])
|
||||
monkeypatch.setattr(
|
||||
app.DATABASE,
|
||||
"soft_delete_workflow_and_schedules_by_permanent_id",
|
||||
mock_atomic_delete,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await workflow_service.delete_workflow_by_permanent_id(
|
||||
workflow_permanent_id="wpid_abc",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
mock_atomic_delete.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_abc",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workflow_no_schedules(monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService) -> None:
|
||||
"""Deleting a workflow with no schedules should still delete the workflow."""
|
||||
from skyvern.forge import app
|
||||
|
||||
mock_atomic_delete = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(
|
||||
app.DATABASE,
|
||||
"soft_delete_workflow_and_schedules_by_permanent_id",
|
||||
mock_atomic_delete,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await workflow_service.delete_workflow_by_permanent_id(
|
||||
workflow_permanent_id="wpid_no_schedules",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
mock_atomic_delete.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_no_schedules",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workflow_atomic_delete_receives_same_inputs(
|
||||
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
|
||||
) -> None:
|
||||
"""The service should pass the workflow identity through unchanged."""
|
||||
from skyvern.forge import app
|
||||
|
||||
mock_atomic_delete = AsyncMock(return_value=["wfs_1"])
|
||||
monkeypatch.setattr(
|
||||
app.DATABASE,
|
||||
"soft_delete_workflow_and_schedules_by_permanent_id",
|
||||
mock_atomic_delete,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await workflow_service.delete_workflow_by_permanent_id(
|
||||
workflow_permanent_id="wpid_order_test",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
mock_atomic_delete.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_order_test",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workflow_without_organization_id(
|
||||
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
|
||||
) -> None:
|
||||
"""Atomic deletion should work without organization_id."""
|
||||
from skyvern.forge import app
|
||||
|
||||
mock_atomic_delete = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(
|
||||
app.DATABASE,
|
||||
"soft_delete_workflow_and_schedules_by_permanent_id",
|
||||
mock_atomic_delete,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await workflow_service.delete_workflow_by_permanent_id(
|
||||
workflow_permanent_id="wpid_no_org",
|
||||
)
|
||||
|
||||
mock_atomic_delete.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_no_org",
|
||||
organization_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workflow_by_id_remains_version_scoped(
|
||||
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
|
||||
) -> None:
|
||||
"""delete_workflow_by_id is rollback-only and should not cascade schedules."""
|
||||
from skyvern.forge import app
|
||||
|
||||
mock_delete_by_id = AsyncMock()
|
||||
mock_atomic_delete = AsyncMock()
|
||||
monkeypatch.setattr(app.DATABASE, "soft_delete_workflow_by_id", mock_delete_by_id)
|
||||
monkeypatch.setattr(
|
||||
app.DATABASE,
|
||||
"soft_delete_workflow_and_schedules_by_permanent_id",
|
||||
mock_atomic_delete,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await workflow_service.delete_workflow_by_id(
|
||||
workflow_id="wf_rollback_only",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
|
||||
mock_delete_by_id.assert_awaited_once_with(
|
||||
workflow_id="wf_rollback_only",
|
||||
organization_id="org_xyz",
|
||||
)
|
||||
mock_atomic_delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_orphaned_schedules_uses_single_returning_update() -> None:
|
||||
db = object.__new__(AgentDB)
|
||||
fake_session = _FakeSession(
|
||||
execute_side_effect=[
|
||||
_FakeResult([("wfs_123", "wpid_abc")]),
|
||||
]
|
||||
)
|
||||
db.Session = lambda: fake_session # type: ignore[method-assign]
|
||||
|
||||
orphaned = await db.soft_delete_orphaned_schedules()
|
||||
|
||||
assert orphaned == [("wfs_123", "wpid_abc")]
|
||||
assert fake_session.execute.await_count == 1
|
||||
fake_session.commit.assert_awaited_once()
|
||||
query = fake_session.execute.await_args.args[0]
|
||||
compiled_query = str(
|
||||
query.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
assert "LIMIT 500" in compiled_query
|
||||
assert (
|
||||
"RETURNING workflow_schedules.workflow_schedule_id, workflow_schedules.workflow_permanent_id" in compiled_query
|
||||
)
|
||||
86
tests/unit/worker_activity_import_helpers.py
Normal file
86
tests/unit/worker_activity_import_helpers.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import importlib
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _install_temporal_activity_stubs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
temporalio_package = ModuleType("temporalio")
|
||||
temporalio_package.__path__ = []
|
||||
temporalio_activity = ModuleType("temporalio.activity")
|
||||
temporalio_activity.defn = lambda fn: fn
|
||||
temporalio_package.activity = temporalio_activity
|
||||
|
||||
monkeypatch.setitem(sys.modules, "temporalio", temporalio_package)
|
||||
monkeypatch.setitem(sys.modules, "temporalio.activity", temporalio_activity)
|
||||
|
||||
|
||||
def import_cron_worker_activities(monkeypatch: pytest.MonkeyPatch):
|
||||
cloud_package = ModuleType("cloud")
|
||||
cloud_package.__path__ = []
|
||||
cloud_clients = ModuleType("cloud.clients")
|
||||
cloud_yescaptcha = ModuleType("cloud.clients.yescaptcha")
|
||||
cloud_yescaptcha_client = ModuleType("cloud.clients.yescaptcha.client")
|
||||
cloud_config = ModuleType("cloud.config")
|
||||
cloud_db = ModuleType("cloud.db")
|
||||
cloud_agent_db = ModuleType("cloud.db.cloud_agent_db")
|
||||
cloud_tasks = ModuleType("cloud.tasks")
|
||||
|
||||
class YescaptchaClient:
|
||||
def __init__(self, client_key: str) -> None:
|
||||
self.client_key = client_key
|
||||
|
||||
async def update_stuck_tasks_to_timed_out() -> None:
|
||||
return None
|
||||
|
||||
async def update_stuck_workflow_runs_to_timed_out() -> None:
|
||||
return None
|
||||
|
||||
cloud_yescaptcha_client.YescaptchaClient = YescaptchaClient
|
||||
cloud_config.settings = SimpleNamespace(
|
||||
ENABLE_YESCAPTCHA_BALANCE_ALERT=False,
|
||||
YESCAPTCHA_API_KEY="",
|
||||
)
|
||||
cloud_agent_db.cloud_db = SimpleNamespace()
|
||||
cloud_tasks.update_stuck_tasks_to_timed_out = update_stuck_tasks_to_timed_out
|
||||
cloud_tasks.update_stuck_workflow_runs_to_timed_out = update_stuck_workflow_runs_to_timed_out
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cloud", cloud_package)
|
||||
monkeypatch.setitem(sys.modules, "cloud.clients", cloud_clients)
|
||||
monkeypatch.setitem(sys.modules, "cloud.clients.yescaptcha", cloud_yescaptcha)
|
||||
monkeypatch.setitem(sys.modules, "cloud.clients.yescaptcha.client", cloud_yescaptcha_client)
|
||||
monkeypatch.setitem(sys.modules, "cloud.config", cloud_config)
|
||||
monkeypatch.setitem(sys.modules, "cloud.db", cloud_db)
|
||||
monkeypatch.setitem(sys.modules, "cloud.db.cloud_agent_db", cloud_agent_db)
|
||||
monkeypatch.setitem(sys.modules, "cloud.tasks", cloud_tasks)
|
||||
_install_temporal_activity_stubs(monkeypatch)
|
||||
|
||||
sys.modules.pop("workers.cron_worker.activities", None)
|
||||
return importlib.import_module("workers.cron_worker.activities")
|
||||
|
||||
|
||||
def import_temporal_v2_worker_activities(monkeypatch: pytest.MonkeyPatch):
|
||||
cloud_package = ModuleType("cloud")
|
||||
cloud_package.__path__ = []
|
||||
cloud_services = ModuleType("cloud.services")
|
||||
data_scrubber_module = ModuleType("cloud.services.data_scrubber_service")
|
||||
worker_utils_module = ModuleType("workers.worker_utils")
|
||||
|
||||
class DataScrubber:
|
||||
pass
|
||||
|
||||
async def activity_teardown() -> None:
|
||||
return None
|
||||
|
||||
data_scrubber_module.DataScrubber = DataScrubber
|
||||
worker_utils_module.activity_teardown = activity_teardown
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cloud", cloud_package)
|
||||
monkeypatch.setitem(sys.modules, "cloud.services", cloud_services)
|
||||
monkeypatch.setitem(sys.modules, "cloud.services.data_scrubber_service", data_scrubber_module)
|
||||
monkeypatch.setitem(sys.modules, "workers.worker_utils", worker_utils_module)
|
||||
_install_temporal_activity_stubs(monkeypatch)
|
||||
|
||||
sys.modules.pop("workers.temporal_v2_worker.activities", None)
|
||||
return importlib.import_module("workers.temporal_v2_worker.activities")
|
||||
Loading…
Add table
Add a link
Reference in a new issue