mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
feat: scheduled recipes
This commit is contained in:
1 parent
9797b8b148
commit
4144a27cad
27 files changed
+2047
-84
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user