Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions dashboard/src/components/task.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [abortError, setAbortError] = useState<string | null>(null);

const needsValidation = task.status.state === "NEEDS_USER_VALIDATION" || task.status.state === "NEEDSUSERVALIDATION";

Expand All @@ -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 (
<Panel shaded borderLeft={"1px solid var(--rs-gray-700)"} className={s.Panel}>
<HStack alignItems={"flex-start"} justifyContent="space-between" className={s.TaskHeader}>
Expand Down Expand Up @@ -81,9 +95,14 @@ const Task = (props: TaskProps) => {
: null
}
{needsValidation ? (
<Button size="sm" appearance="primary" onClick={onConfirm} loading={confirming}>
Confirm
</Button>
<HStack>
<Button size="sm" appearance="primary" color="red" onClick={onAbort} loading={aborting}>
Cancel
</Button>
<Button size="sm" appearance="primary" color="green" onClick={onConfirm} loading={confirming}>
Confirm
</Button>
</HStack>
) : null}
</VStack>
</HStack>
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/components/tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
7 changes: 7 additions & 0 deletions dashboard/src/services/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const useAPI = () => {
});
};

const abortTask = (id: string): Promise<ITask | null> => {
return fetchJSON(`${BASE_URL}/tasks/${id}/cancel`, {
method: "POST",
});
};

const getTaskLogs = (id: string) => {
return fetchJSON(`${BASE_URL}/tasks/${id}/logs`);
};
Expand All @@ -126,6 +132,7 @@ const useAPI = () => {
getTasks,
getTasksCount,
getTask,
abortTask,
confirmTask,
getTaskLogs,
getUser,
Expand Down
21 changes: 21 additions & 0 deletions vicky/migrations/2026-02-09-161859-0000_task_cancel/down.sql
Original file line number Diff line number Diff line change
@@ -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";
3 changes: 3 additions & 0 deletions vicky/migrations/2026-02-09-161859-0000_task_cancel/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Your SQL goes here

ALTER TYPE "TaskStatus_Type" ADD VALUE 'FINISHED::CANCEL';
4 changes: 4 additions & 0 deletions vicky/src/bin/vicky/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
}
}
Expand Down
5 changes: 3 additions & 2 deletions vicky/src/bin/vicky/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions vicky/src/bin/vicky/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,28 @@ pub async fn tasks_confirm(
Ok(Json(task))
}

#[post("/<id>/cancel")]
pub async fn tasks_cancel(
id: Uuid,
db: Database,
global_events: &State<broadcast::Sender<GlobalEvent>>,
_auth: AnyAuthGuard,
) -> Result<Json<Task>, 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<Option<Task>, AppError> {
Expand Down
6 changes: 5 additions & 1 deletion vicky/src/lib/database/entities/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub enum TaskResult {
Success,
Error,
Timeout,
Cancel,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromSqlRow, AsExpression)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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}")
Expand All @@ -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"),
}
}
Expand Down
1 change: 1 addition & 0 deletions vickyctl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub enum TaskCommands {
Claim { features: Vec<String> },
Finish { id: Uuid, status: TaskResult },
Confirm { id: Uuid },
Cancel { id: Uuid },
}

#[derive(Args, Debug)]
Expand Down
3 changes: 2 additions & 1 deletion vickyctl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions vickyctl/src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Task>(&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::<serde_json::Value>(&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;
Expand Down
Loading