feat: scheduled recipes

This commit is contained in:
zhom committed 2026-10-12 08:41:31 +04:00
1 parent 9797b8b148
commit 4144a27cad
27 files changed
+2047 -84

No files matched your search

+12
View File
@@ -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",
+46
View File
@@ -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(
+152
View File
@@ -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/);
+2 -1
View File
@@ -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",
+146
View File
@@ -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<String, String> {
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<Value, String> {
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<Value>,
) -> Result<Value, String> {
crate::cloud_api::call::<Option<Value>>(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<Value, String> {
call("list", reqwest::Method::GET, base(), None).await
}
#[tauri::command]
pub async fn create_agent_schedule(schedule: Value) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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");
}
}
+68
View File
@@ -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<reqwest::Client> = 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<T: DeserializeOwned>(
method: reqwest::Method,
url: String,
body: Option<serde_json::Value>,
codes: FailureCodes,
) -> Result<T, BackendFailure> {
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::<T>(&bytes).map_err(|e| format!("decode response: {e}"))
}
})
.await
.map_err(|e| cloud_errors::classify_message(&e, codes))
}
pub(crate) fn decode_body<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, serde_json::Error> {
if bytes.iter().all(u8::is_ascii_whitespace) {
serde_json::from_slice(b"null")
} else {
serde_json::from_slice(bytes)
}
}
+27
View File
@@ -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"] {
+8
View File
@@ -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,
+3 -54
View File
@@ -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<reqwest::Client> = 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<T: DeserializeOwned>(
method: reqwest::Method,
url: String,
body: Option<serde_json::Value>,
) -> Result<T, BackendFailure> {
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::<T>(&bytes).map_err(|e| format!("decode response: {e}"))
}
})
.await
.map_err(|e| cloud_errors::classify_message(&e, RECIPE_CODES))
}
fn decode_body<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, serde_json::Error> {
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<bool, String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::cloud_api::decode_body;
#[test]
fn a_recipe_delete_with_no_body_is_a_success() {
+51 -4
View File
@@ -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 ? (
<AgentRecipes profiles={profiles} />
) : (
<SetupPanel
@@ -641,6 +659,35 @@ export function AgentPage({
</SetupPanel>
))}
</AnimatedTabsContent>
<AnimatedTabsContent
value="schedules"
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
{blockerPanel ??
(scheduled ? (
<AgentSchedules
profiles={profiles}
canRunOnDesktop={automation}
/>
) : (
<SetupPanel
testId="agent-schedules-plan"
icon={<LuLock />}
title={t("agent.tabs.schedules")}
body={t("agent.schedules.needsPlan")}
>
<Button
size="sm"
variant="soft"
className="h-8 rounded-lg text-xs"
onClick={onOpenAccount}
>
{t("agent.setup.planAction")}
</Button>
</SetupPanel>
))}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</SoftFields>
+710
View File
@@ -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<AgentSchedule[]>([]);
const [recipes, setRecipes] = useState<AgentRecipe[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
const [editing, setEditing] = useState<AgentSchedule | "new" | null>(null);
const [pendingRemoval, setPendingRemoval] = useState<AgentSchedule | null>(
null,
);
const [isRemoving, setIsRemoving] = useState(false);
const [runsFor, setRunsFor] = useState<string | null>(null);
const [runs, setRuns] = useState<AgentScheduleRun[]>([]);
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 (
<div
data-slot="agent-schedules"
className="flex min-h-0 flex-1 flex-col gap-3"
>
<div className="flex shrink-0 items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t("agent.schedules.hint")}
</p>
<RippleButton
size="sm"
data-slot="agent-schedule-new"
className="flex h-8 items-center gap-1.5 rounded-lg text-xs"
disabled={editing !== null || recipes.length === 0}
onClick={() => setEditing("new")}
>
<GoPlus className="size-3.5" />
{t("agent.schedules.new")}
</RippleButton>
</div>
{error !== null && (
<p className="shrink-0 rounded-xl bg-destructive/10 p-3 text-sm text-destructive-text">
{translateBackendError(t, error)}
</p>
)}
{!isLoading && error === null && recipes.length === 0 && (
<p className="shrink-0 rounded-xl bg-foreground/3 p-3 text-xs text-muted-foreground">
{t("agent.schedules.needsRecipe")}
</p>
)}
{editing !== null && (
<ScheduleEditor
schedule={editing === "new" ? null : editing}
recipes={recipes}
profiles={profiles}
canRunOnDesktop={canRunOnDesktop}
weekdays={weekdays}
onCancel={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void load();
}}
/>
)}
<FadingScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-1 pr-1">
{isLoading && schedules.length === 0 ? (
Array.from({ length: 3 }, (_, index) => (
<Skeleton
key={`agent-schedule-skeleton-${index}`}
className="h-14 w-full"
/>
))
) : schedules.length === 0 ? (
<p
data-slot="agent-schedules-empty"
className="py-16 text-center text-sm text-muted-foreground"
>
{t("agent.schedules.empty")}
</p>
) : (
schedules.map((schedule) => (
<div
key={schedule.id}
data-slot="agent-schedule"
data-schedule-id={schedule.id}
className="flex flex-col gap-2 rounded-xl bg-foreground/3 p-3 transition-colors duration-150 hover:bg-foreground/5"
>
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-foreground">
{schedule.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{[
recipeName(schedule.recipeId),
summary(schedule),
t(
schedule.target === "fleet"
? "agent.schedules.targetFleet"
: "agent.schedules.targetDesktop",
),
].join(" · ")}
</span>
<span className="truncate text-xs text-muted-foreground">
{schedule.enabled
? t("agent.schedules.nextRun", {
when: formatWhen(schedule.nextRunAt, i18n.language),
})
: t("agent.schedules.paused")}
</span>
</div>
<AnimatedSwitch
aria-label={t("agent.schedules.enabled", {
name: schedule.name,
})}
checked={schedule.enabled}
onCheckedChange={(checked) =>
void toggleEnabled(schedule, checked === true)
}
/>
<Button
variant="soft"
size="sm"
data-slot="agent-schedule-run-now"
className="h-8 rounded-lg text-xs"
onClick={() => void runNow(schedule)}
>
{t("agent.schedules.runNow")}
</Button>
<Button
variant="subtle"
size="sm"
className="h-8 rounded-lg text-xs"
aria-expanded={runsFor === schedule.id}
onClick={() => void showRuns(schedule)}
>
{t("agent.schedules.runs")}
</Button>
<Button
variant="subtle"
size="sm"
className="h-8 rounded-lg text-xs"
onClick={() => setEditing(schedule)}
>
{t("common.buttons.edit")}
</Button>
<Button
variant="subtle"
size="sm"
className="h-8 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
onClick={() => setPendingRemoval(schedule)}
>
{t("common.buttons.delete")}
</Button>
</div>
{runsFor === schedule.id && (
<ul
data-slot="agent-schedule-runs"
className="flex flex-col gap-1 border-t border-border pt-2 text-xs"
>
{runs.length === 0 ? (
<li className="text-muted-foreground">
{t("agent.schedules.noRuns")}
</li>
) : (
runs.slice(0, 10).map((run) => (
<li
key={run.id}
className="flex items-center justify-between gap-2"
>
<span className="text-muted-foreground">
{formatWhen(run.scheduledFor, i18n.language)}
</span>
<span>
{(RUN_STATUSES as readonly string[]).includes(
run.status,
)
? t(`agent.schedules.status.${run.status}`)
: run.status}
</span>
</li>
))
)}
</ul>
)}
</div>
))
)}
</div>
</FadingScrollArea>
<DeleteConfirmationDialog
isOpen={pendingRemoval !== null}
onClose={() => 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}
/>
</div>
);
}
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<string[]>(
schedule?.profileIds ?? [],
);
const [target, setTarget] = useState<ScheduleTarget>(
schedule?.target ?? "fleet",
);
const [cadence, setCadence] = useState<ScheduleCadence>(
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 (
<form
data-slot="agent-schedule-editor"
className="flex shrink-0 flex-col gap-3 rounded-xl bg-foreground/3 p-3"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="agent-schedule-name" className={labelClass}>
{t("agent.schedules.name")}
</Label>
<Input
id="agent-schedule-name"
value={name}
maxLength={MAX_SCHEDULE_NAME_CHARS}
placeholder={t("agent.schedules.namePlaceholder")}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label className={labelClass}>{t("agent.schedules.recipe")}</Label>
<Select value={recipeId} onValueChange={setRecipeId}>
<SelectTrigger data-slot="agent-schedule-recipe">
<SelectValue placeholder={t("agent.schedules.recipe")} />
</SelectTrigger>
<SelectContent>
{recipes.map((recipe) => (
<SelectItem key={recipe.id} value={recipe.id}>
{recipe.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-2">
<Label className={labelClass}>
{t("agent.schedules.profiles", { n: profileIds.length })}
</Label>
<div className="flex max-h-36 flex-col gap-0.5 overflow-y-auto rounded-lg bg-foreground/3 p-1.5">
{profiles.map((profile) => {
const id = `agent-schedule-profile-${profile.id}`;
return (
<div
key={profile.id}
className="flex items-center gap-2 px-1 py-0.5"
>
<Checkbox
id={id}
data-slot="agent-schedule-profile"
checked={profileIds.includes(profile.id)}
onCheckedChange={(checked) =>
toggleProfile(profile.id, checked === true)
}
/>
<label htmlFor={id} className="min-w-0 truncate text-sm">
{profile.name}
</label>
</div>
);
})}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className={labelClass}>{t("agent.schedules.target")}</Label>
<Select
value={target}
onValueChange={(value) => setTarget(value as ScheduleTarget)}
>
<SelectTrigger data-slot="agent-schedule-target">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fleet">
{t("agent.schedules.targetFleet")}
</SelectItem>
<SelectItem value="desktop" disabled={!canRunOnDesktop}>
<span className="flex items-center gap-2">
{t("agent.schedules.targetDesktop")}
{!canRunOnDesktop && <ProBadge />}
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className={labelClass}>{t("agent.schedules.cadence")}</Label>
<Select
value={cadence}
onValueChange={(value) => setCadence(value as ScheduleCadence)}
>
<SelectTrigger data-slot="agent-schedule-cadence">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly">
{t("agent.schedules.cadenceWeekly")}
</SelectItem>
<SelectItem value="once">
{t("agent.schedules.cadenceOnce")}
</SelectItem>
<SelectItem value="interval">
{t("agent.schedules.cadenceInterval")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{cadence === "weekly" && (
<div className="flex flex-wrap items-end gap-3">
<fieldset className="flex flex-col gap-2">
<legend className={cn(labelClass, "mb-2")}>
{t("agent.schedules.days")}
</legend>
<div className="flex gap-1">
{WEEKDAYS.map((day) => {
const on = (daysMask & (1 << day)) !== 0;
return (
<Button
key={day}
type="button"
size="sm"
variant={on ? "soft" : "subtle"}
aria-pressed={on}
data-slot="agent-schedule-day"
className={cn(
"h-8 min-w-10 rounded-lg px-2 text-xs",
!on && "text-muted-foreground",
)}
onClick={() => setDaysMask(daysMask ^ (1 << day))}
>
{weekdays[day]}
</Button>
);
})}
</div>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="agent-schedule-time" className={labelClass}>
{t("agent.schedules.time")}
</Label>
<Input
id="agent-schedule-time"
type="time"
value={time}
className="w-32"
onChange={(event) => setTime(event.target.value)}
/>
</div>
<p className="pb-2 text-xs text-muted-foreground">
{t("agent.schedules.timezone", { timezone })}
</p>
</div>
)}
{cadence === "once" && (
<div className="flex flex-col gap-2">
<Label htmlFor="agent-schedule-once" className={labelClass}>
{t("agent.schedules.runOnceAt")}
</Label>
<Input
id="agent-schedule-once"
type="datetime-local"
value={runOnceAt}
className="w-64"
onChange={(event) => setRunOnceAt(event.target.value)}
/>
</div>
)}
{cadence === "interval" && (
<div className="flex flex-col gap-2">
<Label htmlFor="agent-schedule-interval" className={labelClass}>
{t("agent.schedules.intervalMinutes", {
min: MIN_INTERVAL_MINUTES,
max: MAX_INTERVAL_MINUTES,
})}
</Label>
<Input
id="agent-schedule-interval"
type="number"
min={MIN_INTERVAL_MINUTES}
max={MAX_INTERVAL_MINUTES}
value={intervalMinutes}
className="w-32"
onChange={(event) => setIntervalMinutes(event.target.value)}
/>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button
variant="subtle"
size="sm"
className="h-8 rounded-lg text-xs"
onClick={onCancel}
type="button"
>
{t("common.buttons.cancel")}
</Button>
<RippleButton
type="submit"
size="sm"
data-slot="agent-schedule-save"
className="h-8 rounded-lg text-xs"
disabled={!canSave}
>
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
</RippleButton>
</div>
</form>
);
}
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+52 -2
View File
@@ -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": {
+18
View File
@@ -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":
+17
View File
@@ -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<string, Capabilities> = {
cookieBot: true,
remoteInteractive: false,
remoteControl: true,
scheduledAutomation: true,
},
pro: {
browserAutomation: true,
@@ -48,6 +51,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cookieBot: true,
remoteInteractive: true,
remoteControl: true,
scheduledAutomation: true,
},
team: {
browserAutomation: true,
@@ -57,6 +61,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cookieBot: true,
remoteInteractive: true,
remoteControl: true,
scheduledAutomation: true,
},
enterprise: {
browserAutomation: true,
@@ -66,6 +71,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
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;
+50
View File
@@ -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);
});
+148
View File
@@ -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<string, unknown> {
const body: Record<string, unknown> = {
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<AgentSchedule[]> {
return invoke<AgentSchedule[] | null>("list_agent_schedules").then(
(list) => list ?? [],
);
}
export function createSchedule(input: ScheduleInput): Promise<AgentSchedule> {
return invoke<AgentSchedule>("create_agent_schedule", {
schedule: scheduleBody(input),
});
}
export function updateSchedule(
id: string,
patch: Record<string, unknown>,
): Promise<AgentSchedule> {
return invoke<AgentSchedule>("update_agent_schedule", { id, patch });
}
export function deleteSchedule(id: string): Promise<void> {
return invoke<void>("delete_agent_schedule", { id });
}
export function runScheduleNow(id: string): Promise<unknown> {
return invoke("run_agent_schedule_now", { id });
}
export function listScheduleRuns(id: string): Promise<AgentScheduleRun[]> {
return invoke<AgentScheduleRun[] | null>("list_agent_schedule_runs", {
id,
}).then((runs) => runs ?? []);
}
+17 -3
View File
@@ -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"
>
>;