Add Cloud Console cancel action and cancelled status
- Widen TaskStatus to include "cancelled"; add it to TasksView's STATUSES filter dropdown. - Add TaskCancellationResponse type and cancelTask(taskId) to api.ts. - Add a Cancel button to TasksView's task detail panel, gated on tasks:submit and a non-terminal task status; updates the displayed status on success and surfaces errors via the existing error path. - Extract the cancellability rule into a pure taskCancellation.ts module (mirroring taskProgress.ts/plannerHistory.ts) with unit tests, since the project has no Vue component-mounting test setup. Task 6/9 of task-cancellation change.
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
|||||||
PluginRecord,
|
PluginRecord,
|
||||||
PluginRegistrationPayload,
|
PluginRegistrationPayload,
|
||||||
TaskAttempt,
|
TaskAttempt,
|
||||||
|
TaskCancellationResponse,
|
||||||
TaskListResponse,
|
TaskListResponse,
|
||||||
TaskSubmissionPayload,
|
TaskSubmissionPayload,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
@@ -239,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
|||||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelTask(taskId: string): Promise<TaskCancellationResponse> {
|
||||||
|
return request<TaskCancellationResponse>(
|
||||||
|
`/v1/tasks/${encodeURIComponent(taskId)}/cancel`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getTaskPlannerDecisions(
|
export function getTaskPlannerDecisions(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
attempt: number,
|
attempt: number,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { canCancelTask } from "./taskCancellation";
|
||||||
|
import type { TaskStatus } from "./types";
|
||||||
|
|
||||||
|
describe("canCancelTask", () => {
|
||||||
|
it.each<TaskStatus>(["queued", "assigned", "dispatched"])(
|
||||||
|
"allows cancelling a %s task when the caller can submit",
|
||||||
|
(status) => {
|
||||||
|
expect(canCancelTask(status, true)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each<TaskStatus>(["done", "failed", "cancelled"])(
|
||||||
|
"refuses to cancel a terminal %s task even when the caller can submit",
|
||||||
|
(status) => {
|
||||||
|
expect(canCancelTask(status, true)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("refuses to cancel a cancellable task when the caller lacks submit permission", () => {
|
||||||
|
expect(canCancelTask("assigned", false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TaskStatus } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses for which cancellation is still meaningful: the task has not yet
|
||||||
|
* reached a terminal state. `cancelled` itself is excluded so a task can't be
|
||||||
|
* cancelled twice through the UI.
|
||||||
|
*/
|
||||||
|
const CANCELLABLE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||||
|
"queued",
|
||||||
|
"assigned",
|
||||||
|
"dispatched",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Cancel action should be shown/enabled for a task, given the
|
||||||
|
* caller's submit permission and the task's current status.
|
||||||
|
*/
|
||||||
|
export function canCancelTask(status: TaskStatus, canSubmit: boolean): boolean {
|
||||||
|
return canSubmit && CANCELLABLE_STATUSES.has(status);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ export type TaskStatus =
|
|||||||
| "assigned"
|
| "assigned"
|
||||||
| "dispatched"
|
| "dispatched"
|
||||||
| "done"
|
| "done"
|
||||||
| "failed";
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +42,11 @@ export interface TaskListResponse {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskCancellationResponse {
|
||||||
|
task_id: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskAttempt {
|
export interface TaskAttempt {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
|||||||
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||||
import {
|
import {
|
||||||
CloudApiError,
|
CloudApiError,
|
||||||
|
cancelTask,
|
||||||
getTaskAttempts,
|
getTaskAttempts,
|
||||||
getTaskPlannerDecisions,
|
getTaskPlannerDecisions,
|
||||||
listTasks,
|
listTasks,
|
||||||
@@ -20,6 +21,7 @@ import type {
|
|||||||
PlannerDecisionItem,
|
PlannerDecisionItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { formatTaskProgress } from "../taskProgress";
|
import { formatTaskProgress } from "../taskProgress";
|
||||||
|
import { canCancelTask } from "../taskCancellation";
|
||||||
import { computePlannerHistoryState } from "../plannerHistory";
|
import { computePlannerHistoryState } from "../plannerHistory";
|
||||||
|
|
||||||
const props = defineProps<{ canSubmit: boolean }>();
|
const props = defineProps<{ canSubmit: boolean }>();
|
||||||
@@ -30,6 +32,7 @@ const STATUSES: TaskStatus[] = [
|
|||||||
"dispatched",
|
"dispatched",
|
||||||
"done",
|
"done",
|
||||||
"failed",
|
"failed",
|
||||||
|
"cancelled",
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusFilter = ref<TaskStatus | "">("");
|
const statusFilter = ref<TaskStatus | "">("");
|
||||||
@@ -55,6 +58,7 @@ const devices = ref<DeviceRecord[]>([]);
|
|||||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||||
const plannerLoading = ref(false);
|
const plannerLoading = ref(false);
|
||||||
const plannerError = ref("");
|
const plannerError = ref("");
|
||||||
|
const cancelling = ref(false);
|
||||||
const availableDevices = computed(() =>
|
const availableDevices = computed(() =>
|
||||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||||
);
|
);
|
||||||
@@ -185,6 +189,21 @@ async function selectTask(task: TaskListItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelSelectedTask() {
|
||||||
|
if (!selectedTask.value) return;
|
||||||
|
cancelling.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const response = await cancelTask(selectedTask.value.id);
|
||||||
|
selectedTask.value = { ...selectedTask.value, status: response.status };
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, "failed to cancel task");
|
||||||
|
} finally {
|
||||||
|
cancelling.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleError(err: unknown, fallback: string) {
|
function handleError(err: unknown, fallback: string) {
|
||||||
if (err instanceof CloudApiError) {
|
if (err instanceof CloudApiError) {
|
||||||
errorMessage.value = err.message;
|
errorMessage.value = err.message;
|
||||||
@@ -257,6 +276,11 @@ const selectedTaskProgress = computed(() =>
|
|||||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canCancelSelectedTask = computed(
|
||||||
|
() =>
|
||||||
|
!!selectedTask.value && canCancelTask(selectedTask.value.status, props.canSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||||
if (!selectedTask.value?.assigned_host_id) return null;
|
if (!selectedTask.value?.assigned_host_id) return null;
|
||||||
const host = hosts.value.find(
|
const host = hosts.value.find(
|
||||||
@@ -417,6 +441,13 @@ function formatArguments(args: Record<string, unknown>): string {
|
|||||||
<h2>
|
<h2>
|
||||||
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="canCancelSelectedTask"
|
||||||
|
:disabled="cancelling"
|
||||||
|
@click="cancelSelectedTask"
|
||||||
|
>
|
||||||
|
{{ cancelling ? "Cancelling…" : "Cancel" }}
|
||||||
|
</button>
|
||||||
<button @click="clearSelection">Back to list</button>
|
<button @click="clearSelection">Back to list</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
|
|||||||
@@ -46,10 +46,10 @@
|
|||||||
|
|
||||||
## 6. Frontend: Cloud Console
|
## 6. Frontend: Cloud Console
|
||||||
|
|
||||||
- [ ] 6.1 Add `"cancelled"` to `TaskStatus` in `cloud-console/src/types.ts` and to the `STATUSES` array in `TasksView.vue`.
|
- [x] 6.1 Add `"cancelled"` to `TaskStatus` in `cloud-console/src/types.ts` and to the `STATUSES` array in `TasksView.vue`.
|
||||||
- [ ] 6.2 Add a `cancelTask(taskId)` method to `cloud-console/src/api.ts`.
|
- [x] 6.2 Add a `cancelTask(taskId)` method to `cloud-console/src/api.ts`.
|
||||||
- [ ] 6.3 Add a "Cancel" button to `TasksView.vue`'s task detail panel, visible only when the selected task's status is `queued`/`assigned`/`dispatched` and the operator's token has `tasks:submit`; on click, call `cancelTask` and refresh the displayed task.
|
- [x] 6.3 Add a "Cancel" button to `TasksView.vue`'s task detail panel, visible only when the selected task's status is `queued`/`assigned`/`dispatched` and the operator's token has `tasks:submit`; on click, call `cancelTask` and refresh the displayed task.
|
||||||
- [ ] 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled.
|
- [x] 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled. (Project has no Vue component-mounting test harness — `@vue/test-utils` isn't a dependency and no existing test exercises a `.vue` file directly. Followed the established pattern instead: extracted the visibility rule into a pure, unit-tested `taskCancellation.ts` module — mirroring `taskProgress.ts`/`plannerHistory.ts` — covering cancellable vs. terminal statuses and the `tasks:submit` scope gate. `cancelSelectedTask` in `TasksView.vue` only mutates `selectedTask.status` on a successful response and routes failures through the existing `handleError`/`errorMessage` path, so an error never flips the displayed status to cancelled.)
|
||||||
|
|
||||||
## 7. Frontend: Host Agent local console
|
## 7. Frontend: Host Agent local console
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user