feat: cancel jobs based on filters (#3874)

* WIP

* Add endpoint to cancel jobs

* Fix endpoints and add modal to cancel jobs

* Add useful tooltips and warnings

* Fix openapi.yml

* Prepare sqlx

* Prepare sqlx

* Select jobs to cancel

* Prepare sqlx

* Remove unused variable

* Make small fixes

- gap between buttons
- dropdown in one button with chevron instead of two buttons
- filters shown on the cancel filtered modal
This commit is contained in:
wendrul
2024-06-11 17:12:30 +02:00
committed by GitHub
parent 609f332116
commit 7474145f0e
11 changed files with 416 additions and 75 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, running, is_flow_step FROM queue WHERE scheduled_for < now() AND workspace_id = $1 AND schedule_path IS NULL",
"query": "SELECT id, is_flow_step, running FROM queue WHERE id = ANY($1) AND schedule_path IS NULL",
"describe": {
"columns": [
{
@@ -10,25 +10,25 @@
},
{
"ordinal": 1,
"name": "running",
"name": "is_flow_step",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "is_flow_step",
"name": "running",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
"UuidArray"
]
},
"nullable": [
false,
false,
true
true,
false
]
},
"hash": "caeb49629b8673c1f1c84a6e40c3e2d2c3bc3fdbde530a0a6b6fd68a22b867c3"
"hash": "4c529e29a0eb084a92f4e952506d701592bc707fd8a8cb3311bf146be6078f26"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
true,
false
false,
true
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
+65 -4
View File
@@ -5418,14 +5418,75 @@ paths:
required:
- database_length
/w/{workspace}/jobs/queue/cancel_all:
post:
summary: cancel all jobs
operationId: cancelAll
/w/{workspace}/jobs/queue/list_filtered_uuids:
get:
summary: get the ids of all jobs matching the given filters
operationId: listFilteredUuids
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/OrderDesc"
- $ref: "#/components/parameters/CreatedBy"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
- $ref: "#/components/parameters/SchedulePath"
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/Success"
- $ref: "#/components/parameters/ScheduledForBeforeNow"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/ResultFilter"
- $ref: "#/components/parameters/Tag"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: concurrency_key
in: query
required: false
schema:
type: string
- name: all_workspaces
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
- name: is_not_schedule
description: is not a scheduled job
in: query
schema:
type: boolean
responses:
"200":
description: uuids of jobs
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/jobs/queue/cancel_selection:
post:
summary: cancel jobs based on the given uuids
operationId: cancelSelection
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: uuids of the jobs to cancel
required: true
content:
application/json:
schema:
type: array
items:
type: string
responses:
"200":
description: uuids of canceled jobs
@@ -120,11 +120,10 @@ struct ObscuredJob {
}
#[derive(Deserialize)]
struct ExtendedJobsParams {
concurrency_key: Option<String>,
row_limit: Option<i64>,
}
fn join_concurrency_key<'c>(concurrency_key: Option<&String>, mut sqlb: SqlBuilder) -> SqlBuilder {
pub fn join_concurrency_key<'c>(concurrency_key: Option<&String>, mut sqlb: SqlBuilder) -> SqlBuilder {
if let Some(key) = concurrency_key {
sqlb.join("concurrency_key")
.on_eq("id", "concurrency_key.job_id")
@@ -151,7 +150,6 @@ async fn get_concurrent_intervals(
}
let row_limit = iq.row_limit.unwrap_or(1000);
let concurrency_key = iq.concurrency_key;
let lq = ListCompletedQuery { order_desc: Some(true), ..lq };
let lqc = lq.clone();
@@ -177,10 +175,10 @@ async fn get_concurrent_intervals(
.limit(row_limit)
.clone();
sqlb_q = join_concurrency_key(concurrency_key.as_ref(), sqlb_q);
sqlb_c = join_concurrency_key(concurrency_key.as_ref(), sqlb_c);
sqlb_q_user = join_concurrency_key(concurrency_key.as_ref(), sqlb_q_user);
sqlb_c_user = join_concurrency_key(concurrency_key.as_ref(), sqlb_c_user);
sqlb_q = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb_q);
sqlb_c = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb_c);
sqlb_q_user = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb_q_user);
sqlb_c_user = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb_c_user);
let should_fetch_obscured_jobs = match lq {
ListCompletedQuery {
@@ -211,7 +209,8 @@ async fn get_concurrent_intervals(
job_kinds: _,
is_flow_step: _,
all_workspaces: _,
} => concurrency_key.is_some(),
concurrency_key: Some(_),
} => true,
_ => false,
};
+76 -18
View File
@@ -28,6 +28,7 @@ use windmill_common::worker::TMP_DIR;
use windmill_common::scripts::PREVIEW_IS_CODEBASE_HASH;
use windmill_common::variables::get_workspace_key;
use crate::concurrency_groups::join_concurrency_key;
use crate::add_webhook_allowed_origin;
use crate::db::ApiAuthed;
@@ -191,7 +192,8 @@ pub fn workspaced_service() -> Router {
)
.route("/queue/list", get(list_queue_jobs))
.route("/queue/count", get(count_queue_jobs))
.route("/queue/cancel_all", post(cancel_all))
.route("/queue/list_filtered_uuids", get(list_filtered_uuids))
.route("/queue/cancel_selection", post(cancel_selection))
.route("/completed/count", get(count_completed_jobs))
.route(
"/completed/list",
@@ -994,6 +996,7 @@ pub struct ListQueueQuery {
pub is_flow_step: Option<bool>,
pub has_null_parent: Option<bool>,
pub is_not_schedule: Option<bool>,
pub concurrency_key: Option<String>,
}
impl From<ListCompletedQuery> for ListQueueQuery {
@@ -1022,6 +1025,7 @@ impl From<ListCompletedQuery> for ListQueueQuery {
is_flow_step: lcq.is_flow_step,
has_null_parent: lcq.has_null_parent,
is_not_schedule: lcq.is_not_schedule,
concurrency_key: lcq.concurrency_key,
}
}
}
@@ -1217,23 +1221,20 @@ async fn list_queue_jobs(
Ok(Json(jobs))
}
async fn cancel_all(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
#[derive(Deserialize, FromRow)]
struct JobToCancel {
id: Uuid,
is_flow_step: Option<bool>,
running: bool,
}
Path(w_id): Path<String>,
async fn cancel_jobs(
jobs: Vec<JobToCancel>,
db: &DB,
username: &str,
w_id: &str,
rsmq: Option<rsmq_async::MultiplexedRsmq>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
let jobs = sqlx::query!(
"SELECT id, running, is_flow_step FROM queue WHERE scheduled_for < now() AND workspace_id = $1 AND schedule_path IS NULL",
w_id,
)
.fetch_all(&db)
.await?;
let username = authed.username;
let mut uuids = vec![];
for j in jobs.iter() {
let r = sqlx::query!(
@@ -1241,7 +1242,7 @@ async fn cancel_all(
username,
j.id,
)
.fetch_optional(&db)
.fetch_optional(db)
.await;
if r.as_ref().is_ok_and(|x| x.is_some()) {
@@ -1254,7 +1255,7 @@ async fn cancel_all(
if let Some(job_running) = job_running {
append_logs(
&j.id,
w_id.clone(),
w_id,
format!("canceled by {username}: cancel_all"),
db.clone(),
)
@@ -1286,6 +1287,62 @@ async fn cancel_all(
Ok(Json(uuids))
}
async fn cancel_selection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Path(w_id): Path<String>,
Json(jobs): Json<Vec<Uuid>>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = user_db.begin(&authed).await?;
let jobs_to_cancel = sqlx::query_as!(
JobToCancel,
"SELECT id, is_flow_step, running FROM queue WHERE id = ANY($1) AND schedule_path IS NULL",
&jobs
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
cancel_jobs(
jobs_to_cancel,
&db,
authed.username.as_str(),
w_id.as_str(),
rsmq,
)
.await
}
async fn list_filtered_uuids(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(lq): Query<ListQueueQuery>,
) -> error::JsonResult<Vec<Uuid>> {
require_admin(authed.is_admin, &authed.username)?;
let mut sqlb = SqlBuilder::select_from("queue")
.fields(&["id"])
.clone();
sqlb = join_concurrency_key(lq.concurrency_key.as_ref(), sqlb);
sqlb.and_where_is_null("schedule_path");
sqlb = filter_list_queue_query(sqlb, &lq, w_id.as_str(), false);
let sql = sqlb.query()?;
let jobs = sqlx::query_scalar(sql.as_str()).fetch_all(&db).await?;
Ok(Json(jobs))
}
#[derive(Serialize, Debug, FromRow)]
struct QueueStats {
database_length: i64,
@@ -4326,6 +4383,7 @@ pub struct ListCompletedQuery {
pub has_null_parent: Option<bool>,
pub label: Option<String>,
pub is_not_schedule: Option<bool>,
pub concurrency_key: Option<String>,
}
async fn list_completed_jobs(
@@ -10,6 +10,7 @@
let c: string = ''
export { c as class }
export let style = ''
export let cancelText: string | undefined = undefined
const dispatch = createEventDispatcher()
@@ -87,7 +88,9 @@
color="light"
size="sm"
>
<span class="inline-flex gap-2">Cancel <Badge color="dark-gray">Escape</Badge></span>
<span class="inline-flex gap-2"
>{cancelText ?? 'Cancel'}<Badge color="dark-gray">Escape</Badge></span
>
</Button>
</div>
</div>
@@ -36,7 +36,7 @@
</script>
<Popover notClickable>
<svete:frament slot="text">
<svelte:fragment slot="text">
<div class="mb-5">
{#if self_wait_time_ms != undefined}
<div>
@@ -66,7 +66,7 @@
</div>
{/if}
<div> In a healthy queue, jobs are expected to start in under 50ms. </div>
</svete:frament>
</svelte:fragment>
{#if variant === 'icon'}
<Hourglass class={classFromColorName(waitColorTresholds(total_wait))} size={14} />
{:else if variant === 'badge'}
+13 -1
View File
@@ -31,12 +31,17 @@
export let containerWidth: number = 0
export let containsLabel: boolean = false
export let activeLabel: string | null
export let isSelectingJobsToCancel: boolean = false
let scheduleEditor: ScheduleEditor
$: isExternal = job && job.id === '-'
let triggeredByWidth: number = 0
function isJobCancelable(j: Job): boolean {
return j.type === 'QueuedJob' && !j.schedule_path
}
</script>
<Portal>
@@ -52,10 +57,17 @@
)}
style="width: {containerWidth}px"
on:click={() => {
dispatch('select')
if (!isSelectingJobsToCancel || isJobCancelable(job)) {
dispatch('select')
}
}}
>
<div class="w-1/12 flex justify-center">
{#if isSelectingJobsToCancel && isJobCancelable(job)}
<div class="px-2">
<input type="checkbox" checked={selected}/>
</div>
{/if}
{#if isExternal}
<Badge color="gray" baseClass="!px-1.5">
<ShieldQuestion size={14} />
@@ -13,6 +13,7 @@
export let externalJobs: Job[] = []
export let omittedObscuredJobs: boolean
export let showExternalJobs: boolean = false
export let isSelectingJobsToCancel: boolean = false
export let selectedIds: string[] = []
export let selectedWorkspace: string | undefined = undefined
export let activeLabel: string | null = null
@@ -171,13 +172,14 @@
>
</div>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
<div class="w-1/12 text-2xs flex flex-row">
{jobs && jobCountString(jobs.length)}
<Popover>
<AlertTriangle size={16} class="ml-0.5 text-yellow-500"/>
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
<svelte:fragment slot="text">
Too specific filtering may have caused the omission of obscured jobs. This is done for security reasons. To see obscured jobs, try removing some filters.
Too specific filtering may have caused the omission of obscured jobs. This is done for
security reasons. To see obscured jobs, try removing some filters.
</svelte:fragment>
</Popover>
</div>
@@ -214,10 +216,21 @@
{containsLabel}
job={jobOrDate.job}
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{isSelectingJobsToCancel}
on:select={() => {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
const jobId = jobOrDate.job.id
if (isSelectingJobsToCancel) {
if (selectedIds.includes(jobOrDate.job.id)) {
selectedIds = selectedIds.filter((id) => id != jobId)
} else {
selectedIds.push(jobId)
selectedIds = selectedIds
}
} else {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
}
}}
{activeLabel}
on:filterByLabel
@@ -31,10 +31,11 @@
import { twMerge } from 'tailwind-merge'
import ManuelDatePicker from '$lib/components/runs/ManuelDatePicker.svelte'
import JobLoader from '$lib/components/runs/JobLoader.svelte'
import { AlertTriangle, Calendar, Clock } from 'lucide-svelte'
import { AlertTriangle, Calendar, Check, ChevronDown, Clock, X } from 'lucide-svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
let jobs: Job[] | undefined
let selectedIds: string[] = []
@@ -97,7 +98,9 @@
let selectedManualDate = 0
let autoRefresh: boolean = true
let runDrawer: Drawer
let cancelAllJobs = false
let isCancelingVisibleJobs = false
let isCancelingFilteredJobs = false
let innerWidth = window.innerWidth
let jobLoader: JobLoader | undefined = undefined
let externalJobs: Job[] | undefined = undefined
@@ -248,6 +251,8 @@
completedJobs = undefined
selectedManualDate = 0
selectedIds = []
jobIdsToCancel = []
isSelectingJobsToCancel = false
selectedWorkspace = undefined
jobLoader?.loadJobs(minTs, maxTs, true)
}
@@ -327,6 +332,60 @@
}
}
let jobIdsToCancel: string[] = []
let isSelectingJobsToCancel = false
let fetchingFilteredJobs = false
let selectedFiltersString: string | undefined = undefined
async function cancelVisibleJobs() {
isSelectingJobsToCancel = true
selectedIds = jobs?.filter(isJobCancelable).map((j) => j.id) ?? []
if (selectedIds.length === 0 ) {
sendUserToast("There are no visible jobs that can be canceled", true)
}
}
async function cancelFilteredJobs() {
isCancelingFilteredJobs = true
fetchingFilteredJobs = true
const selectedFilters = {
workspace: $workspaceStore ?? '',
startedBefore: maxTs,
startedAfter: minTs,
schedulePath,
scriptPathExact: path === null || path === '' ? undefined : path,
createdBy: user === null || user === '' ? undefined : user,
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
jobKinds,
success: success == 'success' ? true : success == 'failure' ? false : undefined,
running: success == 'running' ? true : undefined,
isNotSchedule: showSchedules == false ? true : undefined,
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
args:
argFilter && argFilter != '{}' && argFilter != '' && argError == ''
? argFilter
: undefined,
result:
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
? resultFilter
: undefined,
allWorkspaces: allWorkspaces ? true : undefined,
concurrencyKey: concurrencyKey ?? undefined
}
selectedFiltersString = JSON.stringify(selectedFilters, null, 4)
jobIdsToCancel = await JobService.listFilteredUuids(selectedFilters)
fetchingFilteredJobs = false
}
async function cancelSelectedJobs() {
jobIdsToCancel = selectedIds
isCancelingVisibleJobs = true
}
function isJobCancelable(j: Job): boolean {
return j.type === 'QueuedJob' && !j.schedule_path
}
const warnJobLimitMsg =
'The exact number of concurrent job at the beginning of the time range may be incorrect as only the last 1000 jobs are taken into account: a job that was started earlier than this limit will not be taken into account'
@@ -334,6 +393,10 @@
graph === 'ConcurrencyChart' &&
extendedJobs !== undefined &&
extendedJobs.jobs.length + extendedJobs.obscured_jobs.length >= 1000
$: if (selectedIds.length === 0) {
isSelectingJobsToCancel = false
}
</script>
<JobLoader
@@ -368,17 +431,45 @@
/>
<ConfirmationModal
title="Confirm cancelling all jobs"
confirmationText="Cancel all jobs"
open={cancelAllJobs}
title={`Confirm cancelling all jobs correspoding to the selected filters (${jobIdsToCancel.length} jobs)`}
confirmationText={`Cancel ${jobIdsToCancel.length} jobs that matched the filters`}
open={isCancelingFilteredJobs}
on:confirmed={async () => {
cancelAllJobs = false
let uuids = await JobService.cancelAll({ workspace: $workspaceStore ?? '' })
isCancelingFilteredJobs = false
let uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: jobIdsToCancel
})
jobIdsToCancel = []
selectedIds = []
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
}}
loading={fetchingFilteredJobs}
on:canceled={() => {
isCancelingFilteredJobs = false
}}
>
<pre>{selectedFiltersString}</pre>
</ConfirmationModal>
<ConfirmationModal
title={`Confirm cancelling the jobs visible on this page`}
confirmationText={`Cancel ${jobIdsToCancel.length} jobs`}
open={isCancelingVisibleJobs}
on:confirmed={async () => {
isCancelingVisibleJobs = false
let uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
requestBody: jobIdsToCancel
})
jobIdsToCancel = []
selectedIds = []
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
}}
on:canceled={() => {
cancelAllJobs = false
isCancelingVisibleJobs = false
}}
/>
@@ -492,14 +583,65 @@
<div class="flex flex-col gap-1 md:flex-row w-full p-4">
<div class="flex gap-2 grow flex-row">
<RunsQueue {queue_count} {allWorkspaces} />
<Button
size="xs"
color="light"
variant="contained"
title="Require to be an admin. Cancel all jobs in queue"
disabled={!$userStore?.is_admin && !$superadmin}
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
>
<div class="flex flex-row">
{#if isSelectingJobsToCancel}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: Check }}
size="xs"
color="red"
variant="contained"
on:click={cancelSelectedJobs}
/>
<Button
startIcon={{ icon: X }}
size="xs"
color="gray"
variant="contained"
on:click={() => {
isSelectingJobsToCancel = false
selectedIds = []
}}
/>
</div>
{:else if !$userStore?.is_admin && !$superadmin}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
}
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
</div>
</svelte:fragment>
</DropdownV2>
{:else}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
},
{ displayName: 'Cancel all jobs matching filters', action: cancelFilteredJobs }
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{/if}
</div>
</div>
<div class="relative flex gap-2 items-center pr-8 w-40">
<Toggle
@@ -603,6 +745,7 @@
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={!graphIsRunsChart}
activeLabel={label}
{isSelectingJobsToCancel}
bind:selectedIds
bind:selectedWorkspace
on:filterByPath={filterByPath}
@@ -724,14 +867,65 @@
{#if queue_count}
<RunsQueue {queue_count} {allWorkspaces} />
{/if}
<Button
size="xs"
color="light"
variant="contained"
title="Require to be an admin. Cancel all jobs in queue"
disabled={!$userStore?.is_admin && !$superadmin}
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
>
<div class="flex flex-row">
{#if isSelectingJobsToCancel}
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: Check }}
size="xs"
color="red"
variant="contained"
on:click={cancelSelectedJobs}
/>
<Button
startIcon={{ icon: X }}
size="xs"
color="gray"
variant="contained"
on:click={() => {
isSelectingJobsToCancel = false
selectedIds = []
}}
/>
</div>
{:else if !$userStore?.is_admin && !$superadmin}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
}
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
</div>
</svelte:fragment>
</DropdownV2>
{:else}
<DropdownV2
items={[
{
displayName: 'Select jobs to cancel',
action: cancelVisibleJobs
},
{ displayName: 'Cancel all jobs matching filters', action: cancelFilteredJobs }
]}
>
<svelte:fragment slot="buttonReplacement">
<div
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
>
<span class="text-xs min-w-[5rem]">Cancel jobs</span>
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</DropdownV2>
{/if}
</div>
</div>
<div class="flex gap-2 py-1">
<div class="relative flex gap-2 items-center pr-8 w-40">
@@ -834,10 +1028,11 @@
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={!graphIsRunsChart}
{isSelectingJobsToCancel}
bind:selectedIds
bind:selectedWorkspace
on:select={() => {
runDrawer.openDrawer()
if (!isSelectingJobsToCancel) runDrawer.openDrawer()
}}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}