diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index f35c115b..e5f9aa1a 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -365,6 +365,18 @@ export const commandCoverage = { "recipes::delete_agent_recipe", ], }, + schedules: { + suite: "integrations", + level: "contract", + commands: [ + "agent_schedules::list_agent_schedules", + "agent_schedules::create_agent_schedule", + "agent_schedules::update_agent_schedule", + "agent_schedules::delete_agent_schedule", + "agent_schedules::run_agent_schedule_now", + "agent_schedules::list_agent_schedule_runs", + ], + }, feedback: { suite: "ui", level: "integration", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index 4920a79e..b8fcedb9 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -1602,6 +1602,52 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de /"code":"PROFILE_NOT_FOUND"/, ); + // Schedules live in the cloud too, which checks the plan on every save + // and every fire; signed out, every call refuses with a code, and what + // the app can check itself is checked before anything is sent. + const scheduleId = "00000000-0000-0000-0000-000000000000"; + assert.match(await app.invokeError("list_agent_schedules"), notSignedIn); + assert.match( + await app.invokeError("create_agent_schedule", { + schedule: { + name: "Nightly", + recipeId: "r", + target: "fleet", + profileIds: ["p"], + }, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("create_agent_schedule", { + schedule: ["not", "an", "object"], + }), + /"code":"AGENT_SCHEDULE_INVALID"/, + ); + assert.match( + await app.invokeError("update_agent_schedule", { + id: scheduleId, + patch: { enabled: false }, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("update_agent_schedule", { id: " ", patch: {} }), + /"code":"AGENT_SCHEDULE_NOT_FOUND"/, + ); + assert.match( + await app.invokeError("delete_agent_schedule", { id: scheduleId }), + notSignedIn, + ); + assert.match( + await app.invokeError("run_agent_schedule_now", { id: scheduleId }), + notSignedIn, + ); + assert.match( + await app.invokeError("list_agent_schedule_runs", { id: scheduleId }), + notSignedIn, + ); + // Recipes live in the cloud; signed out, every call refuses with a code. assert.match(await app.invokeError("get_agent_recipes"), notSignedIn); assert.match( diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index f88997aa..685c9f72 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2964,6 +2964,157 @@ test("a folder with no manifest fails with the translated reason, not a raw code }); }); +test("scheduled recipes stay behind the plan, and a schedule is built from the form", async () => { + await withApp( + "ui-agent-schedules", + async (app) => { + const profile = await createUiProfile(app, "Scheduled Profile"); + const user = (plan) => ({ + logged_in_at: new Date().toISOString(), + user: { + id: "ui-schedules", + email: "schedules@example.test", + plan, + planPeriod: "monthly", + subscriptionStatus: "active", + profileLimit: 50, + cloudProfilesUsed: 0, + proxyBandwidthLimitMb: 0, + proxyBandwidthUsedMb: 0, + proxyBandwidthExtraMb: 0, + isPrimaryDevice: true, + }, + }); + await stubCommand(app, "get_mcp_remote_status", { + enabled: true, + connected: true, + instanceId: "ui-schedules", + lastError: null, + }); + await stubCommand(app, "get_agent_recipes", [ + { + id: "r1", + name: "Check orders", + steps: [{ type: "navigate", url: "https://example.com" }], + }, + ]); + await stubCommand(app, "list_agent_schedules", [ + { + id: "s1", + name: "Hourly check", + enabled: true, + target: "fleet", + recipeId: "r1", + profileIds: [profile.id], + groupId: null, + cadence: "interval", + daysMask: 31, + runAtMinute: 540, + timezone: "UTC", + runOnceAt: null, + intervalMinutes: 60, + nextRunAt: new Date(Date.now() + 3_600_000).toISOString(), + lastRunAt: null, + }, + ]); + await stubCommand(app, "create_agent_schedule", { id: "s2" }); + const calls = (command) => + app.execute( + "return (window.__donutStubbedCalls ?? []).filter((c) => c.command === arguments[0]).map((c) => c.payload);", + [command], + ); + + const signIn = async (plan) => { + await stubCommand(app, "cloud_get_user", user(plan)); + await app.invoke("plugin:event|emit", { + event: "cloud-auth-changed", + payload: null, + }); + }; + + // A plan without schedules never reaches the form. + await signIn("free"); + await app.clickSelector(`[aria-label="${en.rail.agent}"]`); + await app.clickSelector('[data-testid="agent-tab-schedules"]'); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.equal( + await app.execute( + `return !document.querySelector('[data-slot="agent-schedules"]');`, + ), + true, + "the schedules list is not shown without the plan", + ); + await app.clickSelector(`[aria-label="${en.rail.profiles}"]`); + + await signIn("pro"); + // Turning paid opens the one-time welcome; close it first. + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[role="dialog"]'));`, + ), + { description: "the paid welcome" }, + ); + await dismissSurface(app); + await app.waitFor( + () => app.execute(`return !document.querySelector('[role="dialog"]');`), + { description: "the welcome closed" }, + ); + await app.clickSelector(`[aria-label="${en.rail.agent}"]`); + await app.clickSelector('[data-testid="agent-tab-schedules"]'); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="agent-schedule"]')?.innerText ?? null;`, + ), + { description: "the stored schedule" }, + ); + const row = await app.execute( + `return document.querySelector('[data-slot="agent-schedule"]').innerText;`, + ); + assert.match(row, /Hourly check/); + assert.match( + row, + new RegExp( + en.agent.schedules.summaryInterval.replace("{{minutes}}", "60"), + ), + ); + + await app.clickSelector('[data-slot="agent-schedule-new"]'); + await app.fillSelector("#agent-schedule-name", "Weekday check"); + await app.clickSelector(`#agent-schedule-profile-${profile.id}`); + await app.capture("agent-schedule-editor"); + await app.clickSelector('[data-slot="agent-schedule-save"]'); + await app.waitFor( + async () => (await calls("create_agent_schedule")).length === 1, + { + description: "the schedule sent to the cloud", + }, + ); + const [created] = await calls("create_agent_schedule"); + assert.equal(created.schedule.name, "Weekday check"); + assert.equal(created.schedule.recipeId, "r1"); + assert.equal(created.schedule.target, "fleet"); + assert.deepEqual(created.schedule.profileIds, [profile.id]); + assert.equal(created.schedule.cadence, "weekly"); + assert.equal( + created.schedule.daysMask, + 0b0011111, + "Monday to Friday by default", + ); + assert.equal(created.schedule.runAtMinute, 9 * 60); + assert.ok(created.schedule.timezone); + assert.equal(created.schedule.enabled, true); + assert.equal( + created.schedule.intervalMinutes, + undefined, + "only the weekly fields", + ); + }, + { seedDownloadedBrowser: true }, + ); +}); + test("the agent page guides setup, then shows agents, requests, notes, take-overs and the pause live", async () => { await withApp( "ui-agent", @@ -3148,6 +3299,7 @@ test("the agent page guides setup, then shows agents, requests, notes, take-over en.agent.tabs.profiles, en.agent.tabs.activity, en.agent.tabs.recipes, + en.agent.tabs.schedules, ], ); assert.match(await painted("agent-quota"), /1[,.\s ]?760/); diff --git a/package.json b/package.json index 2ecac0b0..c15f552b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", "test:tips": "node --test src/lib/tips.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", @@ -26,6 +26,7 @@ "test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs", "test:profile-search": "node --test src/lib/profile-search.test.mjs", "test:ui-sounds": "node --test src/lib/ui-sounds.test.mjs", + "test:schedules": "node --test src/lib/schedules.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", "licenses:generate": "node scripts/generate-licenses.mjs", diff --git a/src-tauri/src/agent_schedules.rs b/src-tauri/src/agent_schedules.rs new file mode 100644 index 00000000..b5ce62a1 --- /dev/null +++ b/src-tauri/src/agent_schedules.rs @@ -0,0 +1,146 @@ +//! Scheduled automation: recipes Donut cloud runs on a calendar, on this +//! desktop or on the remote fleet. Solo, Pro, Team and Enterprise. +//! +//! The cloud stores the schedules, decides when they fire, and checks the +//! plan on every save and again at every fire, so a schedule can never run +//! on a plan that does not include it, whatever this side does. This client +//! only passes the requests on and turns failures into codes the app +//! translates. + +use crate::cloud_errors::{BackendFailure, FailureCodes}; +use serde_json::Value; + +const SCHEDULE_CODES: FailureCodes = FailureCodes { + bad_request: "AGENT_SCHEDULE_INVALID", + forbidden: "SCHEDULED_AUTOMATION_NOT_ENTITLED", + not_found: "AGENT_SCHEDULE_NOT_FOUND", + conflict: "AGENT_SCHEDULE_INVALID", +}; + +fn base() -> String { + format!("{}/api/agent/schedules", crate::cloud_auth::CLOUD_API_URL) +} + +fn url_for(id: &str, tail: &str) -> Result { + let id = id.trim(); + if id.is_empty() || id.len() > 128 { + return Err(crate::backend_error("AGENT_SCHEDULE_NOT_FOUND")); + } + Ok(format!("{}/{}{tail}", base(), urlencoding::encode(id))) +} + +fn failure(action: &str, err: BackendFailure) -> String { + log::warn!( + "Schedule request failed action={action} code={} status={}", + err.code, + err.status + ); + err.to_error_json() +} + +/// A schedule as the app sends it: an object, never anything else. +fn checked_body(body: Value) -> Result { + if body.is_object() { + Ok(body) + } else { + Err(crate::backend_error("AGENT_SCHEDULE_INVALID")) + } +} + +async fn call( + action: &str, + method: reqwest::Method, + url: String, + body: Option, +) -> Result { + crate::cloud_api::call::>(method, url, body, SCHEDULE_CODES) + .await + .map(|value| value.unwrap_or(Value::Null)) + .map_err(|e| failure(action, e)) +} + +#[tauri::command] +pub async fn list_agent_schedules() -> Result { + call("list", reqwest::Method::GET, base(), None).await +} + +#[tauri::command] +pub async fn create_agent_schedule(schedule: Value) -> Result { + let schedule = checked_body(schedule)?; + let created = call("create", reqwest::Method::POST, base(), Some(schedule)).await?; + log::info!( + "Schedule created schedule={}", + created.get("id").and_then(Value::as_str).unwrap_or("?") + ); + Ok(created) +} + +#[tauri::command] +pub async fn update_agent_schedule(id: String, patch: Value) -> Result { + let patch = checked_body(patch)?; + call( + "update", + reqwest::Method::PATCH, + url_for(&id, "")?, + Some(patch), + ) + .await +} + +#[tauri::command] +pub async fn delete_agent_schedule(id: String) -> Result<(), String> { + call("delete", reqwest::Method::DELETE, url_for(&id, "")?, None).await?; + log::info!("Schedule deleted schedule={id}"); + Ok(()) +} + +#[tauri::command] +pub async fn run_agent_schedule_now(id: String) -> Result { + call( + "run_now", + reqwest::Method::POST, + url_for(&id, "/run-now")?, + Some(serde_json::json!({})), + ) + .await +} + +#[tauri::command] +pub async fn list_agent_schedule_runs(id: String) -> Result { + call("runs", reqwest::Method::GET, url_for(&id, "/runs")?, None).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_id_is_one_path_segment() { + let url = url_for("a/../b", "/runs").unwrap(); + assert!(url.ends_with("/api/agent/schedules/a%2F..%2Fb/runs")); + assert!(url_for(" ", "") + .unwrap_err() + .contains("AGENT_SCHEDULE_NOT_FOUND")); + assert!(url_for(&"x".repeat(129), "").is_err()); + } + + #[test] + fn only_an_object_is_sent() { + assert!(checked_body(serde_json::json!({"name": "nightly"})).is_ok()); + assert!(checked_body(serde_json::json!(["x"])) + .unwrap_err() + .contains("AGENT_SCHEDULE_INVALID")); + } + + #[test] + fn a_plan_without_schedules_is_refused_with_the_server_code() { + let refusal = crate::cloud_errors::classify( + 402, + r#"{"code":"SCHEDULED_AUTOMATION_NOT_ENTITLED"}"#, + SCHEDULE_CODES, + ); + assert_eq!(refusal.code, "SCHEDULED_AUTOMATION_NOT_ENTITLED"); + let forbidden = crate::cloud_errors::classify(403, "", SCHEDULE_CODES); + assert_eq!(forbidden.code, "SCHEDULED_AUTOMATION_NOT_ENTITLED"); + } +} diff --git a/src-tauri/src/cloud_api.rs b/src-tauri/src/cloud_api.rs new file mode 100644 index 00000000..b13385df --- /dev/null +++ b/src-tauri/src/cloud_api.rs @@ -0,0 +1,68 @@ +//! JSON calls to Donut cloud as the signed-in account: the token is refreshed +//! and the call retried once when it went stale, and a failure becomes the +//! `{"code":…}` error the frontend translates, with the caller's codes for +//! the statuses that mean something to it. + +use crate::cloud_errors::{self, BackendFailure, FailureCodes}; +use serde::de::DeserializeOwned; +use std::sync::OnceLock; +use std::time::Duration; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +pub(crate) async fn call( + method: reqwest::Method, + url: String, + body: Option, + codes: FailureCodes, +) -> Result { + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let method = method.clone(); + let url = url.clone(); + let body = body.clone(); + async move { + let mut builder = http().request(method, &url).bearer_auth(token); + if let Some(payload) = body { + builder = builder.json(&payload); + } + let response = builder + .send() + .await + .map_err(|e| format!("reach backend: {e}"))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + // api_call_with_retry and classify_message read the status from this prefix. + return Err(format!("({status}) {text}")); + } + let bytes = response + .bytes() + .await + .map_err(|e| format!("decode response: {e}"))?; + decode_body::(&bytes).map_err(|e| format!("decode response: {e}")) + } + }) + .await + .map_err(|e| cloud_errors::classify_message(&e, codes)) +} + +pub(crate) fn decode_body(bytes: &[u8]) -> Result { + if bytes.iter().all(u8::is_ascii_whitespace) { + serde_json::from_slice(b"null") + } else { + serde_json::from_slice(bytes) + } +} diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index afbacd1f..b138ea58 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -133,6 +133,11 @@ pub struct Entitlements { /// cycle out of date must not be what refuses a customer their own machine. #[serde(rename = "remoteControl", default)] pub remote_control: bool, + /// Recipes Donut cloud runs on a calendar. Read only by the UI: the cloud + /// checks the plan when a schedule is saved and again at every fire, so a + /// stale or edited copy here can open a screen but never run anything. + #[serde(rename = "scheduledAutomation", default)] + pub scheduled_automation: bool, #[serde(rename = "profileLimit", default)] pub profile_limit: i64, #[serde(rename = "requestsPerHour", default)] @@ -163,6 +168,7 @@ fn derive_entitlements( cookie_bot: false, remote_interactive: false, remote_control: false, + scheduled_automation: false, profile_limit: 0, requests_per_hour: 0, remote_browser_hours: 0, @@ -205,6 +211,8 @@ fn derive_entitlements( cookie_bot, remote_interactive, remote_control, + // Every plan that is sold with it; an unknown one waits for the server. + scheduled_automation: matches!(plan, "solo" | "pro" | "team" | "enterprise"), profile_limit, requests_per_hour: if browser_automation { DEFAULT_REQUESTS_PER_HOUR @@ -2166,6 +2174,25 @@ mod tests { ); } + #[test] + fn scheduled_automation_is_paid_only_and_not_guessed_for_an_unknown_plan() { + for plan in ["solo", "pro", "team", "enterprise"] { + assert!( + derive_entitlements(plan, Some("monthly"), "active", 20).scheduled_automation, + "{plan}" + ); + } + assert!(!derive_entitlements("free", None, "active", 3).scheduled_automation); + assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 20).scheduled_automation); + assert!(!derive_entitlements("mystery", Some("monthly"), "active", 20).scheduled_automation); + let older_backend: Entitlements = + serde_json::from_str(r#"{"active":true,"browserAutomation":true}"#).unwrap(); + assert!( + !older_backend.scheduled_automation, + "a backend that does not send the key does not open the feature" + ); + } + #[test] fn remote_control_is_granted_to_every_sold_plan_but_not_an_unknown_one() { for plan in ["solo", "pro", "team", "enterprise"] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcb9fb28..31841a68 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,8 @@ mod wayfern_terms; mod window_decorations; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme mod agent_console; +mod agent_schedules; +mod cloud_api; pub mod cloud_auth; mod cloud_errors; mod commercial_license; @@ -3543,6 +3545,12 @@ pub fn run_with_builder( local_storage::get_profile_local_storage_stats, local_storage::export_profile_storage_state, local_storage::import_profile_storage_state, + agent_schedules::list_agent_schedules, + agent_schedules::create_agent_schedule, + agent_schedules::update_agent_schedule, + agent_schedules::delete_agent_schedule, + agent_schedules::run_agent_schedule_now, + agent_schedules::list_agent_schedule_runs, start_mcp_remote_bridge, stop_mcp_remote_bridge, get_mcp_remote_status, diff --git a/src-tauri/src/recipes.rs b/src-tauri/src/recipes.rs index 7282de2f..f851e301 100644 --- a/src-tauri/src/recipes.rs +++ b/src-tauri/src/recipes.rs @@ -1,14 +1,9 @@ //! Client for the cloud recipe routes. Storage, validation rules and replay //! live in the cloud; this side only checks the shape before sending. -use crate::cloud_errors::{self, BackendFailure, FailureCodes}; +use crate::cloud_errors::{BackendFailure, FailureCodes}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use std::sync::OnceLock; -use std::time::Duration; - -const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const RECIPE_CODES: FailureCodes = FailureCodes { bad_request: "AGENT_RECIPE_INVALID", @@ -167,59 +162,12 @@ fn failure(action: &str, err: BackendFailure) -> String { err.to_error_json() } -fn http() -> &'static reqwest::Client { - static CLIENT: OnceLock = OnceLock::new(); - CLIENT.get_or_init(|| { - reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - }) -} - async fn request( method: reqwest::Method, url: String, body: Option, ) -> Result { - crate::cloud_auth::CLOUD_AUTH - .api_call_with_retry(|token| { - let method = method.clone(); - let url = url.clone(); - let body = body.clone(); - async move { - let mut builder = http().request(method, &url).bearer_auth(token); - if let Some(payload) = body { - builder = builder.json(&payload); - } - let response = builder - .send() - .await - .map_err(|e| format!("reach backend: {e}"))?; - let status = response.status().as_u16(); - if !(200..300).contains(&status) { - let text = response.text().await.unwrap_or_default(); - // api_call_with_retry and classify_message read the status from this prefix. - return Err(format!("({status}) {text}")); - } - let bytes = response - .bytes() - .await - .map_err(|e| format!("decode response: {e}"))?; - decode_body::(&bytes).map_err(|e| format!("decode response: {e}")) - } - }) - .await - .map_err(|e| cloud_errors::classify_message(&e, RECIPE_CODES)) -} - -fn decode_body(bytes: &[u8]) -> Result { - if bytes.iter().all(u8::is_ascii_whitespace) { - serde_json::from_slice(b"null") - } else { - serde_json::from_slice(bytes) - } + crate::cloud_api::call(method, url, body, RECIPE_CODES).await } #[tauri::command] @@ -252,6 +200,7 @@ pub async fn delete_agent_recipe(id: String) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::cloud_api::decode_body; #[test] fn a_recipe_delete_with_no_body_is_a_success() { diff --git a/src/components/agent-page.tsx b/src/components/agent-page.tsx index a27dbdc8..fb172e67 100644 --- a/src/components/agent-page.tsx +++ b/src/components/agent-page.tsx @@ -25,6 +25,7 @@ import { } from "@/components/agent-conversation"; import { AgentProfiles } from "@/components/agent-profiles"; import { AgentRecipes } from "@/components/agent-recipes"; +import { AgentSchedules } from "@/components/agent-schedules"; import { AnimatedSwitch } from "@/components/ui/animated-switch"; import { AnimatedTabs, @@ -52,15 +53,30 @@ import { RippleButton } from "@/components/ui/ripple"; import { Textarea } from "@/components/ui/textarea"; import type { AgentConsole } from "@/hooks/use-agent-console"; import { MAX_TEXT_CHARS, profileRollup, quotaLeft } from "@/lib/agent-console"; -import { canUseRemoteControl, getEntitlements } from "@/lib/entitlements"; +import { + canUseRemoteControl, + canUseScheduledAutomation, + getEntitlements, +} from "@/lib/entitlements"; import type { McpRemoteStatus } from "@/lib/mcp"; import { showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, CloudUser } from "@/types"; -export type AgentTab = "conversation" | "profiles" | "activity" | "recipes"; +export type AgentTab = + | "conversation" + | "profiles" + | "activity" + | "recipes" + | "schedules"; -const TABS: AgentTab[] = ["conversation", "profiles", "activity", "recipes"]; +const TABS: AgentTab[] = [ + "conversation", + "profiles", + "activity", + "recipes", + "schedules", +]; interface AgentPageProps { isOpen: boolean; @@ -305,6 +321,8 @@ export function AgentPage({ const signedIn = cloudUser !== null; const entitled = canUseRemoteControl(cloudUser); const automation = getEntitlements(cloudUser).browserAutomation; + // Solo keeps recipes for the schedules it pays for, without on-demand runs. + const scheduled = canUseScheduledAutomation(cloudUser); const blocker = !signedIn ? "signedOut" : !entitled ? "plan" : null; const remoteOff = remoteKnown && remote !== null && !remote.enabled; const hasSessions = agentConsole.sessions.length > 0; @@ -618,7 +636,7 @@ export function AgentPage({ className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex" > {blockerPanel ?? - (automation ? ( + (automation || scheduled ? ( ) : ( ))} + + + {blockerPanel ?? + (scheduled ? ( + + ) : ( + } + title={t("agent.tabs.schedules")} + body={t("agent.schedules.needsPlan")} + > + + + ))} + diff --git a/src/components/agent-schedules.tsx b/src/components/agent-schedules.tsx new file mode 100644 index 00000000..96b19982 --- /dev/null +++ b/src/components/agent-schedules.tsx @@ -0,0 +1,710 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { GoPlus } from "react-icons/go"; +import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ProBadge } from "@/components/ui/pro-badge"; +import { RippleButton } from "@/components/ui/ripple"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { translateBackendError } from "@/lib/backend-errors"; +import { type AgentRecipe, getAgentRecipes } from "@/lib/recipes"; +import { + type AgentSchedule, + type AgentScheduleRun, + createSchedule, + deleteSchedule, + isoToLocalInput, + listScheduleRuns, + listSchedules, + localTimezone, + MAX_INTERVAL_MINUTES, + MAX_SCHEDULE_NAME_CHARS, + MAX_SCHEDULE_PROFILES, + MIN_INTERVAL_MINUTES, + minuteToTime, + runScheduleNow, + type ScheduleCadence, + type ScheduleTarget, + scheduleBody, + timeToMinute, + updateSchedule, + WEEKDAYS, +} from "@/lib/schedules"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; +import type { BrowserProfile } from "@/types"; + +const RUN_STATUSES = [ + "pending", + "running", + "succeeded", + "partial", + "failed", + "cancelled", +] as const; + +/** Short weekday names in the app's language, Monday first. */ +function useWeekdayNames(): string[] { + const { i18n } = useTranslation(); + return useMemo(() => { + const format = new Intl.DateTimeFormat(i18n.language, { weekday: "short" }); + // 2024-01-01 was a Monday. + return WEEKDAYS.map((day) => + format.format(new Date(Date.UTC(2024, 0, 1 + day, 12))), + ); + }, [i18n.language]); +} + +function formatWhen(iso: string | null, language: string): string { + if (!iso) return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat(language, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function AgentSchedules({ + profiles, + canRunOnDesktop, +}: { + profiles: BrowserProfile[]; + /** A schedule on this computer needs browser automation (Pro and up). */ + canRunOnDesktop: boolean; +}) { + const { t, i18n } = useTranslation(); + const weekdays = useWeekdayNames(); + const [schedules, setSchedules] = useState([]); + const [recipes, setRecipes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const [pendingRemoval, setPendingRemoval] = useState( + null, + ); + const [isRemoving, setIsRemoving] = useState(false); + const [runsFor, setRunsFor] = useState(null); + const [runs, setRuns] = useState([]); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const [nextSchedules, nextRecipes] = await Promise.all([ + listSchedules(), + getAgentRecipes(), + ]); + setSchedules(nextSchedules); + setRecipes(nextRecipes); + setError(null); + } catch (loadError) { + setError(loadError); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const recipeName = (id: string | null) => + recipes.find((recipe) => recipe.id === id)?.name ?? "—"; + + const summary = (schedule: AgentSchedule) => { + if (schedule.cadence === "once") { + return t("agent.schedules.summaryOnce", { + when: formatWhen(schedule.runOnceAt, i18n.language), + }); + } + if (schedule.cadence === "interval") { + return t("agent.schedules.summaryInterval", { + minutes: schedule.intervalMinutes ?? 0, + }); + } + const days = WEEKDAYS.filter((day) => schedule.daysMask & (1 << day)) + .map((day) => weekdays[day]) + .join(", "); + return t("agent.schedules.summaryWeekly", { + days, + time: minuteToTime(schedule.runAtMinute), + timezone: schedule.timezone, + }); + }; + + const toggleEnabled = async (schedule: AgentSchedule, enabled: boolean) => { + try { + const updated = await updateSchedule(schedule.id, { enabled }); + setSchedules((current) => + current.map((item) => (item.id === schedule.id ? updated : item)), + ); + } catch (toggleError) { + showErrorToast(translateBackendError(t, toggleError)); + } + }; + + const runNow = async (schedule: AgentSchedule) => { + try { + await runScheduleNow(schedule.id); + showSuccessToast(t("agent.schedules.runStarted")); + } catch (runError) { + showErrorToast(translateBackendError(t, runError)); + } + }; + + const showRuns = async (schedule: AgentSchedule) => { + if (runsFor === schedule.id) { + setRunsFor(null); + return; + } + try { + setRuns(await listScheduleRuns(schedule.id)); + setRunsFor(schedule.id); + } catch (runsError) { + showErrorToast(translateBackendError(t, runsError)); + } + }; + + const confirmRemoval = async () => { + if (!pendingRemoval) return; + setIsRemoving(true); + try { + await deleteSchedule(pendingRemoval.id); + showSuccessToast(t("agent.schedules.deleted")); + setPendingRemoval(null); + void load(); + } catch (removeError) { + showErrorToast(translateBackendError(t, removeError)); + } finally { + setIsRemoving(false); + } + }; + + return ( +
+
+

