mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 15:47:59 -05:00
feat(frontend): run agent page improvements (#10879)
## Changes 🏗️ - Add all the cron scheduling options ( _yearly, monthly, weekly, custom, etc..._ ) using the new Design System components - Add missing agent/run actions: export agent + delete agent ## Checklist 📋 ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run the app locally with `new-agent-runs` enabled - [x] Test the above ### For configuration changes: None
This commit is contained in:
@@ -112,6 +112,7 @@ function renderImage(
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
|
||||
@@ -358,6 +358,7 @@ function renderMarkdown(
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
@@ -410,8 +411,8 @@ function getCopyContentMarkdown(
|
||||
return {
|
||||
mimeType: "text/markdown",
|
||||
data: markdownText,
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
fallbackText: markdownText,
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -163,11 +163,11 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Schedule Section - always visible */}
|
||||
<div className="mt-8">
|
||||
<div className="mt-4">
|
||||
<AgentSectionHeader title="Schedule Setup" />
|
||||
{showScheduleView ? (
|
||||
<>
|
||||
<div className="mb-3 flex justify-start">
|
||||
<div className="my-4 flex justify-start">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -187,7 +187,7 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="mt-2 flex flex-col items-start gap-2">
|
||||
<Text variant="body" className="mb-3 !text-zinc-500">
|
||||
No schedule configured. Create a schedule to run this
|
||||
agent automatically at a specific time.{" "}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { makeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { FrequencySelect } from "./FrequencySelect";
|
||||
import { WeeklyPicker } from "./WeeklyPicker";
|
||||
import { MonthlyPicker } from "./MonthlyPicker";
|
||||
import { YearlyPicker } from "./YearlyPicker";
|
||||
import { CustomInterval } from "./CustomInterval";
|
||||
import { TimeAt } from "./TimeAt";
|
||||
|
||||
export type CronFrequency =
|
||||
| "hourly"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "custom"
|
||||
| "every minute";
|
||||
|
||||
type Props = {
|
||||
onCronExpressionChange: (cron: string) => void;
|
||||
initialCronExpression?: string;
|
||||
};
|
||||
|
||||
export function CronScheduler({
|
||||
onCronExpressionChange,
|
||||
initialCronExpression,
|
||||
}: Props) {
|
||||
const [frequency, setFrequency] = useState<CronFrequency>("daily");
|
||||
const [selectedMinute, setSelectedMinute] = useState<string>("0");
|
||||
const [selectedTime, setSelectedTime] = useState<string>("09:00");
|
||||
const [selectedWeekDays, setSelectedWeekDays] = useState<number[]>([]);
|
||||
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>([]);
|
||||
const [selectedMonths, setSelectedMonths] = useState<number[]>([]);
|
||||
const [customInterval, setCustomInterval] = useState<{
|
||||
value: number;
|
||||
unit: "minutes" | "hours" | "days";
|
||||
}>({ value: 1, unit: "minutes" });
|
||||
|
||||
// Parse provided cron only once to avoid feedback loops
|
||||
const parsedOnceRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedOnceRef.current) return;
|
||||
parsedOnceRef.current = true;
|
||||
|
||||
if (!initialCronExpression) {
|
||||
setFrequency("daily");
|
||||
setSelectedWeekDays([]);
|
||||
setSelectedMonthDays([]);
|
||||
setSelectedMonths([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = initialCronExpression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return;
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
setSelectedWeekDays([]);
|
||||
setSelectedMonthDays([]);
|
||||
setSelectedMonths([]);
|
||||
|
||||
if (
|
||||
minute === "*" &&
|
||||
hour === "*" &&
|
||||
dayOfMonth === "*" &&
|
||||
month === "*" &&
|
||||
dayOfWeek === "*"
|
||||
) {
|
||||
setFrequency("every minute");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hour === "*" &&
|
||||
dayOfMonth === "*" &&
|
||||
month === "*" &&
|
||||
dayOfWeek === "*" &&
|
||||
!minute.includes("/")
|
||||
) {
|
||||
setFrequency("hourly");
|
||||
setSelectedMinute(minute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
minute.startsWith("*/") &&
|
||||
hour === "*" &&
|
||||
dayOfMonth === "*" &&
|
||||
month === "*" &&
|
||||
dayOfWeek === "*"
|
||||
) {
|
||||
setFrequency("custom");
|
||||
const interval = parseInt(minute.substring(2));
|
||||
if (!isNaN(interval))
|
||||
setCustomInterval({ value: interval, unit: "minutes" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hour.startsWith("*/") &&
|
||||
minute === "0" &&
|
||||
dayOfMonth === "*" &&
|
||||
month === "*" &&
|
||||
dayOfWeek === "*"
|
||||
) {
|
||||
setFrequency("custom");
|
||||
const interval = parseInt(hour.substring(2));
|
||||
if (!isNaN(interval))
|
||||
setCustomInterval({ value: interval, unit: "hours" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dayOfMonth.startsWith("*/") &&
|
||||
month === "*" &&
|
||||
dayOfWeek === "*" &&
|
||||
!minute.includes("/") &&
|
||||
!hour.includes("/")
|
||||
) {
|
||||
setFrequency("custom");
|
||||
const interval = parseInt(dayOfMonth.substring(2));
|
||||
if (!isNaN(interval)) {
|
||||
setCustomInterval({ value: interval, unit: "days" });
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum))
|
||||
setSelectedTime(
|
||||
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
|
||||
setFrequency("daily");
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum))
|
||||
setSelectedTime(
|
||||
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayOfWeek !== "*" && dayOfMonth === "*" && month === "*") {
|
||||
setFrequency("weekly");
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum))
|
||||
setSelectedTime(
|
||||
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const days = dayOfWeek
|
||||
.split(",")
|
||||
.map((d) => parseInt(d))
|
||||
.filter((d) => !isNaN(d));
|
||||
setSelectedWeekDays(days);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") {
|
||||
setFrequency("monthly");
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum))
|
||||
setSelectedTime(
|
||||
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const days = dayOfMonth
|
||||
.split(",")
|
||||
.map((d) => parseInt(d))
|
||||
.filter((d) => !isNaN(d) && d >= 1 && d <= 31);
|
||||
setSelectedMonthDays(days);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayOfMonth !== "*" && month !== "*" && dayOfWeek === "*") {
|
||||
setFrequency("yearly");
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum))
|
||||
setSelectedTime(
|
||||
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const months = month
|
||||
.split(",")
|
||||
.map((m) => parseInt(m))
|
||||
.filter((m) => !isNaN(m) && m >= 1 && m <= 12);
|
||||
setSelectedMonths(months);
|
||||
}
|
||||
}, [initialCronExpression]);
|
||||
|
||||
const cronExpression = useMemo(
|
||||
() =>
|
||||
makeCronExpression({
|
||||
frequency,
|
||||
minute:
|
||||
frequency === "hourly"
|
||||
? parseInt(selectedMinute)
|
||||
: parseInt(selectedTime.split(":")[1]),
|
||||
hour: parseInt(selectedTime.split(":")[0]),
|
||||
days:
|
||||
frequency === "weekly"
|
||||
? selectedWeekDays
|
||||
: frequency === "monthly"
|
||||
? selectedMonthDays
|
||||
: [],
|
||||
months: frequency === "yearly" ? selectedMonths : [],
|
||||
customInterval:
|
||||
frequency === "custom"
|
||||
? customInterval
|
||||
: { unit: "minutes", value: 1 },
|
||||
}),
|
||||
[
|
||||
frequency,
|
||||
selectedMinute,
|
||||
selectedTime,
|
||||
selectedWeekDays,
|
||||
selectedMonthDays,
|
||||
selectedMonths,
|
||||
customInterval,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onCronExpressionChange(cronExpression);
|
||||
}, [cronExpression, onCronExpressionChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FrequencySelect
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
selectedMinute={selectedMinute}
|
||||
onMinuteChange={setSelectedMinute}
|
||||
/>
|
||||
|
||||
{frequency === "custom" && (
|
||||
<CustomInterval value={customInterval} onChange={setCustomInterval} />
|
||||
)}
|
||||
|
||||
{frequency === "weekly" && (
|
||||
<WeeklyPicker
|
||||
values={selectedWeekDays}
|
||||
onChange={setSelectedWeekDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === "monthly" && (
|
||||
<MonthlyPicker
|
||||
values={selectedMonthDays}
|
||||
onChange={setSelectedMonthDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === "yearly" && (
|
||||
<YearlyPicker values={selectedMonths} onChange={setSelectedMonths} />
|
||||
)}
|
||||
|
||||
{frequency !== "hourly" && (
|
||||
<TimeAt value={selectedTime} onChange={setSelectedTime} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
|
||||
export function CustomInterval({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: { value: number; unit: "minutes" | "hours" | "days" };
|
||||
onChange: (v: { value: number; unit: "minutes" | "hours" | "days" }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-end gap-3">
|
||||
<Input
|
||||
id="custom-interval-value"
|
||||
label="Every"
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, value: parseInt(e.target.value || "1") })
|
||||
}
|
||||
className="max-w-24"
|
||||
size="small"
|
||||
/>
|
||||
<Select
|
||||
id="custom-interval-unit"
|
||||
label="Interval"
|
||||
size="small"
|
||||
value={value.unit}
|
||||
onValueChange={(v) => onChange({ ...value, unit: v as any })}
|
||||
options={[
|
||||
{ label: "Minutes", value: "minutes" },
|
||||
{ label: "Hours", value: "hours" },
|
||||
{ label: "Days", value: "days" },
|
||||
]}
|
||||
className="max-w-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
|
||||
type CronFrequency =
|
||||
| "hourly"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "custom"
|
||||
| "every minute";
|
||||
|
||||
export function FrequencySelect({
|
||||
value,
|
||||
onChange,
|
||||
selectedMinute,
|
||||
onMinuteChange,
|
||||
}: {
|
||||
value: CronFrequency;
|
||||
onChange: (v: CronFrequency) => void;
|
||||
selectedMinute: string;
|
||||
onMinuteChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id="repeat"
|
||||
label="Repeats"
|
||||
size="small"
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as CronFrequency)}
|
||||
options={[
|
||||
{ label: "Every Hour", value: "hourly" },
|
||||
{ label: "Daily", value: "daily" },
|
||||
{ label: "Weekly", value: "weekly" },
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
{ label: "Yearly", value: "yearly" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
]}
|
||||
className="max-w-80"
|
||||
/>
|
||||
{value === "hourly" && (
|
||||
<Select
|
||||
id="at-minute"
|
||||
label="At minute"
|
||||
size="small"
|
||||
value={selectedMinute}
|
||||
onValueChange={(v) => onMinuteChange(v)}
|
||||
options={["0", "15", "30", "45"].map((m) => ({ label: m, value: m }))}
|
||||
className="max-w-32"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
|
||||
export function MonthlyPicker({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
function allDays() {
|
||||
onChange(Array.from({ length: 31 }, (_, i) => i + 1));
|
||||
}
|
||||
function customize() {
|
||||
onChange([]);
|
||||
}
|
||||
const items = Array.from({ length: 31 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}));
|
||||
const selected = values.map((v) => String(v));
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-2">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
Days of Month
|
||||
</Text>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100`}
|
||||
onClick={allDays}
|
||||
>
|
||||
All Days
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100`}
|
||||
onClick={customize}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={() => onChange([15])}
|
||||
>
|
||||
15th
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={() => onChange([31])}
|
||||
>
|
||||
Last Day
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{values.length < 31 && (
|
||||
<MultiToggle
|
||||
items={items}
|
||||
selectedValues={selected}
|
||||
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
|
||||
aria-label="Select days of month"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
|
||||
export function TimeAt({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [hour12, setHour12] = useState<string>("9");
|
||||
const [minute, setMinute] = useState<string>("00");
|
||||
const [ampm, setAmPm] = useState<"AM" | "PM">("AM");
|
||||
|
||||
// Parse incoming 24h value → local 12h selects
|
||||
useEffect(() => {
|
||||
const [hStr, mStr] = (value || "09:00").split(":");
|
||||
const h24 = Math.max(0, Math.min(23, parseInt(hStr || "9", 10) || 9));
|
||||
const m = Math.max(0, Math.min(59, parseInt(mStr || "0", 10) || 0));
|
||||
const isPm = h24 >= 12;
|
||||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||
setHour12(String(h12));
|
||||
setMinute(m.toString().padStart(2, "0"));
|
||||
setAmPm(isPm ? "PM" : "AM");
|
||||
}, [value]);
|
||||
|
||||
const hourOptions = useMemo(
|
||||
() => Array.from({ length: 12 }, (_, i) => String(i + 1)),
|
||||
[],
|
||||
);
|
||||
const minuteOptions = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, "0")),
|
||||
[],
|
||||
);
|
||||
|
||||
function emit(h12Str: string, mStr: string, meridiem: "AM" | "PM") {
|
||||
const h12Num = Math.max(
|
||||
1,
|
||||
Math.min(12, parseInt(h12Str || "12", 10) || 12),
|
||||
);
|
||||
const mNum = Math.max(0, Math.min(59, parseInt(mStr || "0", 10) || 0));
|
||||
let h24 = h12Num % 12;
|
||||
if (meridiem === "PM") h24 += 12;
|
||||
const next = `${h24.toString().padStart(2, "0")}:${mNum
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="relative">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-700">
|
||||
At
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="time-hour"
|
||||
label=""
|
||||
size="small"
|
||||
value={hour12}
|
||||
onValueChange={(v) => {
|
||||
setHour12(v);
|
||||
emit(v, minute, ampm);
|
||||
}}
|
||||
options={hourOptions.map((h) => ({ label: h, value: h }))}
|
||||
className="max-w-20"
|
||||
/>
|
||||
<Select
|
||||
id="time-minute"
|
||||
label=""
|
||||
size="small"
|
||||
value={minute}
|
||||
onValueChange={(v) => {
|
||||
setMinute(v);
|
||||
emit(hour12, v, ampm);
|
||||
}}
|
||||
options={minuteOptions.map((m) => ({ label: m, value: m }))}
|
||||
className="max-w-24"
|
||||
/>
|
||||
<Select
|
||||
id="time-meridiem"
|
||||
label=""
|
||||
size="small"
|
||||
value={ampm}
|
||||
onValueChange={(v) => {
|
||||
const mer = (v as "AM" | "PM") || "AM";
|
||||
setAmPm(mer);
|
||||
emit(hour12, minute, mer);
|
||||
}}
|
||||
options={[
|
||||
{ label: "AM", value: "AM" },
|
||||
{ label: "PM", value: "PM" },
|
||||
]}
|
||||
className="max-w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
|
||||
const weekDays = [
|
||||
{ label: "Su", value: 0 },
|
||||
{ label: "Mo", value: 1 },
|
||||
{ label: "Tu", value: 2 },
|
||||
{ label: "We", value: 3 },
|
||||
{ label: "Th", value: 4 },
|
||||
{ label: "Fr", value: 5 },
|
||||
{ label: "Sa", value: 6 },
|
||||
];
|
||||
|
||||
export function WeeklyPicker({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
function toggleAll() {
|
||||
if (values.length === weekDays.length) onChange([]);
|
||||
else onChange(weekDays.map((d) => d.value));
|
||||
}
|
||||
function setWeekdays() {
|
||||
onChange([1, 2, 3, 4, 5]);
|
||||
}
|
||||
function setWeekends() {
|
||||
onChange([0, 6]);
|
||||
}
|
||||
const items = weekDays.map((d) => ({
|
||||
value: String(d.value),
|
||||
label: d.label,
|
||||
}));
|
||||
const selectedValues = values.map((v) => String(v));
|
||||
|
||||
return (
|
||||
<div className="mb-8 space-y-3">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
Repeats on
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={toggleAll}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={setWeekdays}
|
||||
>
|
||||
Weekdays
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={setWeekends}
|
||||
>
|
||||
Weekends
|
||||
</button>
|
||||
</div>
|
||||
<MultiToggle
|
||||
items={items}
|
||||
selectedValues={selectedValues}
|
||||
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
|
||||
aria-label="Select days of week"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
|
||||
const months = [
|
||||
{ label: "Jan", value: 1 },
|
||||
{ label: "Feb", value: 2 },
|
||||
{ label: "Mar", value: 3 },
|
||||
{ label: "Apr", value: 4 },
|
||||
{ label: "May", value: 5 },
|
||||
{ label: "Jun", value: 6 },
|
||||
{ label: "Jul", value: 7 },
|
||||
{ label: "Aug", value: 8 },
|
||||
{ label: "Sep", value: 9 },
|
||||
{ label: "Oct", value: 10 },
|
||||
{ label: "Nov", value: 11 },
|
||||
{ label: "Dec", value: 12 },
|
||||
];
|
||||
|
||||
export function YearlyPicker({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
function toggleAll() {
|
||||
if (values.length === months.length) onChange([]);
|
||||
else onChange(months.map((m) => m.value));
|
||||
}
|
||||
const items = months.map((m) => ({ value: String(m.value), label: m.label }));
|
||||
const selected = values.map((v) => String(v));
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-2">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
Months
|
||||
</Text>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={toggleAll}
|
||||
>
|
||||
{values.length === months.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
</div>
|
||||
<MultiToggle
|
||||
items={items}
|
||||
selectedValues={selected}
|
||||
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
|
||||
aria-label="Select months"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function DefaultRunView() {
|
||||
} = useRunAgentModalContext();
|
||||
|
||||
return (
|
||||
<div className="mb-12 mt-6">
|
||||
<div className="my-4">
|
||||
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
|
||||
|
||||
{/* Preset/Trigger fields */}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { useScheduleView } from "./useScheduleView";
|
||||
import { useCallback, useState } from "react";
|
||||
import { validateSchedule } from "./helpers";
|
||||
import { TimezoneNotice } from "../TimezoneNotice/TimezoneNotice";
|
||||
import { CronScheduler } from "../CronScheduler/CronScheduler";
|
||||
|
||||
interface Props {
|
||||
scheduleName: string;
|
||||
@@ -24,41 +22,22 @@ export function ScheduleView({
|
||||
onCronExpressionChange,
|
||||
onValidityChange,
|
||||
}: Props) {
|
||||
const {
|
||||
repeat,
|
||||
selectedDays,
|
||||
time,
|
||||
repeatOptions,
|
||||
dayItems,
|
||||
setSelectedDays,
|
||||
handleRepeatChange,
|
||||
handleTimeChange,
|
||||
handleSelectAll,
|
||||
handleWeekdays,
|
||||
handleWeekends,
|
||||
} = useScheduleView({ onCronExpressionChange });
|
||||
|
||||
function handleScheduleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onScheduleNameChange(e.target.value);
|
||||
}
|
||||
|
||||
const [errors, setErrors] = useState<{
|
||||
scheduleName?: string;
|
||||
time?: string;
|
||||
}>({});
|
||||
|
||||
const validateNow = useCallback(
|
||||
(partial: { scheduleName?: string; time?: string }) => {
|
||||
const fieldErrors = validateSchedule({
|
||||
scheduleName,
|
||||
time,
|
||||
...partial,
|
||||
});
|
||||
(partial: { scheduleName?: string }) => {
|
||||
const fieldErrors = validateSchedule({ scheduleName, ...partial });
|
||||
setErrors(fieldErrors);
|
||||
if (onValidityChange)
|
||||
onValidityChange(Object.keys(fieldErrors).length === 0);
|
||||
},
|
||||
[scheduleName, time, onValidityChange],
|
||||
[scheduleName, onValidityChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -67,12 +46,14 @@ export function ScheduleView({
|
||||
id="schedule-name"
|
||||
label="Schedule Name"
|
||||
value={scheduleName}
|
||||
size="small"
|
||||
onChange={(e) => {
|
||||
handleScheduleNameChange(e);
|
||||
validateNow({ scheduleName: e.target.value });
|
||||
}}
|
||||
placeholder="Enter a name for this schedule"
|
||||
error={errors.scheduleName}
|
||||
className="max-w-80"
|
||||
/>
|
||||
|
||||
{recommendedScheduleCron && (
|
||||
@@ -84,65 +65,15 @@ export function ScheduleView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
id="repeat"
|
||||
label="Repeats"
|
||||
value={repeat}
|
||||
onValueChange={handleRepeatChange}
|
||||
options={repeatOptions}
|
||||
/>
|
||||
|
||||
{repeat === "weekly" && (
|
||||
<div className="mb-8 space-y-3">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
Repeats on
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleWeekdays}
|
||||
>
|
||||
Weekdays
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleWeekends}
|
||||
>
|
||||
Weekends
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MultiToggle
|
||||
items={dayItems}
|
||||
selectedValues={selectedDays}
|
||||
onChange={setSelectedDays}
|
||||
aria-label="Select days of week"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id="schedule-time"
|
||||
label="At"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
handleTimeChange({ ...e, target: { ...e.target, value } } as any);
|
||||
validateNow({ time: value });
|
||||
}}
|
||||
placeholder="00:00"
|
||||
error={errors.time}
|
||||
/>
|
||||
<div className="-mt-4">
|
||||
<div className="mt-1">
|
||||
<CronScheduler
|
||||
onCronExpressionChange={onCronExpressionChange}
|
||||
initialCronExpression={
|
||||
_cronExpression || recommendedScheduleCron || undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 w-fit">
|
||||
<TimezoneNotice />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,16 @@ import { RunStatusBadge } from "../RunDetails/components/RunStatusBadge";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
StopIcon,
|
||||
PlayIcon,
|
||||
ArrowSquareOut,
|
||||
ArrowSquareOutIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import moment from "moment";
|
||||
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
|
||||
import { useRunDetailHeader } from "./useRunDetailHeader";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import Link from "next/link";
|
||||
import { AgentActions } from "./components/AgentActions";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
@@ -77,40 +70,28 @@ export function RunDetailHeader({
|
||||
>
|
||||
<TrashIcon size={16} /> Delete run
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canStop ? (
|
||||
<DropdownMenuItem onClick={stopRun} disabled={isStopping}>
|
||||
<StopIcon size={14} className="mr-2" /> Stop run
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{openInBuilderHref ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowSquareOut size={14} /> Open in builder
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PencilSimpleIcon size={16} /> Edit agent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openInBuilderHref ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
as="NextLink"
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
>
|
||||
<ArrowSquareOutIcon size={16} /> Open in builder
|
||||
</Button>
|
||||
) : null}
|
||||
{canStop ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="small"
|
||||
onClick={stopRun}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<StopIcon size={14} /> Stop run
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentActions agent={agent} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FileArrowDownIcon,
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDeleteV2DeleteLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
interface AgentActionsProps {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function AgentActions({ agent }: AgentActionsProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const deleteMutation = useDeleteV2DeleteLibraryAgent();
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
const res = await getV1GetGraphVersion(
|
||||
agent.graph_id,
|
||||
agent.graph_version,
|
||||
{ for_export: true },
|
||||
);
|
||||
if (res.status === 200) {
|
||||
const filename = `${agent.name}_v${agent.graph_version}.json`;
|
||||
exportAsJSONFile(res.data as any, filename);
|
||||
toast({ title: "Agent exported" });
|
||||
} else {
|
||||
toast({ title: "Failed to export agent", variant: "destructive" });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "Failed to export agent",
|
||||
description: e?.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [agent.graph_id, agent.graph_version, agent.name, toast]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!agent?.id) return;
|
||||
const confirmed = window.confirm(
|
||||
"Are you sure you want to delete this agent? This action cannot be undone.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
deleteMutation.mutate(
|
||||
{ libraryAgentId: agent.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Agent deleted" });
|
||||
router.push("/library");
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
title: "Failed to delete agent",
|
||||
description: error?.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}, [agent.id, deleteMutation, router, toast]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PencilSimpleIcon size={16} /> Edit agent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileArrowDownIcon size={16} /> Export agent to file
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<TrashIcon size={16} /> Delete agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -83,8 +83,8 @@ export function RunOutputs({ outputs }: RunOutputsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="relative flex flex-col gap-4">
|
||||
<div className="absolute -top-3 right-0">
|
||||
<OutputActions
|
||||
items={items.map((item) => ({
|
||||
value: item.value,
|
||||
|
||||
@@ -17,7 +17,7 @@ type Args = {
|
||||
|
||||
export function useRunsSidebar({ graphId, onSelectRun }: Args) {
|
||||
const params = useSearchParams();
|
||||
const existingRunId = params.get("run") as string | undefined;
|
||||
const existingRunId = params.get("executionId") as string | undefined;
|
||||
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
|
||||
|
||||
const runsQuery = useGetV1ListGraphExecutionsInfinite(
|
||||
@@ -97,6 +97,12 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
|
||||
const schedules: GraphExecutionJobInfo[] =
|
||||
schedulesQuery.data?.status === 200 ? schedulesQuery.data.data : [];
|
||||
|
||||
// If there are no runs but there are schedules, and nothing is selected, auto-select the first schedule
|
||||
useEffect(() => {
|
||||
if (!existingRunId && runs.length === 0 && schedules.length > 0)
|
||||
onSelectRun(`schedule:${schedules[0].id}`);
|
||||
}, [existingRunId, runs.length, schedules, onSelectRun]);
|
||||
|
||||
return {
|
||||
runs,
|
||||
schedules,
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ActivityDropdown({
|
||||
<Text variant="body-medium" className="!text-black">
|
||||
{searchQuery
|
||||
? "No matching agents found"
|
||||
: "Nothing to show yet"}
|
||||
: "No recent runs to show yet"}
|
||||
</Text>
|
||||
<Text variant="body" className="!text-zinc-500">
|
||||
{searchQuery
|
||||
|
||||
Reference in New Issue
Block a user