feat: add label and schedule filters to runs page

This commit is contained in:
Ruben Fiszel
2024-04-23 22:48:21 +02:00
parent 032de9c2a3
commit fcff457462
12 changed files with 263 additions and 67 deletions
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "CREATE INDEX CONCURRENTLY labeled_jobs_on_completed_jobs ON completed_job USING GIN ((result -> 'wm_label')) WHERE result ? 'wm_label';",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "8199fa82078d7fb8396f21a3f58e6169edb0b3a56cb50e03f9df4df511ff1b67"
}
@@ -0,0 +1,2 @@
-- Add down migration script here
DROP INDEX IF EXISTS labeled_jobs_on_completed_jobs;
@@ -0,0 +1 @@
-- Add up migration script here
+23
View File
@@ -5248,6 +5248,11 @@ paths:
in: query
schema:
type: boolean
- name: is_not_schedule
description: is not a scheduled job
in: query
schema:
type: boolean
responses:
"200":
description: All queued jobs
@@ -5333,6 +5338,7 @@ paths:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/OrderDesc"
- $ref: "#/components/parameters/CreatedBy"
- $ref: "#/components/parameters/Label"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
@@ -5362,6 +5368,11 @@ paths:
in: query
schema:
type: boolean
- name: is_not_schedule
description: is not a scheduled job
in: query
schema:
type: boolean
responses:
"200":
description: All completed jobs
@@ -5381,6 +5392,7 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/CreatedBy"
- $ref: "#/components/parameters/Label"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
@@ -5423,6 +5435,11 @@ paths:
in: query
schema:
type: boolean
- name: is_not_schedule
description: is not a scheduled job
in: query
schema:
type: boolean
responses:
"200":
description: All jobs
@@ -8032,6 +8049,12 @@ components:
in: query
schema:
type: string
Label:
name: label
description: mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string at key 'wm_label')
in: query
schema:
type: string
ParentJob:
name: parent_job
description:
+8
View File
@@ -136,6 +136,14 @@ impl Migrate for CustomMigrator {
migration.version,
migration.description
);
if migration.version == 20240422144808 {
tracing::info!(
"Special migration to add index concurrently on completed_job labels"
);
sqlx::query!(
"CREATE INDEX CONCURRENTLY labeled_jobs_on_completed_jobs ON completed_job USING GIN ((result -> 'wm_label')) WHERE result ? 'wm_label';"
).execute(&mut *self.inner).await?;
}
let r = self.inner.apply(migration).await;
tracing::info!("Finished applying migration {}", migration.version);
r
+25 -1
View File
@@ -585,6 +585,7 @@ fn generate_get_job_query(no_logs: bool, table: &str) -> String {
result,
deleted,
is_skipped,
result->>'wm_label' as label,
CASE WHEN result is null or pg_column_size(result) < 2000000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result"
} else {
"scheduled_for,
@@ -779,6 +780,8 @@ pub struct ListableCompletedJob {
pub tag: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Deserialize, Clone)]
@@ -839,6 +842,7 @@ pub struct ListQueueQuery {
pub all_workspaces: Option<bool>,
pub is_flow_step: Option<bool>,
pub has_null_parent: Option<bool>,
pub is_not_schedule: Option<bool>,
}
fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> SqlBuilder {
@@ -943,6 +947,10 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
sqlb.and_where_le("scheduled_for", "now()");
}
if lq.is_not_schedule.unwrap_or(false) {
sqlb.and_where("schedule_path IS null");
}
sqlb
}
@@ -1171,13 +1179,14 @@ async fn list_jobs(
"null as concurrent_limit",
"null as concurrency_time_window_s",
"priority",
"result->>'wm_label' as label",
],
))
} else {
None
};
let sql = if lq.success.is_none() {
let sql = if lq.success.is_none() && lq.label.is_none() {
let sqlq = list_queue_jobs_query(
&w_id,
&ListQueueQuery {
@@ -1203,6 +1212,7 @@ async fn list_jobs(
all_workspaces: lq.all_workspaces,
is_flow_step: lq.is_flow_step,
has_null_parent: lq.has_null_parent,
is_not_schedule: lq.is_not_schedule,
},
&[
"'QueuedJob' as typ",
@@ -1236,6 +1246,7 @@ async fn list_jobs(
"concurrent_limit",
"concurrency_time_window_s",
"priority",
"null as label",
],
);
@@ -1968,6 +1979,7 @@ struct UnifiedJob {
concurrent_limit: Option<i32>,
concurrency_time_window_s: Option<i32>,
priority: Option<i16>,
label: Option<String>,
}
impl<'a> From<UnifiedJob> for Job {
@@ -2005,6 +2017,7 @@ impl<'a> From<UnifiedJob> for Job {
mem_peak: uj.mem_peak,
tag: uj.tag,
priority: uj.priority,
label: uj.label,
}),
"QueuedJob" => Job::QueuedJob(QueuedJob {
workspace_id: uj.workspace_id,
@@ -3732,6 +3745,14 @@ fn list_completed_jobs_query(
sqlb.and_where("result @> ?".bind(&result.replace("'", "''")));
}
if let Some(label) = &lq.label {
sqlb.and_where("result->>'wm_label' = ?".bind(label));
}
if lq.is_not_schedule.unwrap_or(false) {
sqlb.and_where("schedule_path IS null");
}
sqlb
}
#[derive(Deserialize, Clone)]
@@ -3763,6 +3784,8 @@ pub struct ListCompletedQuery {
pub scheduled_for_before_now: Option<bool>,
pub all_workspaces: Option<bool>,
pub has_null_parent: Option<bool>,
pub label: Option<String>,
pub is_not_schedule: Option<bool>,
}
async fn list_completed_jobs(
@@ -3810,6 +3833,7 @@ async fn list_completed_jobs(
"mem_peak",
"tag",
"priority",
"result->>'wm_label' as label",
"'CompletedJob' as type",
],
)
+2
View File
@@ -243,6 +243,8 @@ pub struct CompletedJob {
pub tag: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl CompletedJob {
@@ -6,15 +6,15 @@
import { workspaceStore } from '$lib/stores'
import { tweened, type Tweened } from 'svelte/motion'
import { forLater } from '$lib/forLater'
export let jobs: Job[] | undefined
export let user: string | null
export let label: string | null
export let folder: string | null
export let path: string | null
export let success: 'success' | 'failure' | 'running' | undefined = undefined
export let isSkipped: boolean = false
export let hideSchedules: boolean = false
export let showSchedules: boolean = false
export let argFilter: string | undefined
export let resultFilter: string | undefined = undefined
export let schedulePath: string | undefined = undefined
@@ -39,12 +39,13 @@
$: jobKinds = computeJobKinds(jobKindsCat)
$: ($workspaceStore && loadJobsIntern(true)) ||
(path &&
label &&
success &&
isSkipped != undefined &&
jobKinds &&
user &&
folder &&
hideSchedules != undefined &&
showSchedules != undefined &&
allWorkspaces != undefined &&
argFilter != undefined &&
resultFilter != undefined)
@@ -107,6 +108,8 @@
running: success == 'running' ? true : undefined,
isSkipped: isSkipped ? undefined : false,
isFlowStep: jobKindsCat != 'all' ? false : undefined,
label: label === null || label === '' ? undefined : label,
isNotSchedule: showSchedules == false ? true : undefined,
args:
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
result:
@@ -141,12 +144,6 @@
try {
jobs = await fetchJobs(maxTs, minTs)
computeCompletedJobs()
if (hideSchedules && !schedulePath) {
jobs = jobs.filter(
(job) => !(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
)
}
} catch (err) {
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
console.error(JSON.stringify(err))
@@ -215,13 +212,6 @@
.forEach((x) => (jobs![jobs?.findIndex((y) => y.id == x.id)!] = x))
jobs = jobs
computeCompletedJobs()
if (hideSchedules && !schedulePath) {
jobs = jobs.filter(
(job) =>
!(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
)
}
}
loading = false
@@ -28,6 +28,7 @@
export let job: Job
export let selectedId: string | undefined = undefined
export let containerWidth: number = 0
export let containsLabel: boolean = false
let scheduleEditor: ScheduleEditor
</script>
@@ -170,6 +171,24 @@
{/if}
{/if}
</div>
{#if containsLabel}
<div class="w-3/12 flex justify-start">
{#if job && job?.['label']}
<div class="flex flex-row items-center gap-1">
<div class="text-xs">{job?.['label']}</div>
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterByLabel', job?.['label'])
}}
>
<ListFilter size={10} />
</Button>
</div>
{/if}
</div>
{/if}
<div class="w-3/12 flex justify-start">
{#if job && job.schedule_path}
<div class="flex flex-row items-center gap-1">
@@ -11,9 +11,11 @@
import Section from '../Section.svelte'
import CloseButton from '../common/CloseButton.svelte'
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
// Filters
export let path: string | null = null
export let label: string | null = null
export let success: 'running' | 'success' | 'failure' | undefined = undefined
export let isSkipped: boolean | undefined = undefined
export let argFilter: string
@@ -23,7 +25,6 @@
export let jobKindsCat: string
export let user: string | null = null
export let folder: string | null = null
export let hideSchedules: boolean | undefined = undefined
export let mobile: boolean = false
// Autocomplete data
@@ -32,10 +33,14 @@
export let folders: string[] = []
export let allWorkspaces = false
$: displayedLabel = label
let copyArgFilter = argFilter
let copyResultFilter = resultFilter
export let filterBy: 'path' | 'user' | 'folder' = 'path'
export let filterBy: 'path' | 'user' | 'folder' | 'label' = 'path'
const dispatch = createEventDispatcher()
let manuallySet = false
@@ -49,8 +54,13 @@
} else if (folder !== null && folder !== '' && filterBy !== 'folder') {
manuallySet = true
filterBy = 'folder'
} else if (label !== null && label !== '' && filterBy !== 'label') {
manuallySet = true
filterBy = 'label'
}
}
let labelTimeout: NodeJS.Timeout | undefined = undefined
</script>
<div class="flex gap-4">
@@ -75,6 +85,7 @@
path = null
user = null
folder = null
label = null
} else {
manuallySet = false
}
@@ -83,6 +94,7 @@
<ToggleButton value="path" label="Path" />
<ToggleButton value="user" label="User" />
<ToggleButton value="folder" label="Folder" />
<ToggleButton value="label" label="Label" />
</ToggleButtonGroup>
</div>
@@ -94,6 +106,7 @@
class="absolute top-2 right-2 z-50"
on:click={() => {
user = null
dispatch('reset')
}}
>
<X size={14} />
@@ -104,6 +117,12 @@
<span class="text-xs absolute -top-4">User</span>
<AutoComplete
create
onCreate={(user) => {
usernames.push(user)
return user
}}
createText="Press enter to use this value"
noInputStyles
items={usernames}
value={user}
@@ -123,6 +142,7 @@
class="absolute top-2 right-2 z-50"
on:click={() => {
folder = null
dispatch('reset')
}}
>
<X size={14} />
@@ -153,6 +173,7 @@
class="absolute top-2 right-2 z-50"
on:click={() => {
path = null
dispatch('reset')
}}
>
<X size={14} />
@@ -164,6 +185,12 @@
<span class="text-xs absolute -top-4">Path</span>
<AutoComplete
create
onCreate={(path) => {
paths.push(path)
return path
}}
createText="Press enter to use this value"
noInputStyles
items={paths}
value={path}
@@ -175,6 +202,45 @@
/>
</div>
{/key}
{:else if filterBy === 'label'}
{#key label}
<div class="relative">
{#if label}
<button
class="absolute top-2 right-2 z-50"
on:click={() => {
label = null
dispatch('reset')
}}
>
<X size={14} />
</button>
{/if}
<span class="text-xs absolute -top-4"
>Label <Tooltip
>Labels are values in the result of completed jobs at key 'wm_label' to easily
filter them</Tooltip
></span
>
<input
autofocus
type="text"
class="!h-[32px] py-1 !text-xs !w-64"
bind:value={displayedLabel}
on:keydown={(e) => {
if (labelTimeout) {
clearTimeout(labelTimeout)
}
labelTimeout = setTimeout(() => {
label = displayedLabel
}, 1000)
}}
/>
</div>
{/key}
{/if}
</div>
<div class="relative">
@@ -262,6 +328,7 @@
path = null
user = null
folder = null
label = null
} else {
manuallySet = false
}
@@ -411,10 +478,6 @@
</div>
</Label>
<Label label="Hide future scheduled jobs from schedules">
<Toggle size="xs" bind:checked={hideSchedules} />
</Label>
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
@@ -14,12 +14,17 @@
return job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
}
let containsLabel = false
function groupJobsByDay(jobs: Job[]): Record<string, Job[]> {
const groupedLogs: Record<string, Job[]> = {}
if (!jobs) return groupedLogs
let newContainsLabel = false
for (const job of jobs) {
if (job?.['label'] != undefined) {
newContainsLabel = true
}
const field: string | undefined = getTime(job)
if (field) {
const date = new Date(field)
@@ -38,6 +43,7 @@
groupedLogs[day].push(job)
}
}
containsLabel = newContainsLabel
for (const day in groupedLogs) {
groupedLogs[day].sort((a, b) => {
@@ -143,6 +149,9 @@
>
<div class="w-4/12 text-xs font-semibold">Timestamp</div>
<div class="w-4/12 text-xs font-semibold">Path</div>
{#if containsLabel}
<div class="w-3/12 text-xs font-semibold">Label</div>
{/if}
<div class="w-3/12 text-xs font-semibold">Triggered by</div>
</div>
@@ -165,6 +174,7 @@
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
{containsLabel}
job={jobOrDate.job}
{selectedId}
on:select={() => {
@@ -172,6 +182,7 @@
selectedId = jobOrDate.job.id
dispatch('select')
}}
on:filterByLabel
on:filterByPath
on:filterByUser
on:filterByFolder
@@ -30,6 +30,7 @@
import { twMerge } from 'tailwind-merge'
import ManuelDatePicker from '$lib/components/runs/ManuelDatePicker.svelte'
import JobLoader from '$lib/components/runs/JobLoader.svelte'
import { Calendar } from 'lucide-svelte'
let jobs: Job[] | undefined
let selectedId: string | undefined = undefined
@@ -40,6 +41,7 @@
let path: string | null = $page.params.path
let user: string | null = $page.url.searchParams.get('user')
let folder: string | null = $page.url.searchParams.get('folder')
let label: string | null = $page.url.searchParams.get('label')
// Rest of filters handled by RunsFilter
let success: 'running' | 'success' | 'failure' | undefined = ($page.url.searchParams.get(
'success'
@@ -49,10 +51,12 @@
? $page.url.searchParams.get('is_skipped') == 'true'
: false
let hideSchedules: boolean | undefined =
$page.url.searchParams.get('hide_scheduled') != undefined
? $page.url.searchParams.get('hide_scheduled') == 'true'
: false
let showSchedules: boolean | undefined =
$page.url.searchParams.get('show_schedules') != undefined
? $page.url.searchParams.get('show_schedules') == 'true'
: localStorage.getItem('show_schedules_in_run') == 'false'
? false
: true
let argFilter: any = $page.url.searchParams.get('arg')
? JSON.parse(decodeURIComponent($page.url.searchParams.get('arg') ?? '{}'))
@@ -88,11 +92,12 @@
let manualDatePicker: ManuelDatePicker
$: (user ||
label ||
folder ||
path ||
success !== undefined ||
isSkipped ||
hideSchedules ||
showSchedules ||
argFilter ||
resultFilter ||
schedulePath ||
@@ -130,10 +135,10 @@
searchParams.delete('is_skipped')
}
if (hideSchedules) {
searchParams.set('hide_scheduled', hideSchedules.toString())
if (showSchedules) {
searchParams.set('show_schedules', showSchedules.toString())
} else {
searchParams.delete('hide_scheduled')
searchParams.delete('show_schedules')
}
if (allWorkspaces && $workspaceStore == 'admins') {
@@ -178,12 +183,18 @@
searchParams.delete('max_ts')
}
if (label) {
searchParams.set('label', label)
} else {
searchParams.delete('label')
}
let newPath = path ? `/${path}` : '/'
let newUrl = `/runs${newPath}?${searchParams.toString()}`
history.replaceState(history.state, '', newUrl.toString())
}
function reloadLogsWithoutFilterError() {
function reloadJobsWithoutFilterError() {
if (resultError == '' && argError == '') {
filterTimeout && clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
@@ -226,6 +237,34 @@
loadFolders()
loadPaths()
}
function filterByPath(e: CustomEvent<string>) {
path = e.detail
user = null
folder = null
label = null
}
function filterByUser(e: CustomEvent<string>) {
path = null
folder = null
user = e.detail
label = null
}
function filterByFolder(e: CustomEvent<string>) {
path = null
user = null
folder = e.detail
label = null
}
function filterByLabel(e: CustomEvent<string>) {
path = null
user = null
folder = null
label = e.detail
}
</script>
<JobLoader
@@ -234,11 +273,12 @@
{user}
{folder}
{path}
{label}
{success}
{isSkipped}
{argFilter}
{resultFilter}
{hideSchedules}
{showSchedules}
{schedulePath}
{jobKindsCat}
computeMinAndMax={manualDatePicker?.computeMinMax}
@@ -304,6 +344,7 @@
bind:isSkipped
bind:user
bind:folder
bind:label
bind:path
bind:success
bind:argFilter
@@ -311,9 +352,8 @@
bind:argError
bind:resultError
bind:jobKindsCat
bind:hideSchedules
bind:allWorkspaces
on:change={reloadLogsWithoutFilterError}
on:change={reloadJobsWithoutFilterError}
{usernames}
{folders}
{paths}
@@ -346,6 +386,18 @@
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
>
</div>
<div class="relative flex gap-2 items-center pr-8 w-40">
<Toggle
size="xs"
bind:checked={showSchedules}
on:change={() => {
localStorage.setItem('show_schedules_in_run', showSchedules ? 'true' : 'false')
}}
/>
<span class="text-xs absolute -top-4">Jobs from schedules</span>
<Calendar size={16} />
</div>
<div class="flex flex-row gap-1 w-full max-w-lg">
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
@@ -415,21 +467,10 @@
{jobs}
bind:selectedId
bind:selectedWorkspace
on:filterByPath={(e) => {
user = null
folder = null
path = e.detail
}}
on:filterByUser={(e) => {
path = null
folder = null
user = e.detail
}}
on:filterByFolder={(e) => {
path = null
user = null
folder = e.detail
}}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
/>
{:else}
<div class="gap-1 flex flex-col">
@@ -480,10 +521,9 @@
bind:resultFilter
bind:argError
bind:resultError
bind:hideSchedules
bind:allWorkspaces
mobile={true}
on:change={reloadLogsWithoutFilterError}
on:change={reloadJobsWithoutFilterError}
/>
</div>
</div>
@@ -514,6 +554,18 @@
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
>
</div>
<div class="relative flex gap-2 items-center pr-8 w-40">
<Toggle
size="xs"
bind:checked={showSchedules}
on:change={() => {
localStorage.setItem('show_schedules_in_run', showSchedules ? 'true' : 'false')
}}
/>
<span class="text-xs absolute -top-4">Schedules</span>
<Calendar size={16} />
</div>
<div class="flex flex-row gap-1 w-full max-w-lg items-center">
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
@@ -583,21 +635,10 @@
on:select={() => {
runDrawer.openDrawer()
}}
on:filterByPath={(e) => {
user = null
folder = null
path = e.detail
}}
on:filterByUser={(e) => {
path = null
folder = null
user = e.detail
}}
on:filterByFolder={(e) => {
path = null
user = null
folder = e.detail
}}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
/>
</div>
</div>