+ {t("agent.schedules.hint")} +

+ setEditing("new")} + > + + {t("agent.schedules.new")} + +
+ + {error !== null && ( +

+ {translateBackendError(t, error)} +

+ )} + {!isLoading && error === null && recipes.length === 0 && ( +

+ {t("agent.schedules.needsRecipe")} +

+ )} + + {editing !== null && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + void load(); + }} + /> + )} + + +
+ {isLoading && schedules.length === 0 ? ( + Array.from({ length: 3 }, (_, index) => ( + + )) + ) : schedules.length === 0 ? ( +

+ {t("agent.schedules.empty")} +

+ ) : ( + schedules.map((schedule) => ( +
+
+
+ + {schedule.name} + + + {[ + recipeName(schedule.recipeId), + summary(schedule), + t( + schedule.target === "fleet" + ? "agent.schedules.targetFleet" + : "agent.schedules.targetDesktop", + ), + ].join(" · ")} + + + {schedule.enabled + ? t("agent.schedules.nextRun", { + when: formatWhen(schedule.nextRunAt, i18n.language), + }) + : t("agent.schedules.paused")} + +
+ + void toggleEnabled(schedule, checked === true) + } + /> + + + + +
+ {runsFor === schedule.id && ( +
    + {runs.length === 0 ? ( +
  • + {t("agent.schedules.noRuns")} +
  • + ) : ( + runs.slice(0, 10).map((run) => ( +
  • + + {formatWhen(run.scheduledFor, i18n.language)} + + + {(RUN_STATUSES as readonly string[]).includes( + run.status, + ) + ? t(`agent.schedules.status.${run.status}`) + : run.status} + +
  • + )) + )} +
+ )} +
+ )) + )} +
+
+ + setPendingRemoval(null)} + onConfirm={() => void confirmRemoval()} + title={t("agent.schedules.deleteTitle", { + name: pendingRemoval?.name ?? "", + })} + description={t("agent.schedules.deleteDescription")} + confirmButtonText={t("common.buttons.delete")} + isLoading={isRemoving} + /> +
+ ); +} + +function ScheduleEditor({ + schedule, + recipes, + profiles, + canRunOnDesktop, + weekdays, + onCancel, + onSaved, +}: { + schedule: AgentSchedule | null; + recipes: AgentRecipe[]; + profiles: BrowserProfile[]; + canRunOnDesktop: boolean; + weekdays: string[]; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(schedule?.name ?? ""); + const [recipeId, setRecipeId] = useState( + schedule?.recipeId ?? recipes[0]?.id ?? "", + ); + const [profileIds, setProfileIds] = useState( + schedule?.profileIds ?? [], + ); + const [target, setTarget] = useState( + schedule?.target ?? "fleet", + ); + const [cadence, setCadence] = useState( + schedule?.cadence ?? "weekly", + ); + // Monday to Friday until the person picks. + const [daysMask, setDaysMask] = useState(schedule?.daysMask ?? 0b0011111); + const [time, setTime] = useState( + minuteToTime(schedule?.runAtMinute ?? 9 * 60), + ); + const timezone = schedule?.timezone ?? localTimezone(); + const [runOnceAt, setRunOnceAt] = useState( + isoToLocalInput(schedule?.runOnceAt), + ); + const [intervalMinutes, setIntervalMinutes] = useState( + String(schedule?.intervalMinutes ?? 60), + ); + const [isSaving, setIsSaving] = useState(false); + + const minute = timeToMinute(time); + const interval = Number.parseInt(intervalMinutes, 10); + const onceDate = runOnceAt ? new Date(runOnceAt) : null; + const cadenceReady = + cadence === "weekly" + ? daysMask > 0 && minute !== null + : cadence === "once" + ? onceDate !== null && + !Number.isNaN(onceDate.getTime()) && + onceDate.getTime() > Date.now() + : Number.isInteger(interval) && + interval >= MIN_INTERVAL_MINUTES && + interval <= MAX_INTERVAL_MINUTES; + const canSave = + name.trim().length > 0 && + recipeId !== "" && + profileIds.length > 0 && + (target === "fleet" || canRunOnDesktop) && + cadenceReady && + !isSaving; + + const toggleProfile = (id: string, on: boolean) => { + setProfileIds((current) => + on + ? [...current, id].slice(0, MAX_SCHEDULE_PROFILES) + : current.filter((item) => item !== id), + ); + }; + + const save = async () => { + if (!canSave) return; + setIsSaving(true); + const input = { + name, + recipeId, + target, + profileIds, + cadence, + daysMask, + runAtMinute: minute ?? 0, + timezone, + runOnceAt: onceDate?.toISOString(), + intervalMinutes: interval, + }; + try { + if (schedule) { + await updateSchedule(schedule.id, scheduleBody(input)); + showSuccessToast(t("agent.schedules.updated")); + } else { + await createSchedule({ ...input, enabled: true }); + showSuccessToast(t("agent.schedules.created")); + } + onSaved(); + } catch (saveError) { + showErrorToast(translateBackendError(t, saveError)); + } finally { + setIsSaving(false); + } + }; + + const labelClass = "text-xs font-medium text-muted-foreground"; + return ( +
{ + event.preventDefault(); + void save(); + }} + > +
+
+ + setName(event.target.value)} + /> +
+
+ + +
+
+ +
+ +
+ {profiles.map((profile) => { + const id = `agent-schedule-profile-${profile.id}`; + return ( +
+ + toggleProfile(profile.id, checked === true) + } + /> + +
+ ); + })} +
+
+ +
+
+ + +
+
+ + +
+
+ + {cadence === "weekly" && ( +
+
+ + {t("agent.schedules.days")} + +
+ {WEEKDAYS.map((day) => { + const on = (daysMask & (1 << day)) !== 0; + return ( + + ); + })} +
+
+
+ + setTime(event.target.value)} + /> +
+

+ {t("agent.schedules.timezone", { timezone })} +

+
+ )} + {cadence === "once" && ( +
+ + setRunOnceAt(event.target.value)} + /> +
+ )} + {cadence === "interval" && ( +
+ + setIntervalMinutes(event.target.value)} + /> +
+ )} + +
+ + + {isSaving ? t("common.buttons.saving") : t("common.buttons.save")} + +
+
+ ); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3a075cde..769b9b88 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "Die Datei ist größer als 32 MiB.", "storageStateInvalid": "Dies ist keine storageState-Datei.", "localStorageBrowserRunning": "Schließen Sie den Browser, bevor Sie einen Speicherzustand importieren.", - "localStorageRemoteSession": "Eine Remote-Sitzung verwendet dieses Profil. Warten Sie, bis ihre Arbeit wieder hier ist." + "localStorageRemoteSession": "Eine Remote-Sitzung verwendet dieses Profil. Warten Sie, bis ihre Arbeit wieder hier ist.", + "scheduledAutomationNotEntitled": "Geplante Rezepte sind Teil von Solo, Pro, Team und Enterprise.", + "agentScheduleInvalid": "Donut Cloud hat diesen Zeitplan nicht angenommen. Prüfen Sie die Tage, die Uhrzeit und die Profile.", + "agentScheduleNotFound": "Dieser Zeitplan existiert nicht mehr." }, "rail": { "profiles": "Profile", @@ -3426,7 +3429,8 @@ "conversation": "Unterhaltung", "profiles": "Profile", "activity": "Aktivität", - "recipes": "Rezepte" + "recipes": "Rezepte", + "schedules": "Zeitpläne" }, "header": { "quota": "Noch {{left}} von {{limit}} Aufrufen in dieser Stunde", @@ -3625,6 +3629,52 @@ "deleteTitle": "{{name}} löschen?", "deleteDescription": "Das Rezept wird von allen Geräten entfernt.", "needsAutomation": "Rezepte öffnen und steuern Browser und erfordern daher den Pro-Tarif oder höher. Ihr Agent kann weiterhin Profile, Proxys und Gruppen verwalten." + }, + "schedules": { + "hint": "Führen Sie ein Rezept nach Zeitplan aus, auf der Remote-Flotte oder auf diesem Computer.", + "new": "Neuer Zeitplan", + "empty": "Noch keine Zeitpläne.", + "needsRecipe": "Erstellen Sie zuerst auf der Registerkarte Rezepte ein Rezept: Ein Zeitplan führt eines aus.", + "needsPlan": "Geplante Rezepte laufen nach Kalender, auf der Remote-Flotte oder auf diesem Computer. Sie sind Teil von Solo, Pro, Team und Enterprise.", + "name": "Name", + "namePlaceholder": "Nächtliche Prüfung", + "recipe": "Rezept", + "profiles": "Profile ({{n}})", + "target": "Wo es läuft", + "targetFleet": "Remote-Flotte", + "targetDesktop": "Dieser Computer", + "cadence": "Wie oft", + "cadenceWeekly": "An festen Tagen", + "cadenceOnce": "Einmal", + "cadenceInterval": "In Abständen", + "days": "Tage", + "time": "Uhrzeit", + "timezone": "Zeitzone: {{timezone}}", + "runOnceAt": "Datum und Uhrzeit", + "intervalMinutes": "Minuten zwischen den Läufen ({{min}}–{{max}})", + "summaryWeekly": "{{days}} um {{time}} ({{timezone}})", + "summaryOnce": "Einmal, {{when}}", + "summaryInterval": "Alle {{minutes}} Min.", + "nextRun": "Nächster Lauf: {{when}}", + "paused": "Pausiert", + "enabled": "{{name}} nach Zeitplan ausführen", + "runNow": "Jetzt ausführen", + "runStarted": "Lauf gestartet", + "runs": "Läufe", + "noRuns": "Noch keine Läufe.", + "created": "Zeitplan erstellt", + "updated": "Zeitplan gespeichert", + "deleted": "Zeitplan gelöscht", + "deleteTitle": "{{name}} löschen?", + "deleteDescription": "Der Zeitplan endet. Sein Rezept und frühere Läufe bleiben erhalten.", + "status": { + "pending": "Wartet", + "running": "Läuft", + "succeeded": "Fertig", + "partial": "Teilweise fertig", + "failed": "Fehlgeschlagen", + "cancelled": "Abgebrochen" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index de0e6932..6ac87bd3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "The file is larger than 32 MiB.", "storageStateInvalid": "This is not a storageState file.", "localStorageBrowserRunning": "Close the browser before you import a storage state.", - "localStorageRemoteSession": "A remote session owns this profile. Wait until its work is back here." + "localStorageRemoteSession": "A remote session owns this profile. Wait until its work is back here.", + "scheduledAutomationNotEntitled": "Scheduled recipes are part of Solo, Pro, Team and Enterprise.", + "agentScheduleInvalid": "Donut cloud did not accept this schedule. Check the days, the time and the profiles.", + "agentScheduleNotFound": "This schedule no longer exists." }, "rail": { "profiles": "Profiles", @@ -3426,7 +3429,8 @@ "conversation": "Conversation", "profiles": "Profiles", "activity": "Activity", - "recipes": "Recipes" + "recipes": "Recipes", + "schedules": "Schedules" }, "header": { "quota": "{{left}} of {{limit}} calls left this hour", @@ -3625,6 +3629,52 @@ "deleteTitle": "Delete {{name}}?", "deleteDescription": "The recipe is removed from every device.", "needsAutomation": "Recipes open and drive browsers, so they need the Pro plan or higher. Your agent can still manage profiles, proxies and groups." + }, + "schedules": { + "hint": "Run a recipe on a calendar, on the remote fleet or on this computer.", + "new": "New schedule", + "empty": "No schedules yet.", + "needsRecipe": "Make a recipe on the Recipes tab first: a schedule runs one.", + "needsPlan": "Scheduled recipes run on a calendar, on the remote fleet or on this computer. They are part of Solo, Pro, Team and Enterprise.", + "name": "Name", + "namePlaceholder": "Nightly check", + "recipe": "Recipe", + "profiles": "Profiles ({{n}})", + "target": "Where it runs", + "targetFleet": "Remote fleet", + "targetDesktop": "This computer", + "cadence": "How often", + "cadenceWeekly": "On set days", + "cadenceOnce": "Once", + "cadenceInterval": "At an interval", + "days": "Days", + "time": "Time", + "timezone": "Time zone: {{timezone}}", + "runOnceAt": "Date and time", + "intervalMinutes": "Minutes between runs ({{min}}–{{max}})", + "summaryWeekly": "{{days}} at {{time}} ({{timezone}})", + "summaryOnce": "Once, {{when}}", + "summaryInterval": "Every {{minutes}} min", + "nextRun": "Next run: {{when}}", + "paused": "Paused", + "enabled": "Run {{name}} on its schedule", + "runNow": "Run now", + "runStarted": "Run started", + "runs": "Runs", + "noRuns": "No runs yet.", + "created": "Schedule created", + "updated": "Schedule saved", + "deleted": "Schedule deleted", + "deleteTitle": "Delete {{name}}?", + "deleteDescription": "The schedule stops. Its recipe and earlier runs stay.", + "status": { + "pending": "Waiting", + "running": "Running", + "succeeded": "Done", + "partial": "Partly done", + "failed": "Failed", + "cancelled": "Cancelled" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c8942a8e..3b35071d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2658,7 +2658,10 @@ "localStorageImportTooLarge": "El archivo supera los 32 MiB.", "storageStateInvalid": "Este no es un archivo storageState.", "localStorageBrowserRunning": "Cierra el navegador antes de importar un estado de almacenamiento.", - "localStorageRemoteSession": "Una sesión remota usa este perfil. Espera a que su trabajo vuelva aquí." + "localStorageRemoteSession": "Una sesión remota usa este perfil. Espera a que su trabajo vuelva aquí.", + "scheduledAutomationNotEntitled": "Las recetas programadas forman parte de Solo, Pro, Team y Enterprise.", + "agentScheduleInvalid": "Donut cloud no aceptó esta programación. Revisa los días, la hora y los perfiles.", + "agentScheduleNotFound": "Esta programación ya no existe." }, "rail": { "profiles": "Perfiles", @@ -3469,7 +3472,8 @@ "conversation": "Conversación", "profiles": "Perfiles", "activity": "Actividad", - "recipes": "Recetas" + "recipes": "Recetas", + "schedules": "Programaciones" }, "header": { "quota": "Quedan {{left}} de {{limit}} llamadas esta hora", @@ -3668,6 +3672,52 @@ "deleteTitle": "¿Eliminar {{name}}?", "deleteDescription": "La receta se elimina de todos los dispositivos.", "needsAutomation": "Las recetas abren y controlan navegadores, por eso requieren el plan Pro o superior. Tu agente puede seguir gestionando perfiles, proxies y grupos." + }, + "schedules": { + "hint": "Ejecuta una receta según un calendario, en la flota remota o en este ordenador.", + "new": "Nueva programación", + "empty": "Aún no hay programaciones.", + "needsRecipe": "Primero crea una receta en la pestaña Recetas: una programación ejecuta una.", + "needsPlan": "Las recetas programadas se ejecutan según un calendario, en la flota remota o en este ordenador. Forman parte de Solo, Pro, Team y Enterprise.", + "name": "Nombre", + "namePlaceholder": "Revisión nocturna", + "recipe": "Receta", + "profiles": "Perfiles ({{n}})", + "target": "Dónde se ejecuta", + "targetFleet": "Flota remota", + "targetDesktop": "Este ordenador", + "cadence": "Con qué frecuencia", + "cadenceWeekly": "En días fijos", + "cadenceOnce": "Una vez", + "cadenceInterval": "Cada cierto tiempo", + "days": "Días", + "time": "Hora", + "timezone": "Zona horaria: {{timezone}}", + "runOnceAt": "Fecha y hora", + "intervalMinutes": "Minutos entre ejecuciones ({{min}}–{{max}})", + "summaryWeekly": "{{days}} a las {{time}} ({{timezone}})", + "summaryOnce": "Una vez, {{when}}", + "summaryInterval": "Cada {{minutes}} min", + "nextRun": "Próxima ejecución: {{when}}", + "paused": "En pausa", + "enabled": "Ejecutar {{name}} según su programación", + "runNow": "Ejecutar ahora", + "runStarted": "Ejecución iniciada", + "runs": "Ejecuciones", + "noRuns": "Aún no hay ejecuciones.", + "created": "Programación creada", + "updated": "Programación guardada", + "deleted": "Programación eliminada", + "deleteTitle": "¿Eliminar {{name}}?", + "deleteDescription": "La programación se detiene. Su receta y las ejecuciones anteriores se conservan.", + "status": { + "pending": "En espera", + "running": "En curso", + "succeeded": "Hecho", + "partial": "Hecho en parte", + "failed": "Falló", + "cancelled": "Cancelado" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 150fc962..cb73c249 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2658,7 +2658,10 @@ "localStorageImportTooLarge": "Le fichier dépasse 32 Mio.", "storageStateInvalid": "Ce n'est pas un fichier storageState.", "localStorageBrowserRunning": "Fermez le navigateur avant d'importer un état de stockage.", - "localStorageRemoteSession": "Une session distante utilise ce profil. Attendez que son travail soit revenu ici." + "localStorageRemoteSession": "Une session distante utilise ce profil. Attendez que son travail soit revenu ici.", + "scheduledAutomationNotEntitled": "Les recettes planifiées font partie de Solo, Pro, Team et Enterprise.", + "agentScheduleInvalid": "Donut cloud n'a pas accepté cette planification. Vérifiez les jours, l'heure et les profils.", + "agentScheduleNotFound": "Cette planification n'existe plus." }, "rail": { "profiles": "Profils", @@ -3469,7 +3472,8 @@ "conversation": "Conversation", "profiles": "Profils", "activity": "Activité", - "recipes": "Recettes" + "recipes": "Recettes", + "schedules": "Planifications" }, "header": { "quota": "Il reste {{left}} appels sur {{limit}} pour cette heure", @@ -3668,6 +3672,52 @@ "deleteTitle": "Supprimer {{name}} ?", "deleteDescription": "La recette est supprimée de tous les appareils.", "needsAutomation": "Les recettes ouvrent et pilotent des navigateurs : elles nécessitent donc le forfait Pro ou supérieur. Votre agent peut toujours gérer les profils, les proxys et les groupes." + }, + "schedules": { + "hint": "Lancez une recette selon un calendrier, sur la flotte distante ou sur cet ordinateur.", + "new": "Nouvelle planification", + "empty": "Aucune planification pour l'instant.", + "needsRecipe": "Créez d'abord une recette dans l'onglet Recettes : une planification en lance une.", + "needsPlan": "Les recettes planifiées se lancent selon un calendrier, sur la flotte distante ou sur cet ordinateur. Elles font partie de Solo, Pro, Team et Enterprise.", + "name": "Nom", + "namePlaceholder": "Vérification nocturne", + "recipe": "Recette", + "profiles": "Profils ({{n}})", + "target": "Où elle s'exécute", + "targetFleet": "Flotte distante", + "targetDesktop": "Cet ordinateur", + "cadence": "À quelle fréquence", + "cadenceWeekly": "Certains jours", + "cadenceOnce": "Une fois", + "cadenceInterval": "À intervalle", + "days": "Jours", + "time": "Heure", + "timezone": "Fuseau horaire : {{timezone}}", + "runOnceAt": "Date et heure", + "intervalMinutes": "Minutes entre deux exécutions ({{min}}–{{max}})", + "summaryWeekly": "{{days}} à {{time}} ({{timezone}})", + "summaryOnce": "Une fois, {{when}}", + "summaryInterval": "Toutes les {{minutes}} min", + "nextRun": "Prochaine exécution : {{when}}", + "paused": "En pause", + "enabled": "Lancer {{name}} selon sa planification", + "runNow": "Lancer maintenant", + "runStarted": "Exécution lancée", + "runs": "Exécutions", + "noRuns": "Aucune exécution pour l'instant.", + "created": "Planification créée", + "updated": "Planification enregistrée", + "deleted": "Planification supprimée", + "deleteTitle": "Supprimer {{name}} ?", + "deleteDescription": "La planification s'arrête. Sa recette et les exécutions passées restent.", + "status": { + "pending": "En attente", + "running": "En cours", + "succeeded": "Terminé", + "partial": "Terminé en partie", + "failed": "Échec", + "cancelled": "Annulé" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f24088fb..07c7874f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "ファイルが 32 MiB を超えています。", "storageStateInvalid": "これは storageState ファイルではありません。", "localStorageBrowserRunning": "ストレージ状態をインポートする前にブラウザーを閉じてください。", - "localStorageRemoteSession": "リモートセッションがこのプロファイルを使用しています。作業がここに戻るまでお待ちください。" + "localStorageRemoteSession": "リモートセッションがこのプロファイルを使用しています。作業がここに戻るまでお待ちください。", + "scheduledAutomationNotEntitled": "スケジュールされたレシピは Solo、Pro、Team、Enterprise に含まれます。", + "agentScheduleInvalid": "Donut クラウドがこのスケジュールを受け付けませんでした。曜日、時刻、プロファイルを確認してください。", + "agentScheduleNotFound": "このスケジュールはもう存在しません。" }, "rail": { "profiles": "プロファイル", @@ -3426,7 +3429,8 @@ "conversation": "会話", "profiles": "プロファイル", "activity": "アクティビティ", - "recipes": "レシピ" + "recipes": "レシピ", + "schedules": "スケジュール" }, "header": { "quota": "この 1 時間の残り呼び出し: {{left}} / {{limit}}", @@ -3625,6 +3629,52 @@ "deleteTitle": "{{name}} を削除しますか?", "deleteDescription": "レシピはすべての端末から削除されます。", "needsAutomation": "レシピはブラウザを開いて操作するため、Pro プラン以上が必要です。エージェントは引き続きプロファイル、プロキシ、グループを管理できます。" + }, + "schedules": { + "hint": "レシピを予定どおりに実行します。リモートフリートまたはこのコンピューターで実行できます。", + "new": "新しいスケジュール", + "empty": "スケジュールはまだありません。", + "needsRecipe": "先に「レシピ」タブでレシピを作成してください。スケジュールはレシピを実行します。", + "needsPlan": "スケジュールされたレシピは予定どおりに、リモートフリートまたはこのコンピューターで実行されます。Solo、Pro、Team、Enterprise に含まれます。", + "name": "名前", + "namePlaceholder": "夜間チェック", + "recipe": "レシピ", + "profiles": "プロファイル ({{n}})", + "target": "実行場所", + "targetFleet": "リモートフリート", + "targetDesktop": "このコンピューター", + "cadence": "頻度", + "cadenceWeekly": "決まった曜日", + "cadenceOnce": "1 回", + "cadenceInterval": "一定の間隔", + "days": "曜日", + "time": "時刻", + "timezone": "タイムゾーン: {{timezone}}", + "runOnceAt": "日時", + "intervalMinutes": "実行の間隔 (分、{{min}}–{{max}})", + "summaryWeekly": "{{days}} の {{time}} ({{timezone}})", + "summaryOnce": "1 回、{{when}}", + "summaryInterval": "{{minutes}} 分ごと", + "nextRun": "次回の実行: {{when}}", + "paused": "一時停止中", + "enabled": "{{name}} をスケジュールどおりに実行", + "runNow": "今すぐ実行", + "runStarted": "実行を開始しました", + "runs": "実行履歴", + "noRuns": "実行履歴はまだありません。", + "created": "スケジュールを作成しました", + "updated": "スケジュールを保存しました", + "deleted": "スケジュールを削除しました", + "deleteTitle": "{{name}} を削除しますか?", + "deleteDescription": "スケジュールは止まります。レシピと過去の実行履歴は残ります。", + "status": { + "pending": "待機中", + "running": "実行中", + "succeeded": "完了", + "partial": "一部完了", + "failed": "失敗", + "cancelled": "キャンセル済み" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f9920c90..dd3512f3 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "파일이 32 MiB보다 큽니다.", "storageStateInvalid": "storageState 파일이 아닙니다.", "localStorageBrowserRunning": "스토리지 상태를 가져오기 전에 브라우저를 닫으세요.", - "localStorageRemoteSession": "원격 세션이 이 프로필을 사용 중입니다. 작업이 이곳으로 돌아올 때까지 기다리세요." + "localStorageRemoteSession": "원격 세션이 이 프로필을 사용 중입니다. 작업이 이곳으로 돌아올 때까지 기다리세요.", + "scheduledAutomationNotEntitled": "예약된 레시피는 Solo, Pro, Team, Enterprise에 포함됩니다.", + "agentScheduleInvalid": "Donut 클라우드가 이 일정을 받아들이지 않았습니다. 요일, 시간, 프로필을 확인하세요.", + "agentScheduleNotFound": "이 일정은 더 이상 없습니다." }, "rail": { "profiles": "프로필", @@ -3426,7 +3429,8 @@ "conversation": "대화", "profiles": "프로필", "activity": "활동", - "recipes": "레시피" + "recipes": "레시피", + "schedules": "일정" }, "header": { "quota": "이번 시간 남은 호출: {{left}}/{{limit}}", @@ -3625,6 +3629,52 @@ "deleteTitle": "{{name}}을(를) 삭제할까요?", "deleteDescription": "레시피는 모든 기기에서 삭제됩니다.", "needsAutomation": "레시피는 브라우저를 열고 조작하므로 Pro 요금제 이상이 필요합니다. 에이전트는 계속해서 프로필, 프록시, 그룹을 관리할 수 있습니다." + }, + "schedules": { + "hint": "레시피를 일정에 따라 원격 플릿이나 이 컴퓨터에서 실행합니다.", + "new": "새 일정", + "empty": "아직 일정이 없습니다.", + "needsRecipe": "먼저 레시피 탭에서 레시피를 만드세요. 일정은 레시피를 실행합니다.", + "needsPlan": "예약된 레시피는 일정에 따라 원격 플릿이나 이 컴퓨터에서 실행됩니다. Solo, Pro, Team, Enterprise에 포함됩니다.", + "name": "이름", + "namePlaceholder": "야간 점검", + "recipe": "레시피", + "profiles": "프로필 ({{n}})", + "target": "실행 위치", + "targetFleet": "원격 플릿", + "targetDesktop": "이 컴퓨터", + "cadence": "빈도", + "cadenceWeekly": "정한 요일", + "cadenceOnce": "한 번", + "cadenceInterval": "일정 간격", + "days": "요일", + "time": "시간", + "timezone": "시간대: {{timezone}}", + "runOnceAt": "날짜와 시간", + "intervalMinutes": "실행 간격(분, {{min}}–{{max}})", + "summaryWeekly": "{{days}} {{time}} ({{timezone}})", + "summaryOnce": "한 번, {{when}}", + "summaryInterval": "{{minutes}}분마다", + "nextRun": "다음 실행: {{when}}", + "paused": "일시 중지됨", + "enabled": "{{name}}을(를) 일정대로 실행", + "runNow": "지금 실행", + "runStarted": "실행을 시작했습니다", + "runs": "실행 기록", + "noRuns": "아직 실행 기록이 없습니다.", + "created": "일정을 만들었습니다", + "updated": "일정을 저장했습니다", + "deleted": "일정을 삭제했습니다", + "deleteTitle": "{{name}}을(를) 삭제할까요?", + "deleteDescription": "일정이 멈춥니다. 레시피와 이전 실행 기록은 남습니다.", + "status": { + "pending": "대기 중", + "running": "실행 중", + "succeeded": "완료", + "partial": "일부 완료", + "failed": "실패", + "cancelled": "취소됨" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 13c181b2..37093355 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2658,7 +2658,10 @@ "localStorageImportTooLarge": "O arquivo tem mais de 32 MiB.", "storageStateInvalid": "Este não é um arquivo storageState.", "localStorageBrowserRunning": "Feche o navegador antes de importar um estado de armazenamento.", - "localStorageRemoteSession": "Uma sessão remota está usando este perfil. Aguarde até o trabalho dela voltar para cá." + "localStorageRemoteSession": "Uma sessão remota está usando este perfil. Aguarde até o trabalho dela voltar para cá.", + "scheduledAutomationNotEntitled": "As receitas agendadas fazem parte do Solo, Pro, Team e Enterprise.", + "agentScheduleInvalid": "A nuvem do Donut não aceitou este agendamento. Verifique os dias, o horário e os perfis.", + "agentScheduleNotFound": "Este agendamento não existe mais." }, "rail": { "profiles": "Perfis", @@ -3469,7 +3472,8 @@ "conversation": "Conversa", "profiles": "Perfis", "activity": "Atividade", - "recipes": "Receitas" + "recipes": "Receitas", + "schedules": "Agendamentos" }, "header": { "quota": "Restam {{left}} de {{limit}} chamadas nesta hora", @@ -3668,6 +3672,52 @@ "deleteTitle": "Eliminar {{name}}?", "deleteDescription": "A receita é removida de todos os dispositivos.", "needsAutomation": "As receitas abrem e controlam navegadores, por isso requerem o plano Pro ou superior. O seu agente continua a poder gerir perfis, proxies e grupos." + }, + "schedules": { + "hint": "Execute uma receita em um calendário, na frota remota ou neste computador.", + "new": "Novo agendamento", + "empty": "Ainda não há agendamentos.", + "needsRecipe": "Crie primeiro uma receita na aba Receitas: um agendamento executa uma.", + "needsPlan": "As receitas agendadas rodam em um calendário, na frota remota ou neste computador. Fazem parte do Solo, Pro, Team e Enterprise.", + "name": "Nome", + "namePlaceholder": "Verificação noturna", + "recipe": "Receita", + "profiles": "Perfis ({{n}})", + "target": "Onde roda", + "targetFleet": "Frota remota", + "targetDesktop": "Este computador", + "cadence": "Com que frequência", + "cadenceWeekly": "Em dias definidos", + "cadenceOnce": "Uma vez", + "cadenceInterval": "Em intervalos", + "days": "Dias", + "time": "Horário", + "timezone": "Fuso horário: {{timezone}}", + "runOnceAt": "Data e hora", + "intervalMinutes": "Minutos entre execuções ({{min}}–{{max}})", + "summaryWeekly": "{{days}} às {{time}} ({{timezone}})", + "summaryOnce": "Uma vez, {{when}}", + "summaryInterval": "A cada {{minutes}} min", + "nextRun": "Próxima execução: {{when}}", + "paused": "Pausado", + "enabled": "Executar {{name}} no agendamento", + "runNow": "Executar agora", + "runStarted": "Execução iniciada", + "runs": "Execuções", + "noRuns": "Ainda não há execuções.", + "created": "Agendamento criado", + "updated": "Agendamento salvo", + "deleted": "Agendamento excluído", + "deleteTitle": "Excluir {{name}}?", + "deleteDescription": "O agendamento para. A receita e as execuções anteriores continuam.", + "status": { + "pending": "Aguardando", + "running": "Em execução", + "succeeded": "Concluído", + "partial": "Concluído em parte", + "failed": "Falhou", + "cancelled": "Cancelado" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f78196f4..f5f470ac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2678,7 +2678,10 @@ "localStorageImportTooLarge": "Файл больше 32 МиБ.", "storageStateInvalid": "Это не файл storageState.", "localStorageBrowserRunning": "Закройте браузер перед импортом состояния хранилища.", - "localStorageRemoteSession": "Этот профиль использует удалённая сессия. Дождитесь, пока её работа вернётся сюда." + "localStorageRemoteSession": "Этот профиль использует удалённая сессия. Дождитесь, пока её работа вернётся сюда.", + "scheduledAutomationNotEntitled": "Рецепты по расписанию входят в Solo, Pro, Team и Enterprise.", + "agentScheduleInvalid": "Облако Donut не приняло это расписание. Проверьте дни, время и профили.", + "agentScheduleNotFound": "Этого расписания больше нет." }, "rail": { "profiles": "Профили", @@ -3512,7 +3515,8 @@ "conversation": "Разговор", "profiles": "Профили", "activity": "Активность", - "recipes": "Рецепты" + "recipes": "Рецепты", + "schedules": "Расписания" }, "header": { "quota": "Осталось вызовов в этот час: {{left}} из {{limit}}", @@ -3711,6 +3715,52 @@ "deleteTitle": "Удалить «{{name}}»?", "deleteDescription": "Рецепт удалится со всех устройств.", "needsAutomation": "Рецепты открывают браузеры и управляют ими, поэтому для них нужен план Pro или выше. Агент по-прежнему может управлять профилями, прокси и группами." + }, + "schedules": { + "hint": "Запускайте рецепт по расписанию — в удалённом парке или на этом компьютере.", + "new": "Новое расписание", + "empty": "Расписаний пока нет.", + "needsRecipe": "Сначала создайте рецепт на вкладке «Рецепты»: расписание запускает рецепт.", + "needsPlan": "Рецепты по расписанию запускаются по календарю — в удалённом парке или на этом компьютере. Они входят в Solo, Pro, Team и Enterprise.", + "name": "Название", + "namePlaceholder": "Ночная проверка", + "recipe": "Рецепт", + "profiles": "Профили ({{n}})", + "target": "Где запускать", + "targetFleet": "Удалённый парк", + "targetDesktop": "Этот компьютер", + "cadence": "Как часто", + "cadenceWeekly": "В выбранные дни", + "cadenceOnce": "Один раз", + "cadenceInterval": "С интервалом", + "days": "Дни", + "time": "Время", + "timezone": "Часовой пояс: {{timezone}}", + "runOnceAt": "Дата и время", + "intervalMinutes": "Минут между запусками ({{min}}–{{max}})", + "summaryWeekly": "{{days}} в {{time}} ({{timezone}})", + "summaryOnce": "Один раз, {{when}}", + "summaryInterval": "Каждые {{minutes}} мин", + "nextRun": "Следующий запуск: {{when}}", + "paused": "Приостановлено", + "enabled": "Запускать {{name}} по расписанию", + "runNow": "Запустить сейчас", + "runStarted": "Запуск начат", + "runs": "Запуски", + "noRuns": "Запусков пока нет.", + "created": "Расписание создано", + "updated": "Расписание сохранено", + "deleted": "Расписание удалено", + "deleteTitle": "Удалить {{name}}?", + "deleteDescription": "Расписание остановится. Рецепт и прошлые запуски останутся.", + "status": { + "pending": "Ожидает", + "running": "Выполняется", + "succeeded": "Готово", + "partial": "Частично готово", + "failed": "Ошибка", + "cancelled": "Отменено" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 48330f36..80d426a4 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "Dosya 32 MiB'tan büyük.", "storageStateInvalid": "Bu bir storageState dosyası değil.", "localStorageBrowserRunning": "Bir depolama durumunu içe aktarmadan önce tarayıcıyı kapatın.", - "localStorageRemoteSession": "Bu profili uzak bir oturum kullanıyor. İşi buraya dönene kadar bekleyin." + "localStorageRemoteSession": "Bu profili uzak bir oturum kullanıyor. İşi buraya dönene kadar bekleyin.", + "scheduledAutomationNotEntitled": "Zamanlanmış tarifler Solo, Pro, Team ve Enterprise'a dahildir.", + "agentScheduleInvalid": "Donut bulutu bu zamanlamayı kabul etmedi. Günleri, saati ve profilleri kontrol edin.", + "agentScheduleNotFound": "Bu zamanlama artık yok." }, "rail": { "profiles": "Profiller", @@ -3426,7 +3429,8 @@ "conversation": "Konuşma", "profiles": "Profiller", "activity": "Etkinlik", - "recipes": "Tarifler" + "recipes": "Tarifler", + "schedules": "Zamanlamalar" }, "header": { "quota": "Bu saat için {{limit}} çağrıdan {{left}} kaldı", @@ -3625,6 +3629,52 @@ "deleteTitle": "{{name}} silinsin mi?", "deleteDescription": "Tarif tüm cihazlardan silinir.", "needsAutomation": "Tarifler tarayıcıları açıp yönettiği için Pro veya daha üst bir plan gerektirir. Ajanınız profilleri, proxy'leri ve grupları yönetmeye devam edebilir." + }, + "schedules": { + "hint": "Bir tarifi takvime göre uzak filoda veya bu bilgisayarda çalıştırın.", + "new": "Yeni zamanlama", + "empty": "Henüz zamanlama yok.", + "needsRecipe": "Önce Tarifler sekmesinde bir tarif oluşturun: bir zamanlama bir tarifi çalıştırır.", + "needsPlan": "Zamanlanmış tarifler takvime göre uzak filoda veya bu bilgisayarda çalışır. Solo, Pro, Team ve Enterprise'a dahildir.", + "name": "Ad", + "namePlaceholder": "Gece kontrolü", + "recipe": "Tarif", + "profiles": "Profiller ({{n}})", + "target": "Nerede çalışır", + "targetFleet": "Uzak filo", + "targetDesktop": "Bu bilgisayar", + "cadence": "Ne sıklıkla", + "cadenceWeekly": "Belirli günlerde", + "cadenceOnce": "Bir kez", + "cadenceInterval": "Belirli aralıklarla", + "days": "Günler", + "time": "Saat", + "timezone": "Saat dilimi: {{timezone}}", + "runOnceAt": "Tarih ve saat", + "intervalMinutes": "Çalıştırmalar arası dakika ({{min}}–{{max}})", + "summaryWeekly": "{{days}}, {{time}} ({{timezone}})", + "summaryOnce": "Bir kez, {{when}}", + "summaryInterval": "Her {{minutes}} dk", + "nextRun": "Sonraki çalıştırma: {{when}}", + "paused": "Duraklatıldı", + "enabled": "{{name}} zamanlamasına göre çalışsın", + "runNow": "Şimdi çalıştır", + "runStarted": "Çalıştırma başladı", + "runs": "Çalıştırmalar", + "noRuns": "Henüz çalıştırma yok.", + "created": "Zamanlama oluşturuldu", + "updated": "Zamanlama kaydedildi", + "deleted": "Zamanlama silindi", + "deleteTitle": "{{name}} silinsin mi?", + "deleteDescription": "Zamanlama durur. Tarifi ve önceki çalıştırmaları kalır.", + "status": { + "pending": "Bekliyor", + "running": "Çalışıyor", + "succeeded": "Tamamlandı", + "partial": "Kısmen tamamlandı", + "failed": "Başarısız", + "cancelled": "İptal edildi" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index c140b73f..5e1301b4 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "Tệp lớn hơn 32 MiB.", "storageStateInvalid": "Đây không phải tệp storageState.", "localStorageBrowserRunning": "Hãy đóng trình duyệt trước khi nhập trạng thái lưu trữ.", - "localStorageRemoteSession": "Một phiên từ xa đang dùng profile này. Hãy đợi đến khi công việc của nó trở lại đây." + "localStorageRemoteSession": "Một phiên từ xa đang dùng profile này. Hãy đợi đến khi công việc của nó trở lại đây.", + "scheduledAutomationNotEntitled": "Công thức theo lịch có trong Solo, Pro, Team và Enterprise.", + "agentScheduleInvalid": "Đám mây Donut không chấp nhận lịch chạy này. Hãy kiểm tra ngày, giờ và profile.", + "agentScheduleNotFound": "Lịch chạy này không còn tồn tại." }, "rail": { "profiles": "Profile", @@ -3426,7 +3429,8 @@ "conversation": "Hội thoại", "profiles": "Hồ sơ", "activity": "Hoạt động", - "recipes": "Công thức" + "recipes": "Công thức", + "schedules": "Lịch chạy" }, "header": { "quota": "Còn {{left}}/{{limit}} lượt gọi trong giờ này", @@ -3625,6 +3629,52 @@ "deleteTitle": "Xoá {{name}}?", "deleteDescription": "Công thức bị xoá trên mọi thiết bị.", "needsAutomation": "Công thức mở và điều khiển trình duyệt nên cần gói Pro trở lên. Tác nhân của bạn vẫn có thể quản lý hồ sơ, proxy và nhóm." + }, + "schedules": { + "hint": "Chạy một công thức theo lịch, trên đội máy từ xa hoặc trên máy tính này.", + "new": "Lịch chạy mới", + "empty": "Chưa có lịch chạy nào.", + "needsRecipe": "Hãy tạo một công thức ở thẻ Công thức trước: lịch chạy sẽ chạy công thức đó.", + "needsPlan": "Công thức theo lịch chạy theo thời gian biểu, trên đội máy từ xa hoặc trên máy tính này. Có trong Solo, Pro, Team và Enterprise.", + "name": "Tên", + "namePlaceholder": "Kiểm tra ban đêm", + "recipe": "Công thức", + "profiles": "Profile ({{n}})", + "target": "Chạy ở đâu", + "targetFleet": "Đội máy từ xa", + "targetDesktop": "Máy tính này", + "cadence": "Tần suất", + "cadenceWeekly": "Vào các ngày đã chọn", + "cadenceOnce": "Một lần", + "cadenceInterval": "Theo khoảng thời gian", + "days": "Ngày", + "time": "Giờ", + "timezone": "Múi giờ: {{timezone}}", + "runOnceAt": "Ngày và giờ", + "intervalMinutes": "Số phút giữa các lần chạy ({{min}}–{{max}})", + "summaryWeekly": "{{days}} lúc {{time}} ({{timezone}})", + "summaryOnce": "Một lần, {{when}}", + "summaryInterval": "Mỗi {{minutes}} phút", + "nextRun": "Lần chạy tiếp theo: {{when}}", + "paused": "Đã tạm dừng", + "enabled": "Chạy {{name}} theo lịch", + "runNow": "Chạy ngay", + "runStarted": "Đã bắt đầu chạy", + "runs": "Các lần chạy", + "noRuns": "Chưa có lần chạy nào.", + "created": "Đã tạo lịch chạy", + "updated": "Đã lưu lịch chạy", + "deleted": "Đã xóa lịch chạy", + "deleteTitle": "Xóa {{name}}?", + "deleteDescription": "Lịch chạy dừng lại. Công thức và các lần chạy trước vẫn còn.", + "status": { + "pending": "Đang chờ", + "running": "Đang chạy", + "succeeded": "Xong", + "partial": "Xong một phần", + "failed": "Thất bại", + "cancelled": "Đã hủy" + } } }, "proxyDistribution": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 054c0af8..ef05b15c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2638,7 +2638,10 @@ "localStorageImportTooLarge": "文件大于 32 MiB。", "storageStateInvalid": "这不是 storageState 文件。", "localStorageBrowserRunning": "导入存储状态前请关闭浏览器。", - "localStorageRemoteSession": "远程会话正在使用此配置文件。请等待其工作回到这里。" + "localStorageRemoteSession": "远程会话正在使用此配置文件。请等待其工作回到这里。", + "scheduledAutomationNotEntitled": "计划配方包含在 Solo、Pro、Team 和 Enterprise 中。", + "agentScheduleInvalid": "Donut 云未接受此计划。请检查日期、时间和配置文件。", + "agentScheduleNotFound": "该计划已不存在。" }, "rail": { "profiles": "配置文件", @@ -3426,7 +3429,8 @@ "conversation": "对话", "profiles": "配置文件", "activity": "活动", - "recipes": "配方" + "recipes": "配方", + "schedules": "计划" }, "header": { "quota": "本小时剩余 {{left}} / {{limit}} 次调用", @@ -3625,6 +3629,52 @@ "deleteTitle": "删除 {{name}}?", "deleteDescription": "配方会从所有设备上删除。", "needsAutomation": "配方会打开并操控浏览器,因此需要 Pro 或更高方案。你的智能体仍可管理配置文件、代理和分组。" + }, + "schedules": { + "hint": "按日程在远程机群或此电脑上运行配方。", + "new": "新建计划", + "empty": "还没有计划。", + "needsRecipe": "请先在“配方”标签页创建配方:计划会运行配方。", + "needsPlan": "计划配方会按日程在远程机群或此电脑上运行。包含在 Solo、Pro、Team 和 Enterprise 中。", + "name": "名称", + "namePlaceholder": "夜间检查", + "recipe": "配方", + "profiles": "配置文件 ({{n}})", + "target": "运行位置", + "targetFleet": "远程机群", + "targetDesktop": "此电脑", + "cadence": "频率", + "cadenceWeekly": "在选定的日子", + "cadenceOnce": "一次", + "cadenceInterval": "按间隔", + "days": "日期", + "time": "时间", + "timezone": "时区:{{timezone}}", + "runOnceAt": "日期和时间", + "intervalMinutes": "两次运行间隔的分钟数({{min}}–{{max}})", + "summaryWeekly": "{{days}} {{time}}({{timezone}})", + "summaryOnce": "一次,{{when}}", + "summaryInterval": "每 {{minutes}} 分钟", + "nextRun": "下次运行:{{when}}", + "paused": "已暂停", + "enabled": "按计划运行 {{name}}", + "runNow": "立即运行", + "runStarted": "已开始运行", + "runs": "运行记录", + "noRuns": "还没有运行记录。", + "created": "已创建计划", + "updated": "已保存计划", + "deleted": "已删除计划", + "deleteTitle": "删除 {{name}}?", + "deleteDescription": "计划将停止。其配方和之前的运行记录会保留。", + "status": { + "pending": "等待中", + "running": "运行中", + "succeeded": "完成", + "partial": "部分完成", + "failed": "失败", + "cancelled": "已取消" + } } }, "proxyDistribution": { diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 41556d99..3b58f022 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -122,6 +122,12 @@ export type BackendErrorCode = | "API_PORT_UNAVAILABLE" | "MCP_AGENT_UNKNOWN" | "MCP_TOOLSET_UNKNOWN" + | "SCHEDULED_AUTOMATION_NOT_ENTITLED" + | "AGENT_SCHEDULE_INVALID" + | "AGENT_SCHEDULE_NOT_FOUND" + | "SCHEDULED_AUTOMATION_NOT_ENTITLED" + | "AGENT_SCHEDULE_INVALID" + | "AGENT_SCHEDULE_NOT_FOUND" | "LOCAL_STORAGE_EPHEMERAL" | "LOCAL_STORAGE_PROFILE_PROTECTED" | "LOCAL_STORAGE_UNREADABLE" @@ -673,6 +679,18 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.localStorageBrowserRunning"); case "LOCAL_STORAGE_REMOTE_SESSION": return t("backendErrors.localStorageRemoteSession"); + case "SCHEDULED_AUTOMATION_NOT_ENTITLED": + return t("backendErrors.scheduledAutomationNotEntitled"); + case "AGENT_SCHEDULE_INVALID": + return t("backendErrors.agentScheduleInvalid"); + case "AGENT_SCHEDULE_NOT_FOUND": + return t("backendErrors.agentScheduleNotFound"); + case "SCHEDULED_AUTOMATION_NOT_ENTITLED": + return t("backendErrors.scheduledAutomationNotEntitled"); + case "AGENT_SCHEDULE_INVALID": + return t("backendErrors.agentScheduleInvalid"); + case "AGENT_SCHEDULE_NOT_FOUND": + return t("backendErrors.agentScheduleNotFound"); case "MCP_TOOLSET_UNKNOWN": return t("backendErrors.mcpToolsetUnknown"); case "MCP_AGENT_UNKNOWN": diff --git a/src/lib/entitlements.ts b/src/lib/entitlements.ts index f466a439..6c251926 100644 --- a/src/lib/entitlements.ts +++ b/src/lib/entitlements.ts @@ -10,6 +10,7 @@ interface Capabilities { cookieBot: boolean; remoteInteractive: boolean; remoteControl: boolean; + scheduledAutomation: boolean; } const NONE: Entitlements = { @@ -21,6 +22,7 @@ const NONE: Entitlements = { cookieBot: false, remoteInteractive: false, remoteControl: false, + scheduledAutomation: false, profileLimit: 0, requestsPerHour: 0, remoteBrowserHours: 0, @@ -39,6 +41,7 @@ const PLAN_CAPABILITIES: Record = { cookieBot: true, remoteInteractive: false, remoteControl: true, + scheduledAutomation: true, }, pro: { browserAutomation: true, @@ -48,6 +51,7 @@ const PLAN_CAPABILITIES: Record = { cookieBot: true, remoteInteractive: true, remoteControl: true, + scheduledAutomation: true, }, team: { browserAutomation: true, @@ -57,6 +61,7 @@ const PLAN_CAPABILITIES: Record = { cookieBot: true, remoteInteractive: true, remoteControl: true, + scheduledAutomation: true, }, enterprise: { browserAutomation: true, @@ -66,6 +71,7 @@ const PLAN_CAPABILITIES: Record = { cookieBot: true, remoteInteractive: true, remoteControl: true, + scheduledAutomation: true, }, }; @@ -80,6 +86,7 @@ const DEFAULT_PAID: Capabilities = { cookieBot: true, remoteInteractive: true, remoteControl: false, + scheduledAutomation: false, }; /** @@ -114,6 +121,7 @@ export function getEntitlements( // backend with no remote-control endpoint to be entitled to, so `false` // is the true answer rather than a conservative guess. remoteControl: server.remoteControl ?? false, + scheduledAutomation: server.scheduledAutomation ?? false, remoteBrowserHours: server.remoteBrowserHours ?? 0, }; } @@ -134,6 +142,7 @@ export function getEntitlements( cookieBot: caps.cookieBot, remoteInteractive: caps.remoteInteractive, remoteControl: caps.remoteControl, + scheduledAutomation: caps.scheduledAutomation, profileLimit: user.profileLimit, requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0, remoteBrowserHours: 0, @@ -157,6 +166,14 @@ export function effectivePlanOf(user: CloudUser | null | undefined): string { * goes through here so a plan change is one edit, and so the Pro badge and the * control it guards can never disagree. */ +/** Recipes on a schedule: Solo, Pro, Team and Enterprise. */ +export function canUseScheduledAutomation( + user: CloudUser | null | undefined, +): boolean { + const entitlements = getEntitlements(user); + return entitlements.active && entitlements.scheduledAutomation; +} + export function canUseCookieBot(user: CloudUser | null | undefined): boolean { const entitlements = getEntitlements(user); return entitlements.active && entitlements.cookieBot; diff --git a/src/lib/schedules.test.mjs b/src/lib/schedules.test.mjs new file mode 100644 index 00000000..f56b3f78 --- /dev/null +++ b/src/lib/schedules.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isoToLocalInput, + minuteToTime, + scheduleBody, + timeToMinute, +} from "./schedules.ts"; + +test("times convert both ways and refuse what is not a time", () => { + assert.equal(minuteToTime(9 * 60 + 5), "09:05"); + assert.equal(timeToMinute("09:05"), 545); + assert.equal(timeToMinute("23:59"), 1439); + for (const bad of ["24:00", "9", "ab:cd", "12:60"]) { + assert.equal(timeToMinute(bad), null, bad); + } + assert.equal(isoToLocalInput(null), ""); + assert.match( + isoToLocalInput("2030-01-02T03:04:00Z"), + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/, + ); +}); + +test("a schedule sends only the fields its cadence reads", () => { + const base = { + name: " Nightly ", + recipeId: "r1", + target: "fleet", + profileIds: ["p1"], + daysMask: 31, + runAtMinute: 540, + timezone: "UTC", + runOnceAt: "2030-01-01T00:00:00.000Z", + intervalMinutes: 60, + }; + const weekly = scheduleBody({ ...base, cadence: "weekly" }); + assert.equal(weekly.name, "Nightly"); + assert.deepEqual( + [weekly.daysMask, weekly.runAtMinute, weekly.timezone], + [31, 540, "UTC"], + ); + assert.equal(weekly.runOnceAt, undefined); + assert.equal(weekly.intervalMinutes, undefined); + const once = scheduleBody({ ...base, cadence: "once" }); + assert.equal(once.runOnceAt, "2030-01-01T00:00:00.000Z"); + assert.equal(once.daysMask, undefined); + const interval = scheduleBody({ ...base, cadence: "interval" }); + assert.equal(interval.intervalMinutes, 60); + assert.equal(interval.timezone, undefined); +}); diff --git a/src/lib/schedules.ts b/src/lib/schedules.ts new file mode 100644 index 00000000..f94afaa3 --- /dev/null +++ b/src/lib/schedules.ts @@ -0,0 +1,148 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** How often a schedule fires. */ +export type ScheduleCadence = "weekly" | "once" | "interval"; + +/** Where it runs: on this computer, or on the remote fleet. */ +export type ScheduleTarget = "desktop" | "fleet"; + +/** A schedule as the cloud keeps it. */ +export interface AgentSchedule { + id: string; + name: string; + enabled: boolean; + target: ScheduleTarget; + recipeId: string | null; + profileIds: string[]; + groupId: string | null; + cadence: ScheduleCadence; + /** bit0 = Monday … bit6 = Sunday. */ + daysMask: number; + /** Minutes after midnight, in `timezone`. */ + runAtMinute: number; + timezone: string; + runOnceAt: string | null; + intervalMinutes: number | null; + nextRunAt: string | null; + lastRunAt: string | null; +} + +export interface AgentScheduleRun { + id: string; + status: string; + scheduledFor: string; + label: string; +} + +/** What a new or changed schedule sends. The cloud checks every field. */ +export interface ScheduleInput { + name: string; + recipeId: string; + target: ScheduleTarget; + profileIds: string[]; + cadence: ScheduleCadence; + daysMask?: number; + runAtMinute?: number; + timezone?: string; + runOnceAt?: string; + intervalMinutes?: number; + enabled?: boolean; +} + +/** The cloud's own limits, so the form never offers what it refuses. */ +export const MIN_INTERVAL_MINUTES = 15; +export const MAX_INTERVAL_MINUTES = 10_080; +export const MAX_SCHEDULE_PROFILES = 100; +export const MAX_SCHEDULE_NAME_CHARS = 80; + +/** Monday first, as `daysMask` counts. */ +export const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6] as const; + +export function localTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +/** `09:30` from minutes after midnight. */ +export function minuteToTime(minute: number): string { + const hours = Math.floor(minute / 60) % 24; + const minutes = minute % 60; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; +} + +/** Minutes after midnight from `09:30`, or null. */ +export function timeToMinute(time: string): number | null { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours > 23 || minutes > 59) return null; + return hours * 60 + minutes; +} + +/** A `datetime-local` value (local time) from an ISO instant. */ +export function isoToLocalInput(iso: string | null | undefined): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** Only the fields its cadence reads, so the cloud never sees a mix. */ +export function scheduleBody(input: ScheduleInput): Record { + const body: Record = { + name: input.name.trim(), + recipeId: input.recipeId, + target: input.target, + profileIds: input.profileIds, + cadence: input.cadence, + }; + if (input.enabled !== undefined) body.enabled = input.enabled; + if (input.cadence === "weekly") { + body.daysMask = input.daysMask; + body.runAtMinute = input.runAtMinute; + body.timezone = input.timezone; + } else if (input.cadence === "once") { + body.runOnceAt = input.runOnceAt; + } else { + body.intervalMinutes = input.intervalMinutes; + } + return body; +} + +export function listSchedules(): Promise { + return invoke("list_agent_schedules").then( + (list) => list ?? [], + ); +} + +export function createSchedule(input: ScheduleInput): Promise { + return invoke("create_agent_schedule", { + schedule: scheduleBody(input), + }); +} + +export function updateSchedule( + id: string, + patch: Record, +): Promise { + return invoke("update_agent_schedule", { id, patch }); +} + +export function deleteSchedule(id: string): Promise { + return invoke("delete_agent_schedule", { id }); +} + +export function runScheduleNow(id: string): Promise { + return invoke("run_agent_schedule_now", { id }); +} + +export function listScheduleRuns(id: string): Promise { + return invoke("list_agent_schedule_runs", { + id, + }).then((runs) => runs ?? []); +} diff --git a/src/types.ts b/src/types.ts index c03f555e..ad461040 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,6 +142,12 @@ export interface Entitlements { * bridge itself never gates on it: the server decides who may send work. */ remoteControl: boolean; + /** + * Recipes Donut cloud runs on a calendar. Solo, Pro, Team and Enterprise. + * The UI reads it to open the Schedules page; the cloud checks the plan on + * every save and every fire. + */ + scheduledAutomation: boolean; profileLimit: number; requestsPerHour: number; /** @@ -164,16 +170,24 @@ export interface Entitlements { * * `remoteControl` is optional too, but for the opposite reason: a backend that * omits it has no remote-control endpoint at all, so `false` is the true answer - * rather than a gap to fill in. + * rather than a gap to fill in. `scheduledAutomation` is the same. */ export type ServerEntitlements = Omit< Entitlements, - "cookieBot" | "remoteBrowserHours" | "remoteInteractive" | "remoteControl" + | "cookieBot" + | "remoteBrowserHours" + | "remoteInteractive" + | "remoteControl" + | "scheduledAutomation" > & Partial< Pick< Entitlements, - "cookieBot" | "remoteBrowserHours" | "remoteInteractive" | "remoteControl" + | "cookieBot" + | "remoteBrowserHours" + | "remoteInteractive" + | "remoteControl" + | "scheduledAutomation" > >;