feat: improve runs page + add all workspaces to admins runs page

This commit is contained in:
Ruben Fiszel
2024-02-13 10:01:03 +01:00
parent 8bb9ed646b
commit fa9b9a0839
17 changed files with 497 additions and 275 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT coalesce(COUNT(*), 0) as \"database_length!\" FROM queue WHERE workspace_id = $1 AND scheduled_for <= now() AND running = false",
"query": "SELECT coalesce(COUNT(*), 0) as \"database_length!\" FROM queue WHERE (workspace_id = $1 OR $2) AND scheduled_for <= now() AND running = false",
"describe": {
"columns": [
{
@@ -11,12 +11,13 @@
],
"parameters": {
"Left": [
"Text"
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "9da1bf41c433c1f781bcc8ea5d86fd796b7ff92434f5ed4682c4445a02c2c720"
"hash": "8481d1bd91b7d23f946b4cf0d312acfc0ed4973309b4860e93882a37a21a0bd0"
}
+15
View File
@@ -4814,6 +4814,11 @@ paths:
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/ResultFilter"
- $ref: "#/components/parameters/Tag"
- name: all_workspaces
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
responses:
"200":
description: All queued jobs
@@ -4832,6 +4837,11 @@ paths:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: all_workspaces
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
responses:
"200":
description: queue count
@@ -4965,6 +4975,11 @@ paths:
in: query
schema:
type: boolean
- name: all_workspaces
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
responses:
"200":
description: All jobs
+1 -1
View File
@@ -82,7 +82,7 @@ pub struct ListableApp {
pub extra_perms: serde_json::Value,
pub execution_mode: String,
pub starred: bool,
pub edited_at: chrono::DateTime<chrono::Utc>,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
+22 -4
View File
@@ -663,6 +663,7 @@ pub struct ListQueueQuery {
pub args: Option<String>,
pub tag: Option<String>,
pub scheduled_for_before_now: Option<bool>,
pub all_workspaces: Option<bool>,
}
fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> SqlBuilder {
@@ -670,9 +671,12 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
.fields(fields)
.order_by("created_at", lq.order_desc.unwrap_or(true))
.limit(1000)
.and_where_eq("workspace_id", "?".bind(&w_id))
.clone();
if w_id != "admins" || !lq.all_workspaces.is_some_and(|x| x) {
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
}
if let Some(ps) = &lq.script_path_start {
sqlb.and_where_like_left("script_path", "?".bind(ps));
}
@@ -765,6 +769,7 @@ struct ListableQueuedJob {
pub suspend: Option<i32>,
pub tag: String,
pub priority: Option<i16>,
pub workspace_id: String,
}
async fn list_queue_jobs(
@@ -794,6 +799,7 @@ async fn list_queue_jobs(
"suspend",
"tag",
"priority",
"workspace_id",
],
)
.sql()?;
@@ -857,15 +863,22 @@ struct QueueStats {
database_length: i64,
}
#[derive(Deserialize)]
pub struct CountQueueJobsQuery {
all_workspaces: Option<bool>,
}
async fn count_queue_jobs(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(cq): Query<CountQueueJobsQuery>,
) -> error::JsonResult<QueueStats> {
Ok(Json(
sqlx::query_as!(
QueueStats,
"SELECT coalesce(COUNT(*), 0) as \"database_length!\" FROM queue WHERE workspace_id = $1 AND scheduled_for <= now() AND running = false",
w_id
"SELECT coalesce(COUNT(*), 0) as \"database_length!\" FROM queue WHERE (workspace_id = $1 OR $2) AND scheduled_for <= now() AND running = false",
w_id,
w_id == "admins" && cq.all_workspaces.unwrap_or(false),
)
.fetch_one(&db)
.await?,
@@ -972,6 +985,7 @@ async fn list_jobs(
tag: lq.tag,
schedule_path: lq.schedule_path,
scheduled_for_before_now: lq.scheduled_for_before_now,
all_workspaces: lq.all_workspaces,
},
&[
"'QueuedJob' as typ",
@@ -3135,11 +3149,14 @@ fn list_completed_jobs_query(
let mut sqlb = SqlBuilder::select_from("completed_job")
.fields(fields)
.order_by("created_at", lq.order_desc.unwrap_or(true))
.and_where_eq("workspace_id", "?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
if w_id != "admins" || !lq.all_workspaces.is_some_and(|x| x) {
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
}
if let Some(p) = &lq.schedule_path {
sqlb.and_where_eq("schedule_path", "?".bind(p));
}
@@ -3229,6 +3246,7 @@ pub struct ListCompletedQuery {
pub result: Option<String>,
pub tag: Option<String>,
pub scheduled_for_before_now: Option<bool>,
pub all_workspaces: Option<bool>,
}
async fn list_completed_jobs(
+1 -1
View File
@@ -528,7 +528,7 @@ export async function ignoreF(wmillconf: {
.join(", ");
log.info(
colors.gray(
`(Deprecated, use wmill.conf/includes instead) Using .wmillignore file (${condensed})`
`(Deprecated, use wmill.yaml/includes instead) Using .wmillignore file (${condensed})`
)
);
ign = gitignore_parser.compile(ignoreContent);
+50 -10
View File
@@ -20,6 +20,8 @@
export let jobs: CompletedJob[] | undefined = []
export let maxIsNow: boolean = false
export let minTimeSet: string | undefined = undefined
export let maxTimeSet: string | undefined = undefined
const dispatch = createEventDispatcher()
@@ -100,25 +102,56 @@
let minTime = addSeconds(new Date(), -300)
let maxTime = getDbClockNow()
$: computeMinMaxTime(jobs)
$: computeMinMaxTime(jobs, minTimeSet, maxTimeSet)
function computeMinMaxTime(jobs: CompletedJob[] | undefined) {
if (jobs == undefined || jobs?.length == 0) {
minTime = addSeconds(new Date(), -300)
maxTime = getDbClockNow()
function minJobTime(jobs: CompletedJob[]): Date {
let min: Date = new Date(jobs[0].started_at)
for (const job of jobs) {
if (new Date(job.started_at) < min) {
min = new Date(job.started_at)
}
}
return min
}
function maxJobTime(jobs: CompletedJob[]): Date {
let max: Date = new Date(jobs[0].started_at)
for (const job of jobs) {
if (new Date(job.started_at) > max) {
max = new Date(job.started_at)
}
}
return max
}
function computeMinMaxTime(
jobs: CompletedJob[] | undefined,
minTimeSet: string | undefined,
maxTimeSet: string | undefined
) {
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return
}
const maxJob = maxIsNow ? getDbClockNow() : new Date(jobs?.[0].started_at)
const minJob = new Date(jobs?.[jobs?.length - 1].started_at)
if (jobs == undefined || jobs?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? getDbClockNow()
return
}
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(jobs)
const minJob = minJobTime(jobs)
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = addSeconds(minJob, -diff)
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxIsNow) {
maxTime = maxJob
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = addSeconds(maxJob, diff)
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
}
@@ -168,6 +201,13 @@
} as any
</script>
<!-- {JSON.stringify(minTime)}
{JSON.stringify(maxTime)}
{JSON.stringify(jobs?.map((x) => x.started_at))} -->
<!-- {minTime}
{maxTime} -->
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
<div class="relative max-h-40">
<Scatter {data} options={scatterOptions} />
</div>
@@ -153,9 +153,9 @@
success="running"
argFilter={undefined}
bind:loading
synUrl={false}
syncQueuedRunsCount={false}
refreshRate={10000}
computeMinAndMax={undefined}
/>
<div class="min-w-[300px] h-full">
@@ -181,7 +181,7 @@
errorIteration += 1
if (errorIteration == 5) {
notfound = true
await clearCurrentJob()
job = undefined
}
console.warn(err)
}
+88 -158
View File
@@ -1,21 +1,11 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
JobService,
Job,
CompletedJob,
ScriptService,
FlowService,
UserService,
FolderService
} from '$lib/gen'
import { JobService, Job, CompletedJob } from '$lib/gen'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import { tweened, type Tweened } from 'svelte/motion'
import { goto } from '$app/navigation'
import { forLater } from '$lib/forLater'
export let jobs: Job[] | undefined
@@ -34,65 +24,35 @@
export let jobKinds: string = ''
export let queue_count: Tweened<number> | undefined = undefined
export let autoRefresh: boolean = true
export let paths: string[] = []
export let usernames: string[] = []
export let folders: string[] = []
export let completedJobs: CompletedJob[] | undefined = undefined
export let argError = ''
export let resultError = ''
export let loading: boolean = false
export let synUrl: boolean = true
export let refreshRate = 5000
export let syncQueuedRunsCount: boolean = true
export let allWorkspaces: boolean = false
export let computeMinAndMax: (() => { minTs: string; maxTs: string } | undefined) | undefined
let mounted: boolean = false
let intervalId: NodeJS.Timeout | undefined
let sync = true
// This reactive statement is used to sync the url with the current state of the filters
$: if (synUrl) {
let searchParams = new URLSearchParams()
user && searchParams.set('user', user)
folder && searchParams.set('folder', folder)
if (success !== undefined) {
searchParams.set('success', success.toString())
}
if (isSkipped) {
searchParams.set('is_skipped', isSkipped.toString())
}
if (hideSchedules) {
searchParams.set('hide_scheduled', hideSchedules.toString())
}
// ArgFilter is an object. Encode it to a string
argFilter && searchParams.set('arg', encodeURIComponent(JSON.stringify(argFilter)))
resultFilter && searchParams.set('result', encodeURIComponent(JSON.stringify(resultFilter)))
schedulePath && searchParams.set('schedule_path', schedulePath)
jobKindsCat != 'runs' && searchParams.set('job_kinds', jobKindsCat)
minTs && searchParams.set('min_ts', minTs)
maxTs && searchParams.set('max_ts', maxTs)
let newPath = path ? `/${path}` : '/'
let newUrl = `/runs${newPath}?${searchParams.toString()}`
goto(newUrl)
}
$: jobKinds = computeJobKinds(jobKindsCat)
$: ($workspaceStore && loadJobs()) ||
(path && success && isSkipped && jobKinds && user && folder && minTs && maxTs && hideSchedules)
$: ($workspaceStore && loadJobsIntern(true)) ||
(path &&
success &&
isSkipped != undefined &&
jobKinds &&
user &&
folder &&
hideSchedules != undefined &&
allWorkspaces != undefined)
$: if (mounted && !intervalId && autoRefresh) {
$: if (!intervalId && autoRefresh) {
intervalId = setInterval(syncer, refreshRate)
}
$: if (mounted && intervalId && !autoRefresh) {
$: if (intervalId && !autoRefresh) {
clearInterval(intervalId)
intervalId = undefined
}
@@ -126,26 +86,41 @@
jobKinds,
success: success == 'success' ? true : success == 'failure' ? false : undefined,
running: success == 'running' ? true : undefined,
isSkipped,
isSkipped: isSkipped ? true : undefined,
isFlowStep: jobKindsCat != 'all' ? false : undefined,
args:
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
result:
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
? resultFilter
: undefined
: undefined,
allWorkspaces: allWorkspaces ? true : undefined
})
}
export async function loadJobs(shouldGetCount?: boolean): Promise<void> {
export async function loadJobs(
nMinTs: string | undefined,
nMaxTs: string | undefined,
reset: boolean,
shouldGetCount?: boolean
): Promise<void> {
minTs = nMinTs
maxTs = nMaxTs
if (reset) {
jobs = undefined
completedJobs = undefined
intervalId && clearInterval(intervalId)
intervalId = setInterval(syncer, refreshRate)
}
await loadJobsIntern(shouldGetCount)
}
async function loadJobsIntern(shouldGetCount?: boolean): Promise<void> {
if (shouldGetCount) {
getCount()
}
loading = true
try {
jobs = await fetchJobs(maxTs, minTs)
computeCompletedJobs()
if (hideSchedules && !schedulePath) {
@@ -161,7 +136,8 @@
}
async function getCount() {
const qc = (await JobService.getQueueCount({ workspace: $workspaceStore! })).database_length
const qc = (await JobService.getQueueCount({ workspace: $workspaceStore!, allWorkspaces }))
.database_length
if (queue_count) {
queue_count.set(qc)
} else {
@@ -170,106 +146,69 @@
}
async function syncer() {
if (syncQueuedRunsCount) {
getCount()
}
if (sync) {
if (syncQueuedRunsCount) {
getCount()
}
if (sync && jobs && maxTs == undefined) {
if (success == 'running') {
loadJobs()
} else {
let ts: string | undefined = undefined
let cursor = 0
if (computeMinAndMax) {
const ts = computeMinAndMax()
if (ts) {
minTs = ts.minTs
maxTs = ts.maxTs
if (maxTs != undefined) {
loadJobsIntern(false)
}
}
}
while (cursor < jobs.length && minTs == undefined) {
let invCursor = jobs.length - 1 - cursor
let isQueuedJob =
cursor == jobs?.length - 1 || jobs[invCursor].type == Job.type.QUEUED_JOB
if (isQueuedJob) {
if (cursor > 0) {
const date = new Date(jobs[invCursor + 1]?.created_at!)
date.setMilliseconds(date.getMilliseconds() + 1)
ts = date.toISOString()
if (jobs && maxTs == undefined) {
if (success == 'running') {
loadJobsIntern(false)
} else {
let ts: string | undefined = undefined
let cursor = 0
while (cursor < jobs.length && minTs == undefined) {
let invCursor = jobs.length - 1 - cursor
let isQueuedJob =
cursor == jobs?.length - 1 || jobs[invCursor].type == Job.type.QUEUED_JOB
if (isQueuedJob) {
if (cursor > 0) {
const date = new Date(jobs[invCursor + 1]?.created_at!)
date.setMilliseconds(date.getMilliseconds() + 1)
ts = date.toISOString()
}
break
}
break
cursor++
}
cursor++
}
loading = true
loading = true
const newJobs = await fetchJobs(maxTs, minTs ?? ts)
if (newJobs && newJobs.length > 0 && jobs) {
const oldJobs = jobs?.map((x) => x.id)
jobs = newJobs.filter((x) => !oldJobs.includes(x.id)).concat(jobs)
newJobs
.filter((x) => oldJobs.includes(x.id))
.forEach((x) => (jobs![jobs?.findIndex((y) => y.id == x.id)!] = x))
jobs = jobs
computeCompletedJobs()
const newJobs = await fetchJobs(maxTs, minTs ?? ts)
if (newJobs && newJobs.length > 0 && jobs) {
const oldJobs = jobs?.map((x) => x.id)
jobs = newJobs.filter((x) => !oldJobs.includes(x.id)).concat(jobs)
newJobs
.filter((x) => oldJobs.includes(x.id))
.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))
)
if (hideSchedules && !schedulePath) {
jobs = jobs.filter(
(job) =>
!(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
)
}
}
}
loading = false
loading = false
}
}
}
}
function updateFiltersFromURL() {
path = $page.params.path
user = $page.url.searchParams.get('user')
folder = $page.url.searchParams.get('folder')
success = ($page.url.searchParams.get('success') ?? undefined) as
| 'success'
| 'failure'
| 'running'
| undefined
isSkipped =
$page.url.searchParams.get('is_skipped') != undefined
? $page.url.searchParams.get('is_skipped') == 'true'
: false
hideSchedules =
$page.url.searchParams.get('hide_scheduled') != undefined
? $page.url.searchParams.get('hide_scheduled') == 'true'
: false
argFilter = $page.url.searchParams.get('arg')
? JSON.parse(decodeURIComponent($page.url.searchParams.get('arg') ?? '{}'))
: undefined
resultFilter = $page.url.searchParams.get('result')
? JSON.parse(decodeURIComponent($page.url.searchParams.get('result') ?? '{}'))
: undefined
schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
// Handled on the main page
minTs = $page.url.searchParams.get('min_ts') ?? undefined
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
}
async function loadFolders(): Promise<void> {
folders = await FolderService.listFolders({
workspace: $workspaceStore!
}).then((x) => x.map((y) => y.name))
}
async function loadPaths() {
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
paths = npaths_scripts.concat(npaths_flows).sort()
}
function computeCompletedJobs() {
completedJobs =
jobs?.filter((x) => x.type == 'CompletedJob').map((x) => x as CompletedJob) ?? []
@@ -284,18 +223,9 @@
}
onMount(() => {
mounted = true
loadPaths()
loadUsernames()
loadFolders()
intervalId = setInterval(syncer, refreshRate)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('popstate', updateFiltersFromURL)
return () => {
window.removeEventListener('popstate', updateFiltersFromURL)
window.removeEventListener('visibilitychange', onVisibilityChange)
}
})
@@ -10,9 +10,11 @@
import FlowProgressBar from '../flows/FlowProgressBar.svelte'
import FlowStatusViewer from '../FlowStatusViewer.svelte'
import DurationMs from '../DurationMs.svelte'
import { workspaceStore } from '$lib/stores'
export let id: string
export let blankLink = false
export let workspace: string | undefined
let job: Job | undefined = undefined
let watchJob: (id: string) => Promise<void>
@@ -34,8 +36,7 @@
let viewTab = 'result'
</script>
<TestJobLoader bind:job={currentJob} bind:watchJob on:done={onDone} />
<TestJobLoader workspaceOverride={workspace} bind:job={currentJob} bind:watchJob on:done={onDone} />
<div class="p-4 flex flex-col gap-2 items-start h-full">
{#if job}
<div class="flex gap-2">
@@ -52,6 +53,11 @@
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
{#if workspace && $workspaceStore != workspace}
<Badge large>
{workspace}
</Badge>
{/if}
</div>
<a
href="/run/{job?.id}?workspace={job?.workspace_id}"
@@ -68,7 +74,9 @@
<JobArgs args={job?.args} />
</div>
<span class="font-semibold text-xs leading-6">Results</span>
{#if job?.type === Job.type.COMPLETED_JOB}
<span class="font-semibold text-xs leading-6">Results</span>
{/if}
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
<div class="text-sm font-semibold text-tertiary mb-1">
@@ -8,61 +8,74 @@
export let loading: boolean = false
export let selectedManualDate = 0
const manualDates = [
export function computeMinMax(): { minTs: string; maxTs: string } | undefined {
return manualDates[selectedManualDate].computeMinMax()
}
const manualDates: {
label: string
computeMinMax: () => { minTs: string; maxTs: string } | undefined
}[] = [
{
label: 'Last 1000 runs',
setMinMax: () => {
minTs = undefined
maxTs = undefined
computeMinMax: () => {
return undefined
}
},
{
label: 'Within 30 seconds',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 30 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 30 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last minute',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last 5 minutes',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last 30 minutes',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 30 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 30 * 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last 24 hours',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last 7 days',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
},
{
label: 'Within last month',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
computeMinMax: () => {
let minTs = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
}
]
@@ -75,7 +88,11 @@
size="xs"
wrapperClasses="border rounded-md"
on:click={() => {
manualDates[selectedManualDate].setMinMax()
const ts = computeMinMax()
if (ts) {
minTs = ts.minTs
maxTs = ts.maxTs
}
dispatch('loadJobs')
}}
dropdownItems={[
@@ -83,7 +100,14 @@
label: d.label,
onClick: () => {
selectedManualDate = i
d.setMinMax()
const ts = d.computeMinMax()
if (ts) {
minTs = ts.minTs
maxTs = ts.maxTs
} else {
minTs = undefined
maxTs = undefined
}
dispatch('loadJobs')
}
}))
@@ -6,6 +6,7 @@
import { displayDate } from '$lib/utils'
let jobs: QueuedJob[] | undefined = undefined
export let allWorkspaces: boolean = false
getQueuedJobs()
async function getQueuedJobs() {
@@ -13,7 +14,8 @@
workspace: $workspaceStore ?? '',
scheduledForBeforeNow: true,
suspended: false,
running: false
running: false,
allWorkspaces
})
}
</script>
@@ -35,7 +37,7 @@
<div class="flex">
<a
target="_blank"
href={`/run/${job.id}?workspace=${$workspaceStore}`}
href={`/run/${job.id}?workspace=${job.workspace_id}`}
class="flex flex-row gap-2 items-center font-mono mr-8"
>{job.id} <ExternalLink size={10} />
</a>
@@ -56,7 +56,6 @@
)}
style="width: {containerWidth}px"
on:click={() => {
selectedId = job.id
dispatch('select')
}}
>
@@ -10,6 +10,7 @@
import Label from '../Label.svelte'
import Section from '../Section.svelte'
import CloseButton from '../common/CloseButton.svelte'
import { workspaceStore } from '$lib/stores'
// Filters
export let path: string | null = null
@@ -29,6 +30,7 @@
export let paths: string[] = []
export let usernames: string[] = []
export let folders: string[] = []
export let allWorkspaces = false
let copyArgFilter = argFilter
let copyResultFilter = resultFilter
@@ -54,6 +56,16 @@
<div class="flex gap-4">
{#if !mobile}
<div class="flex gap-2">
{#if $workspaceStore == 'admins'}
<div class="relative">
<span class="text-xs absolute -top-4">Workspaces</span>
<ToggleButtonGroup bind:selected={allWorkspaces}>
<ToggleButton value={false} label="Admins" />
<ToggleButton value={true} label="All" />
</ToggleButtonGroup>
</div>
{/if}
<div class="relative">
<span class="text-xs absolute -top-4">Filter by</span>
<ToggleButtonGroup
@@ -4,6 +4,7 @@
import Popup from '../common/popup/Popup.svelte'
export let queue_count: Tweened<number> | undefined = undefined
export let allWorkspaces: boolean = false
</script>
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline">
@@ -15,7 +16,7 @@
<svelte:fragment slot="button">
<span class="text-2xs truncate">jobs</span>
</svelte:fragment>
<QueuePopover />
<QueuePopover {allWorkspaces} />
</Popup>
{/if}
</div>
@@ -2,11 +2,12 @@
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from 'svelte-tiny-virtual-list'
import { onMount } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
//import InfiniteLoading from 'svelte-infinite-loading'
export let jobs: Job[] = []
export let jobs: Job[] | undefined = undefined
export let selectedId: string | undefined = undefined
export let selectedWorkspace: string | undefined = undefined
// const loadMoreQuantity: number = 100
function getTime(job: Job): string | undefined {
@@ -56,7 +57,7 @@
return sortedLogs
}
$: groupedJobs = groupJobsByDay(jobs)
$: groupedJobs = jobs ? groupJobsByDay(jobs) : undefined
type FlatJobs =
| {
@@ -81,14 +82,14 @@
return flatJobs
}
$: flatJobs = flattenJobs(groupedJobs)
$: flatJobs = groupedJobs ? flattenJobs(groupedJobs) : undefined
let stickyIndices: number[] = []
$: {
stickyIndices = []
let index = 0
for (const entry of flatJobs) {
for (const entry of flatJobs ?? []) {
if (entry.type === 'date') {
stickyIndices.push(index)
}
@@ -117,17 +118,29 @@
}
*/
onMount(() => {
function computeHeight() {
tableHeight = document.querySelector('#runs-table-wrapper')!.parentElement?.clientHeight ?? 0
}
onMount(() => {
computeHeight()
})
const dispatch = createEventDispatcher()
</script>
<div class="divide-y min-w-[640px]" id="runs-table-wrapper" bind:clientWidth={containerWidth}>
<svelte:window on:resize={() => computeHeight()} />
<div
class="divide-y min-w-[640px] h-full"
id="runs-table-wrapper"
bind:clientWidth={containerWidth}
>
<div
class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4"
bind:clientHeight={header}
>
<div class="w-1/12" />
<div class="w-1/12 text-2xs"
>{jobs?.length == 1000 ? '1000+' : jobs ? jobs.length.toString() : '...'} jobs</div
>
<div class="w-4/12 text-xs font-semibold">Timestamp</div>
<div class="w-4/12 text-xs font-semibold">Path</div>
<div class="w-3/12 text-xs font-semibold">Triggered by</div>
@@ -136,44 +149,48 @@
<VirtualList
width="100%"
height={tableHeight - header}
itemCount={flatJobs.length}
itemCount={flatJobs?.length ?? 3}
itemSize={42}
{stickyIndices}
>
<div slot="item" let:index let:style {style} class="w-full">
{@const jobOrDate = flatJobs[index]}
{#if flatJobs}
{@const jobOrDate = flatJobs[index]}
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
{jobOrDate.date}
</div>
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
{jobOrDate.date}
</div>
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
job={jobOrDate.job}
{selectedId}
on:select={() => {
selectedWorkspace = jobOrDate.job.workspace_id
selectedId = jobOrDate.job.id
dispatch('select')
}}
on:filterByPath
on:filterByUser
on:filterByFolder
{containerWidth}
/>
</div>
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
job={jobOrDate.job}
bind:selectedId
on:select
on:filterByPath
on:filterByUser
on:filterByFolder
{containerWidth}
/>
</div>
{JSON.stringify(jobOrDate)}
{/if}
{:else}
{JSON.stringify(jobOrDate)}
<div class="flex flex-row items-center h-full w-full">
<div class="w-1/12 text-2xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-3/12 text-xs">...</div>
</div>
{/if}
</div>
<!-- <div slot="footer">
<InfiniteLoading on:infinite={infiniteHandler}>
<div slot="noMore">
<div class="text-center text-xs text-secondary p-2">
Reached the limit of {MAX_ITEMS} jobs. Please refine your search using filters.
</div>
</div>
</InfiniteLoading>
</div> -->
</VirtualList>
</div>
{#if jobs?.length == 0}
@@ -1,5 +1,13 @@
<script lang="ts">
import { JobService, Job, CompletedJob } from '$lib/gen'
import {
JobService,
Job,
CompletedJob,
UserService,
FolderService,
ScriptService,
FlowService
} from '$lib/gen'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
@@ -25,6 +33,7 @@
let jobs: Job[] | undefined
let selectedId: string | undefined = undefined
let selectedWorkspace: string | undefined = undefined
// All Filters
// Filter by
@@ -57,6 +66,8 @@
let maxTs = $page.url.searchParams.get('max_ts') ?? undefined
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
let jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
let allWorkspaces = $page.url.searchParams.get('all_workspaces') == 'true' ?? false
let queue_count: Tweened<number> | undefined = undefined
let jobKinds: string | undefined = undefined
let loading: boolean = false
@@ -74,11 +85,109 @@
let innerWidth = window.innerWidth
let jobLoader: JobLoader | undefined = undefined
let manualDatePicker: ManuelDatePicker
$: (user ||
folder ||
path ||
success !== undefined ||
isSkipped ||
hideSchedules ||
argFilter ||
resultFilter ||
schedulePath ||
jobKindsCat ||
minTs ||
maxTs ||
allWorkspaces ||
$workspaceStore) &&
setQuery()
function setQuery() {
let searchParams = new URLSearchParams()
if (user) {
searchParams.set('user', user)
} else {
searchParams.delete('user')
}
if (folder) {
searchParams.set('folder', folder)
} else {
searchParams.delete('folder')
}
if (success !== undefined) {
searchParams.set('success', success.toString())
} else {
searchParams.delete('success')
}
if (isSkipped) {
searchParams.set('is_skipped', isSkipped.toString())
} else {
searchParams.delete('is_skipped')
}
if (hideSchedules) {
searchParams.set('hide_scheduled', hideSchedules.toString())
} else {
searchParams.delete('hide_scheduled')
}
if (allWorkspaces && $workspaceStore == 'admins') {
searchParams.set('all_workspaces', allWorkspaces.toString())
searchParams.set('workspace', 'admins')
} else {
searchParams.delete('all_workspaces')
}
// ArgFilter is an object. Encode it to a string
if (argFilter) {
searchParams.set('arg', encodeURIComponent(JSON.stringify(argFilter)))
} else {
searchParams.delete('arg')
}
if (resultFilter) {
searchParams.set('result', encodeURIComponent(JSON.stringify(resultFilter)))
} else {
searchParams.delete('result')
}
if (schedulePath) {
searchParams.set('schedule_path', schedulePath)
} else {
searchParams.delete('schedule_path')
}
if (jobKindsCat != 'runs') {
searchParams.set('job_kinds', jobKindsCat)
} else {
searchParams.delete('job_kinds')
}
if (minTs) {
searchParams.set('min_ts', minTs)
} else {
searchParams.delete('min_ts')
}
if (maxTs) {
searchParams.set('max_ts', maxTs)
} else {
searchParams.delete('max_ts')
}
let newPath = path ? `/${path}` : '/'
let newUrl = `/runs${newPath}?${searchParams.toString()}`
history.replaceState(history.state, '', newUrl.toString())
}
function reloadLogsWithoutFilterError() {
if (resultError == '' && argError == '') {
filterTimeout && clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
jobLoader?.loadJobs(true)
jobLoader?.loadJobs(minTs, maxTs, true)
}, 2000)
}
}
@@ -92,32 +201,54 @@
completedJobs = undefined
selectedManualDate = 0
selectedId = undefined
jobLoader?.loadJobs(true)
selectedWorkspace = undefined
jobLoader?.loadJobs(minTs, maxTs, true)
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
}
async function loadFolders(): Promise<void> {
folders = await FolderService.listFolders({
workspace: $workspaceStore!
}).then((x) => x.map((y) => y.name))
}
async function loadPaths() {
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
paths = npaths_scripts.concat(npaths_flows).sort()
}
$: if ($workspaceStore) {
loadUsernames()
loadFolders()
loadPaths()
}
</script>
<JobLoader
{allWorkspaces}
bind:jobs
bind:user
bind:folder
bind:path
bind:success
bind:isSkipped
bind:argFilter
bind:resultFilter
bind:schedulePath
bind:jobKindsCat
{user}
{folder}
{path}
{success}
{isSkipped}
{argFilter}
{resultFilter}
{schedulePath}
{jobKindsCat}
computeMinAndMax={manualDatePicker?.computeMinMax}
bind:minTs
bind:maxTs
bind:jobKinds
{jobKinds}
bind:queue_count
bind:autoRefresh
bind:paths
bind:usernames
bind:folders
{autoRefresh}
bind:completedJobs
bind:argError
bind:resultError
{argError}
{resultError}
bind:loading
bind:this={jobLoader}
/>
@@ -129,7 +260,7 @@
on:confirmed={async () => {
cancelAllJobs = false
let uuids = await JobService.cancelAll({ workspace: $workspaceStore ?? '' })
jobLoader?.loadJobs(true)
jobLoader?.loadJobs(minTs, maxTs, true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
}}
on:canceled={() => {
@@ -140,7 +271,7 @@
<Drawer bind:this={runDrawer}>
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
{#if selectedId}
<JobPreview blankLink id={selectedId} />
<JobPreview blankLink id={selectedId} workspace={selectedWorkspace} />
{/if}
</DrawerContent>
</Drawer>
@@ -180,6 +311,7 @@
bind:resultError
bind:jobKindsCat
bind:hideSchedules
bind:allWorkspaces
on:change={reloadLogsWithoutFilterError}
{usernames}
{folders}
@@ -195,12 +327,13 @@
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
jobLoader?.loadJobs(minTs, maxTs, true)
}}
/>
</div>
<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} />
<RunsQueue {queue_count} {allWorkspaces} />
<Button
size="xs"
color="light"
@@ -246,7 +379,16 @@
</div>
<div class="flex flex-row gap-2 items-center">
<Button size="xs" color="light" variant="border" on:click={reset}>Reset</Button>
<ManuelDatePicker bind:minTs bind:maxTs bind:selectedManualDate {loading} />
<ManuelDatePicker
on:loadJobs={() => {
jobLoader?.loadJobs(minTs, maxTs, true, true)
}}
bind:minTs
bind:maxTs
bind:selectedManualDate
{loading}
bind:this={manualDatePicker}
/>
<Toggle
size="xs"
bind:checked={autoRefresh}
@@ -263,6 +405,7 @@
<RunsTable
{jobs}
bind:selectedId
bind:selectedWorkspace
on:filterByPath={(e) => {
user = null
folder = null
@@ -289,7 +432,7 @@
</Pane>
<Pane size={40} minSize={15} class="border-t">
{#if selectedId}
<JobPreview id={selectedId} />
<JobPreview id={selectedId} workspace={selectedWorkspace} />
{:else}
<div class="text-xs m-4">No job selected</div>
{/if}
@@ -329,6 +472,7 @@
bind:argError
bind:resultError
bind:hideSchedules
bind:allWorkspaces
mobile={true}
on:change={reloadLogsWithoutFilterError}
/>
@@ -341,13 +485,14 @@
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
jobLoader?.loadJobs(minTs, maxTs, true)
}}
/>
</div>
<div class="flex flex-col gap-1 md:flex-row w-full p-4">
<div class="flex items-center flex-row gap-2 grow mb-4">
{#if queue_count}
<RunsQueue {queue_count} />
<RunsQueue {queue_count} {allWorkspaces} />
{/if}
<Button
size="xs"
@@ -394,7 +539,16 @@
</div>
<div class="flex flex-row gap-2 items-center">
<Button size="xs" color="light" variant="border" on:click={reset}>Reset</Button>
<ManuelDatePicker bind:minTs bind:maxTs bind:selectedManualDate {loading} />
<ManuelDatePicker
on:loadJobs={() => {
jobLoader?.loadJobs(minTs, maxTs, true, true)
}}
bind:this={manualDatePicker}
bind:minTs
bind:maxTs
bind:selectedManualDate
{loading}
/>
<Toggle
size="xs"
@@ -408,6 +562,7 @@
<RunsTable
{jobs}
bind:selectedId
bind:selectedWorkspace
on:select={() => {
runDrawer.openDrawer()
}}