diff --git a/dashboard/src/components/task.tsx b/dashboard/src/components/task.tsx index b7ce460..ea730eb 100644 --- a/dashboard/src/components/task.tsx +++ b/dashboard/src/components/task.tsx @@ -21,7 +21,9 @@ const Task = (props: TaskProps) => { const duration = task.finished_at && task.claimed_at ? Math.max(task.finished_at - task.claimed_at, 0) : null const api = useAPI(); const [confirming, setConfirming] = useState(false); + const [aborting, setAborting] = useState(false); const [confirmError, setConfirmError] = useState(null); + const [abortError, setAbortError] = useState(null); const needsValidation = task.status.state === "NEEDS_USER_VALIDATION" || task.status.state === "NEEDSUSERVALIDATION"; @@ -37,6 +39,18 @@ const Task = (props: TaskProps) => { } } + const onAbort = async () => { + try { + setAborting(true); + setAbortError(null); + await api.abortTask(task.id); + } catch (e) { + setAbortError("Could not confirm task"); + } finally { + setAborting(false); + } + } + return ( @@ -81,9 +95,14 @@ const Task = (props: TaskProps) => { : null } {needsValidation ? ( - + + + + ) : null} diff --git a/dashboard/src/components/tasks.tsx b/dashboard/src/components/tasks.tsx index 54f4234..c1809eb 100644 --- a/dashboard/src/components/tasks.tsx +++ b/dashboard/src/components/tasks.tsx @@ -25,6 +25,7 @@ export const FILTERS: FilterOption[] = [ { label: "Running", value: "RUNNING", color: "#7c3aed" }, { label: "Success", value: "FINISHED::SUCCESS", color: "#22c55e" }, { label: "Timeout", value: "FINISHED::TIMEOUT", color: "#ff6200" }, + { label: "Cancelled", value: "FINISHED::CANCEL", color: "#5f656bff" }, { label: "Error", value: "FINISHED::ERROR", color: "#ef4444" }, ]; diff --git a/dashboard/src/services/api.tsx b/dashboard/src/services/api.tsx index c143759..0026713 100644 --- a/dashboard/src/services/api.tsx +++ b/dashboard/src/services/api.tsx @@ -114,6 +114,12 @@ const useAPI = () => { }); }; + const abortTask = (id: string): Promise => { + return fetchJSON(`${BASE_URL}/tasks/${id}/cancel`, { + method: "POST", + }); + }; + const getTaskLogs = (id: string) => { return fetchJSON(`${BASE_URL}/tasks/${id}/logs`); }; @@ -126,6 +132,7 @@ const useAPI = () => { getTasks, getTasksCount, getTask, + abortTask, confirmTask, getTaskLogs, getUser, diff --git a/vicky/migrations/2026-02-09-161859-0000_task_cancel/down.sql b/vicky/migrations/2026-02-09-161859-0000_task_cancel/down.sql new file mode 100644 index 0000000..a82a4a7 --- /dev/null +++ b/vicky/migrations/2026-02-09-161859-0000_task_cancel/down.sql @@ -0,0 +1,21 @@ +-- This file should undo anything in `up.sql` + +-- can't drop enum values from an enum. +CREATE TYPE "TaskStatus_Type_New" AS ENUM ( + 'NEW', + 'NEEDS_USER_VALIDATION', + 'RUNNING', + 'FINISHED::SUCCESS', + 'FINISHED::ERROR', + 'FINSIHED::TIMEOUT' +); + +UPDATE tasks SET status = 'FINISHED::ERROR' WHERE status = 'FINISHED::CANCEL'; + +ALTER TABLE tasks + ALTER COLUMN status TYPE "TaskStatus_Type_New" + USING (status::text::"TaskStatus_Type_New"); + +DROP TYPE "TaskStatus_Type"; + +ALTER TYPE "TaskStatus_Type_New" RENAME TO "TaskStatus_Type"; \ No newline at end of file diff --git a/vicky/migrations/2026-02-09-161859-0000_task_cancel/up.sql b/vicky/migrations/2026-02-09-161859-0000_task_cancel/up.sql new file mode 100644 index 0000000..a21b489 --- /dev/null +++ b/vicky/migrations/2026-02-09-161859-0000_task_cancel/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here + +ALTER TYPE "TaskStatus_Type" ADD VALUE 'FINISHED::CANCEL'; \ No newline at end of file diff --git a/vicky/src/bin/vicky/errors.rs b/vicky/src/bin/vicky/errors.rs index fb267be..9319601 100644 --- a/vicky/src/bin/vicky/errors.rs +++ b/vicky/src/bin/vicky/errors.rs @@ -49,6 +49,9 @@ pub enum AppError { #[error("task was already confirmed")] TaskAlreadyConfirmed, + + #[error("task was already cancelled")] + TaskAlreadyCancelled, } impl<'r, 'o: 'r> Responder<'r, 'o> for AppError { @@ -60,6 +63,7 @@ impl<'r, 'o: 'r> Responder<'r, 'o> for AppError { match self { Self::HttpError(x) => x.respond_to(req), Self::TaskAlreadyConfirmed => Status::NoContent.respond_to(req), + Self::TaskAlreadyCancelled => Status::NoContent.respond_to(req), _ => Status::InternalServerError.respond_to(req), } } diff --git a/vicky/src/bin/vicky/main.rs b/vicky/src/bin/vicky/main.rs index b1d03c8..21edb9c 100644 --- a/vicky/src/bin/vicky/main.rs +++ b/vicky/src/bin/vicky/main.rs @@ -6,7 +6,7 @@ use crate::locks::{ use crate::startup::Result; use crate::tasks::{ tasks_add, tasks_claim, tasks_confirm, tasks_count, tasks_download_logs, tasks_finish, - tasks_get, tasks_get_logs, tasks_get_specific, tasks_heartbeat, tasks_put_logs, + tasks_get, tasks_get_logs, tasks_get_specific, tasks_heartbeat, tasks_put_logs, tasks_cancel }; use crate::user::get_user; use crate::webconfig::get_web_config; @@ -198,7 +198,8 @@ async fn build_web_api( tasks_get_logs, tasks_put_logs, tasks_download_logs, - tasks_confirm + tasks_confirm, + tasks_cancel ], ) .mount( diff --git a/vicky/src/bin/vicky/tasks.rs b/vicky/src/bin/vicky/tasks.rs index 4a3fbba..556416e 100644 --- a/vicky/src/bin/vicky/tasks.rs +++ b/vicky/src/bin/vicky/tasks.rs @@ -378,6 +378,28 @@ pub async fn tasks_confirm( Ok(Json(task)) } +#[post("//cancel")] +pub async fn tasks_cancel( + id: Uuid, + db: Database, + global_events: &State>, + _auth: AnyAuthGuard, +) -> Result, AppError> { + let mut task = task_or_not_found!(db, id)?; + + if task.status == TaskStatus::Finished(TaskResult::Cancel) { + return Err(AppError::TaskAlreadyCancelled); + } else if task.status != TaskStatus::NeedsUserValidation { + return Err(AppError::HttpError(Status::Conflict)); + } + + task.status = TaskStatus::Finished(TaskResult::Cancel); + db.update_task(task.clone()).await?; + global_events.send(GlobalEvent::TaskUpdate { uuid: task.id })?; + + Ok(Json(task)) +} + // only returns the task back if the task is in a running state and not timed out or finished #[allow(unused)] async fn maybe_timeout_task(task: Task, db: &mut Database) -> Result, AppError> { diff --git a/vicky/src/lib/database/entities/task.rs b/vicky/src/lib/database/entities/task.rs index 01ee3d4..be358a6 100644 --- a/vicky/src/lib/database/entities/task.rs +++ b/vicky/src/lib/database/entities/task.rs @@ -20,6 +20,7 @@ pub enum TaskResult { Success, Error, Timeout, + Cancel, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromSqlRow, AsExpression)] @@ -215,7 +216,7 @@ impl TaskStatus { | TaskStatus::New | TaskStatus::Running | TaskStatus::Finished(TaskResult::Success) => false, - TaskStatus::Finished(TaskResult::Error | TaskResult::Timeout) => true, + TaskStatus::Finished(TaskResult::Error | TaskResult::Timeout | TaskResult::Cancel ) => true, } } } @@ -291,6 +292,7 @@ pub mod db_impl { pub const STATE_FINISHED_SUCCESS_STR: &str = "FINISHED::SUCCESS"; pub const STATE_FINISHED_ERROR_STR: &str = "FINISHED::ERROR"; pub const STATE_FINISHED_TIMEOUT_STR: &str = "FINISHED::TIMEOUT"; + pub const STATE_FINISHED_CANCEL_STR: &str = "FINISHED::CANCEL"; impl Display for TaskStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -302,6 +304,7 @@ pub mod db_impl { TaskResult::Success => STATE_FINISHED_SUCCESS_STR, TaskResult::Error => STATE_FINISHED_ERROR_STR, TaskResult::Timeout => STATE_FINISHED_TIMEOUT_STR, + TaskResult::Cancel => STATE_FINISHED_CANCEL_STR, }, }; write!(f, "{str}") @@ -319,6 +322,7 @@ pub mod db_impl { STATE_FINISHED_SUCCESS_STR => Ok(TaskStatus::Finished(TaskResult::Success)), STATE_FINISHED_ERROR_STR => Ok(TaskStatus::Finished(TaskResult::Error)), STATE_FINISHED_TIMEOUT_STR => Ok(TaskStatus::Finished(TaskResult::Timeout)), + STATE_FINISHED_CANCEL_STR => Ok(TaskStatus::Finished(TaskResult::Cancel)), _ => Err("Could not deserialize to TaskStatus"), } } diff --git a/vickyctl/src/cli.rs b/vickyctl/src/cli.rs index 1c6a395..a77ba6b 100644 --- a/vickyctl/src/cli.rs +++ b/vickyctl/src/cli.rs @@ -43,6 +43,7 @@ pub enum TaskCommands { Claim { features: Vec }, Finish { id: Uuid, status: TaskResult }, Confirm { id: Uuid }, + Cancel { id: Uuid }, } #[derive(Args, Debug)] diff --git a/vickyctl/src/main.rs b/vickyctl/src/main.rs index 3472863..ce15e8d 100644 --- a/vickyctl/src/main.rs +++ b/vickyctl/src/main.rs @@ -7,7 +7,7 @@ mod tasks; mod tui; use crate::cli::{Cli, TaskCommands}; -use crate::tasks::{claim_task, confirm_task, create_task, finish_task}; +use crate::tasks::{claim_task, confirm_task, cancel_task, create_task, finish_task}; use clap::Parser; fn main() { @@ -21,6 +21,7 @@ fn main() { TaskCommands::Claim { features } => claim_task(&features, &task_args.ctx), TaskCommands::Finish { id, status } => finish_task(&id, status, &task_args.ctx), TaskCommands::Confirm { id } => confirm_task(&id, &task_args.ctx), + TaskCommands::Cancel { id } => cancel_task(&id, &task_args.ctx), }, Cli::Tasks(tasks_args) => tasks::show_tasks(&tasks_args), Cli::Locks(locks_args) => tui::show_locks(&locks_args), diff --git a/vickyctl/src/tasks.rs b/vickyctl/src/tasks.rs index e852b25..7ed0149 100644 --- a/vickyctl/src/tasks.rs +++ b/vickyctl/src/tasks.rs @@ -239,6 +239,71 @@ pub fn confirm_task(id: &Uuid, ctx: &AppContext) -> Result<(), Error> { Ok(()) } + +pub fn cancel_task(id: &Uuid, ctx: &AppContext) -> Result<(), Error> { + let client = prepare_client(ctx)?; + let request = client + .post(format!("{}/api/v1/tasks/{id}/cancel", ctx.vicky_url)) + .build()?; + + let response = client + .execute(request)? + .error_for_status() + .map_err(|e| (e, "Task couldn't be cancelled".to_string()))?; + + let status = response.status(); + let text = response.text()?; + + if text.trim().is_empty() { + if ctx.humanize { + print_http(Some(status), &format!("Task {id} cancelled.")); + } else { + println!(); + } + return Ok(()); + } + + if ctx.humanize + && let Ok(task) = serde_json::de::from_str::(&text) + { + print_http( + Some(status), + &format!( + "Task {} cancelled. New status: {:?}", + task.id.to_string().bright_blue(), + task.status + ), + ); + return Ok(()); + } + + match serde_json::de::from_str::(&text) { + Ok(pretty_json) => { + let pretty_data = serde_json::ser::to_string(&pretty_json)?; + if ctx.humanize { + print_http( + Some(status), + &format!("Task was cancelled: {}", pretty_data.bright_blue()), + ); + } else { + println!("{pretty_data}"); + } + } + Err(_) => { + if ctx.humanize { + print_http( + Some(status), + &format!("Task was cancelled: {}", text.bright_blue()), + ); + } else { + println!("{text}"); + } + } + }; + + Ok(()) +} + #[cfg(test)] mod tests { use crate::cli::TaskData;