diff --git a/frontend/package.json b/frontend/package.json index 071bc2662cd..b063414891b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@mintplex-labs/piper-tts-web": "^1.0.4", "@phosphor-icons/react": "^2.1.7", "@tremor/react": "^3.15.1", + "cronstrue": "^2.50.0", "dompurify": "^3.0.8", "file-saver": "^2.0.5", "he": "^1.2.0", diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx index 6220ecffe4e..9cd9110526f 100644 --- a/frontend/src/components/PrivateRoute/index.jsx +++ b/frontend/src/components/PrivateRoute/index.jsx @@ -126,6 +126,25 @@ export function ManagerRoute({ Component }) { ); } +// Allows access only in single user mode — redirects to home in multi-user mode +export function SingleUserRoute({ Component }) { + const { isAuthd, shouldRedirectToOnboarding, multiUserMode } = + useIsAuthenticated(); + if (isAuthd === null) return ; + + if (shouldRedirectToOnboarding) { + return ; + } + + return isAuthd && !multiUserMode ? ( + + + + ) : ( + + ); +} + export default function PrivateRoute({ Component }) { const { isAuthd, shouldRedirectToOnboarding } = useIsAuthenticated(); if (isAuthd === null) return ; diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx index e61de158d8d..13f3e745d2f 100644 --- a/frontend/src/components/SettingsSidebar/index.jsx +++ b/frontend/src/components/SettingsSidebar/index.jsx @@ -395,6 +395,12 @@ const SidebarOptions = ({ user = null, t }) => ( flex: true, roles: ["admin"], }, + { + btnText: "Scheduled Jobs", + href: paths.settings.scheduledJobs(), + flex: true, + hidden: !!user, + }, { btnText: t("settings.api-keys"), href: paths.settings.apiKeys(), diff --git a/frontend/src/hooks/usePolling.js b/frontend/src/hooks/usePolling.js new file mode 100644 index 00000000000..76f5928141f --- /dev/null +++ b/frontend/src/hooks/usePolling.js @@ -0,0 +1,52 @@ +import { useEffect, useRef } from "react"; + +/** + * Polls a callback on an interval, but only while the tab is visible. + * Automatically pauses when the user switches away and resumes on return. + * + * @param {() => void | Promise} callback - The function to invoke on each tick + * @param {number} intervalMs - Polling interval in milliseconds + * @param {boolean} [enabled=true] - When false, polling is suspended + */ +export default function usePolling(callback, intervalMs, enabled = true) { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + if (!enabled || !intervalMs) return; + + let timerId = null; + + const start = () => { + if (timerId) return; + timerId = setInterval(() => savedCallback.current(), intervalMs); + }; + + const stop = () => { + if (!timerId) return; + clearInterval(timerId); + timerId = null; + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + // Fire immediately on return so the UI feels fresh, then resume interval + savedCallback.current(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + stop(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [intervalMs, enabled]); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 22b375b46c0..d777862708f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,6 +5,7 @@ import App from "@/App.jsx"; import PrivateRoute, { AdminRoute, ManagerRoute, + SingleUserRoute, } from "@/components/PrivateRoute"; import Login from "@/pages/Login"; import SimpleSSOPassthrough from "@/pages/Login/SSO/simple"; @@ -381,6 +382,35 @@ const router = createBrowserRouter([ return { element: }; }, }, + { + path: "/settings/scheduled-jobs", + lazy: async () => { + const { default: ScheduledJobs } = await import( + "@/pages/GeneralSettings/ScheduledJobs" + ); + return { element: }; + }, + }, + { + path: "/settings/scheduled-jobs/:id/runs", + lazy: async () => { + const { default: ScheduledJobRuns } = await import( + "@/pages/GeneralSettings/ScheduledJobs/RunHistoryPage" + ); + return { element: }; + }, + }, + { + path: "/settings/scheduled-jobs/:id/runs/:runId", + lazy: async () => { + const { default: ScheduledJobRunDetail } = await import( + "@/pages/GeneralSettings/ScheduledJobs/RunDetailPage" + ); + return { + element: , + }; + }, + }, // Catch-all route for 404s { path: "*", diff --git a/frontend/src/models/scheduledJobs.js b/frontend/src/models/scheduledJobs.js new file mode 100644 index 00000000000..a37762307ef --- /dev/null +++ b/frontend/src/models/scheduledJobs.js @@ -0,0 +1,147 @@ +import { API_BASE } from "@/utils/constants"; +import { baseHeaders } from "@/utils/request"; + +const ScheduledJobs = { + list: async function () { + return await fetch(`${API_BASE}/scheduled-jobs`, { + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { jobs: [] }; + }); + }, + + create: async function (data) { + return await fetch(`${API_BASE}/scheduled-jobs/new`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { job: null, error: e.message }; + }); + }, + + get: async function (id) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}`, { + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { job: null }; + }); + }, + + update: async function (id, data) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}`, { + method: "PUT", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { job: null, error: e.message }; + }); + }, + + delete: async function (id) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false }; + }); + }, + + toggle: async function (id) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}/toggle`, { + method: "POST", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { job: null }; + }); + }, + + trigger: async function (id) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}/trigger`, { + method: "POST", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + + runs: async function (id) { + return await fetch(`${API_BASE}/scheduled-jobs/${id}/runs`, { + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { runs: [] }; + }); + }, + + getRun: async function (runId) { + return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}`, { + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { run: null, job: null }; + }); + }, + + markRunRead: async function (runId) { + return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}/read`, { + method: "POST", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false }; + }); + }, + + continueInThread: async function (runId) { + return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}/continue`, { + method: "POST", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { workspaceSlug: null, threadSlug: null, error: e.message }; + }); + }, + + availableTools: async function () { + return await fetch(`${API_BASE}/scheduled-jobs/available-tools`, { + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { tools: [] }; + }); + }, +}; + +export default ScheduledJobs; diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/CronBuilder.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/CronBuilder.jsx new file mode 100644 index 00000000000..6aaacce11b9 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/CronBuilder.jsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { + parseCronToBuilderState, + buildCronFromBuilderState, +} from "./utils/cron"; + +const WEEKDAYS = [ + { value: 0, label: "Sun" }, + { value: 1, label: "Mon" }, + { value: 2, label: "Tue" }, + { value: 3, label: "Wed" }, + { value: 4, label: "Thu" }, + { value: 5, label: "Fri" }, + { value: 6, label: "Sat" }, +]; + +const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30]; +const MINUTES = Array.from({ length: 60 }, (_, i) => i); +const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1); + +const pad2 = (n) => String(n).padStart(2, "0"); + +const inputClass = + "border-none bg-theme-settings-input-bg text-theme-text-primary text-sm rounded-lg focus:outline-primary-button outline-none p-2.5"; + +const labelClass = "text-sm text-theme-text-secondary"; + +// Visual cron builder. Maintains its own state derived from the incoming +// `value` on mount, and emits a fresh 5-field cron string via `onChange` +// whenever the user changes any sub-field. +export default function CronBuilder({ value, onChange }) { + const [state, setState] = useState( + () => parseCronToBuilderState(value).state + ); + const [wasFallback, setWasFallback] = useState( + () => parseCronToBuilderState(value).wasFallback + ); + + const update = (patch) => { + const next = { ...state, ...patch }; + setState(next); + if (wasFallback) setWasFallback(false); + const cron = buildCronFromBuilderState(next); + if (cron !== value) onChange(cron); + }; + + return ( +
+ {wasFallback && ( +

+ This expression can't be edited visually. Switch to Custom to + keep it, or change anything below to overwrite it. +

+ )} + +
+ Run + +
+ + {state.frequency === "minute" && ( +
+ Every + +
+ )} + + {state.frequency === "hour" && ( +
+ At minute + + past every hour +
+ )} + + {(state.frequency === "day" || + state.frequency === "week" || + state.frequency === "month") && ( +
+ At + { + const [h, m] = e.target.value.split(":"); + update({ + hour: parseInt(h, 10) || 0, + minute: parseInt(m, 10) || 0, + }); + }} + className={inputClass} + /> +
+ )} + + {state.frequency === "week" && ( +
+ On +
+ {WEEKDAYS.map((day) => { + const selected = state.weekdays.includes(day.value); + return ( + + ); + })} +
+
+ )} + + {state.frequency === "month" && ( +
+ On day + + of every month +
+ )} +
+ ); +} diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx new file mode 100644 index 00000000000..042a345ea35 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from "react"; +import { X } from "@phosphor-icons/react"; +import ScheduledJobs from "@/models/scheduledJobs"; +import showToast from "@/utils/toast"; +import { safeJsonParse } from "@/utils/request"; +import { humanizeCron } from "./utils/cron"; +import CronBuilder from "./CronBuilder"; + +export default function NewJobModal({ job = null, onClose, onSaved }) { + const isEditing = !!job; + const [form, setForm] = useState({ + name: job?.name || "", + prompt: job?.prompt || "", + schedule: job?.schedule || "0 9 * * *", + scheduleMode: "builder", + selectedTools: job?.tools ? safeJsonParse(job.tools, []) : [], + }); + const [availableTools, setAvailableTools] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + ScheduledJobs.availableTools().then(({ tools }) => { + setAvailableTools(tools || []); + }); + }, []); + + const handleChange = (e) => { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value })); + }; + + const handleModeChange = (mode) => { + setForm((prev) => ({ ...prev, scheduleMode: mode })); + }; + + const toggleTool = (toolName) => { + setForm((prev) => ({ + ...prev, + selectedTools: prev.selectedTools.includes(toolName) + ? prev.selectedTools.filter((t) => t !== toolName) + : [...prev.selectedTools, toolName], + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!form.name.trim() || !form.prompt.trim() || !form.schedule.trim()) { + showToast("Please fill in all required fields", "error"); + return; + } + + setSaving(true); + const data = { + name: form.name.trim(), + prompt: form.prompt.trim(), + schedule: form.schedule.trim(), + tools: form.selectedTools, + }; + + const result = isEditing + ? await ScheduledJobs.update(job.id, data) + : await ScheduledJobs.create(data); + + setSaving(false); + + if (result.error) { + showToast(result.error, "error"); + return; + } + + showToast(isEditing ? "Job updated" : "Job created", "success"); + onSaved(); + }; + + return ( +
+
+
+

+ {isEditing ? "Edit Scheduled Job" : "New Scheduled Job"} +

+ +
+ +
+
+ + +
+ +
+ +