feat(frontend): Runs rework v2 (#2012)

* wip

* feat(frontend): Add disable prop to App Toggles

* feat(frontend): run page v2

* feat(frontend): column ordering + load more

* feat(frontend): column ordering + load more

* feat(frontend): revert old coode

* feat(frontend): simplify table + fix load more style + add groups

* feat(frontend): Done

* feat(frontend): Fix styling issues

* feat(frontend): Fix styling issues

* feat(frontend): Simplify filters

* feat(frontend): Rework runs done

* feat(frontend): fix drawer

* feat(frontend): rework the filters

* feat(frontend): rework the filters

* feat(frontend): small ui fix

* feat(frontend): correctly display title

* feat(frontend): fix dark mode switch

* feat(frontend): fix reset/clear buttons

* feat(frontend): Rework runs done
This commit is contained in:
Faton Ramadani
2023-08-10 17:24:31 +02:00
committed by GitHub
parent 8a1f9a7c6a
commit 7d88a2d13a
13 changed files with 950 additions and 320 deletions
@@ -6,6 +6,7 @@
import nord from 'svelte-highlight/styles/nord'
import { each } from 'chart.js/helpers'
import { Chart } from 'chart.js'
import { deepMergeWithPriority } from '$lib/utils'
meditor.defineTheme('nord', {
base: 'vs-dark',
@@ -118,7 +119,7 @@
meditor.setTheme('nord')
darkMode = true
each(Chart.instances, (instance) => {
instance.options = {
instance.options = deepMergeWithPriority(instance.options, {
scales: {
y: {
ticks: {
@@ -144,7 +145,7 @@
}
}
}
}
})
instance.update()
})
} else {
@@ -152,7 +153,7 @@
darkMode = false
each(Chart.instances, (instance) => {
instance.options = {
instance.options = deepMergeWithPriority(instance.options, {
scales: {
y: {
ticks: {
@@ -178,7 +179,8 @@
}
}
}
}
})
instance.update()
})
}
+80 -74
View File
@@ -2,11 +2,14 @@
import { ChevronRightSquare, ClipboardCopy, Download, Expand } from 'lucide-svelte'
import ArgInfo from './ArgInfo.svelte'
import { Button, Drawer, DrawerContent, Skeleton } from './common'
import TableCustom from './TableCustom.svelte'
import Portal from 'svelte-portal'
import { Highlight } from 'svelte-highlight'
import { copyToClipboard } from '$lib/utils'
import { json } from 'svelte-highlight/languages'
import DataTable from './table/DataTable.svelte'
import Cell from './table/Cell.svelte'
import Head from './table/Head.svelte'
import Row from './table/Row.svelte'
export let args: any
export let tableClass = ''
@@ -46,87 +49,90 @@ ${Object.entries(args)
</script>
<div class="relative">
<div class="text-tertiary text-xs absolute top-8 right-0">
<button
on:click={() => {
jsonStr = JSON.stringify(args, null, 4)
jsonViewer.openDrawer()
}}><Expand size={18} /></button
>
</div>
<TableCustom class="py-2 {tableClass}">
<tr slot="header-row">
<th>Argument</th>
<th>Value</th>
</tr>
<tbody slot="body">
<DataTable size="sm">
<Head>
<tr>
<Cell head first>Argument</Cell>
<Cell head last>Value</Cell>
</tr>
<svelte:fragment slot="headerAction">
<button
on:click={() => {
jsonStr = JSON.stringify(args, null, 4)
jsonViewer.openDrawer()
}}
>
<Expand size={18} />
</button>
</svelte:fragment>
</Head>
<tbody class="divide-y">
{#if args && Object.keys(args).length > 0}
{#each Object.entries(args) as [arg, value]}
<tr>
<td class="font-semibold">{arg}</td>
<td><ArgInfo {value} /></td>
</tr>
<Row>
<Cell first>{arg}</Cell>
<Cell last><ArgInfo {value} /></Cell>
</Row>
{/each}
{:else if args}
<tr><div class="text-secondary pt-2 pl-1 text-sm">No arguments</div></tr>
<Row><Cell>No arguments</Cell></Row>
{:else}
<tr>
<td>
<Row>
<Cell first>
<Skeleton layout={[[3], 0.5, [3]]} />
</td>
<td>
</Cell>
<Cell last>
<Skeleton layout={[[3], 0.5, [3]]} />
</td>
</tr>
</Cell>
</Row>
{/if}
</tbody>
</TableCustom>
</DataTable>
</div>
<Portal>
<Drawer bind:this={jsonViewer} size="900px">
<DrawerContent title="Expanded Args" on:close={jsonViewer.closeDrawer}>
<svelte:fragment slot="actions">
<a
class="text-sm text-secondary mr-2 inline-flex gap-2 items-center py-2 px-2 hover:bg-surface-hover rounded-lg"
download="windmill-args.json"
href="data:text/json;charset=utf-8,{encodeURIComponent(jsonStr)}"
>Download <Download size={14} /></a
>
<Button on:click={runLocally.openDrawer} color="light" size="xs">
<div class="flex gap-2 items-center">Use in a local script<ChevronRightSquare /> </div>
</Button>
<Button on:click={() => copyToClipboard(jsonStr)} color="light" size="xs">
<div class="flex gap-2 items-center">Copy to clipboard <ClipboardCopy /> </div>
</Button>
</svelte:fragment>
{#if jsonStr.length > 100000}
<div class="text-sm mb-2 text-tertiary">
<a
download="windmill-args.json"
href="data:text/json;charset=utf-8,{encodeURIComponent(jsonStr)}">Download</a
>
JSON is too large to be displayed in full.
</div>
{:else}
<Highlight language={json} code={jsonStr.replace(/\\n/g, '\n')} />
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={runLocally} size="900px">
<DrawerContent title="Run locally" on:close={runLocally.closeDrawer}>
<h3 class="mb-2">Envs</h3>
If using the wmill client in your code, set the following env variables:
<pre
><code
>BASE_URL="{window.location.origin}"
WM_TOKEN="{'<TOKEN>'}"</code
></pre
<Drawer bind:this={jsonViewer} size="900px">
<DrawerContent title="Expanded Args" on:close={jsonViewer.closeDrawer}>
<svelte:fragment slot="actions">
<a
class="text-sm text-secondary mr-2 inline-flex gap-2 items-center py-2 px-2 hover:bg-surface-hover rounded-lg"
download="windmill-args.json"
href="data:text/json;charset=utf-8,{encodeURIComponent(jsonStr)}"
>Download <Download size={14} /></a
>
<h3 class="mt-8">TypeScript</h3>
<pre><code>{typescriptCode()}</code></pre>
<h3 class="mt-8">Python</h3>
<pre><code>{pythonCode()}</code></pre>
</DrawerContent>
</Drawer>
</Portal>
<Button on:click={runLocally.openDrawer} color="light" size="xs">
<div class="flex gap-2 items-center">Use in a local script<ChevronRightSquare /> </div>
</Button>
<Button on:click={() => copyToClipboard(jsonStr)} color="light" size="xs">
<div class="flex gap-2 items-center">Copy to clipboard <ClipboardCopy /> </div>
</Button>
</svelte:fragment>
{#if jsonStr.length > 100000}
<div class="text-sm mb-2 text-tertiary">
<a
download="windmill-args.json"
href="data:text/json;charset=utf-8,{encodeURIComponent(jsonStr)}">Download</a
>
JSON is too large to be displayed in full.
</div>
{:else}
<Highlight language={json} code={jsonStr.replace(/\\n/g, '\n')} />
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={runLocally} size="900px">
<DrawerContent title="Run locally" on:close={runLocally.closeDrawer}>
<h3 class="mb-2">Envs</h3>
If using the wmill client in your code, set the following env variables:
<pre
><code
>BASE_URL="{window.location.origin}"
WM_TOKEN="{'<TOKEN>'}"</code
></pre
>
<h3 class="mt-8">TypeScript</h3>
<pre><code>{typescriptCode()}</code></pre>
<h3 class="mt-8">Python</h3>
<pre><code>{pythonCode()}</code></pre>
</DrawerContent>
</Drawer>
@@ -23,7 +23,7 @@
$: setSelected(selected)
$: $selectedContent && dispatch('selected', $selectedContent)
$: dispatch('selected', $selectedContent)
setContext<ToggleButtonContext>('ToggleButtonGroup', {
selected: selectedContent,
select: (value: any) => {
@@ -0,0 +1,130 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { Job } from '../../gen'
import TestJobLoader from '../TestJobLoader.svelte'
import DisplayResult from '../DisplayResult.svelte'
import JobArgs from '../JobArgs.svelte'
import LogViewer from '../LogViewer.svelte'
import { forLater, msToSec } from '$lib/utils'
import { Badge, Button, Skeleton, Tab, Tabs } from '../common'
import { onMount } from 'svelte'
import HighlightCode from '../HighlightCode.svelte'
import { goto } from '$app/navigation'
export let id: string
let job: Job | undefined = undefined
let timeout: NodeJS.Timeout | undefined
let watchJob: (id: string) => Promise<void>
let result: any
function onDone(event: { detail: Job }) {
job = event.detail
result = job['result']
}
onMount(() => {
watchJob(id)
})
onDestroy(() => {
timeout && clearTimeout(timeout)
})
let viewTab = 'result'
</script>
<svelte:window on:keydown={({ key }) => ['Escape', 'Esc'].includes(key) && close()} />
<TestJobLoader bind:job bind:watchJob on:done={onDone} />
<div class="p-4 flex flex-col gap-2 items-start">
<div class="flex gap-2">
<Badge color="blue">
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
Ran in ({msToSec(job.duration_ms)}s)
{/if}
</Badge>
{#if job?.['mem_peak']}
<Badge color="blue">
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{/if}
</div>
<Button
class="flex flex-row gap-1 items-center"
on:click={() => {
goto(`/run/${job?.id}`)
}}
>
<span class="font-semibold text-xs leading-6">ID:</span>
<span class="text-xs">{job?.id}</span>
</Button>
<span class="font-semibold text-xs leading-6">Arguments</span>
<div class="w-full">
<JobArgs args={job?.args} />
</div>
<span class="font-semibold text-xs leading-6">Results</span>
{#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">
<div>Job is scheduled for</div>
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
</div>
{/if}
<div class=" w-full rounded-md overflow-auto">
{#if job?.type === Job.type.COMPLETED_JOB}
<Tabs bind:selected={viewTab}>
<Tab size="xs" value="result">Result</Tab>
<Tab size="xs" value="logs">Logs</Tab>
{#if job?.job_kind == 'dependencies'}
<Tab size="xs" value="code">Code</Tab>
{:else if job?.job_kind == 'preview'}
<Tab size="xs" value="code">Code</Tab>
{/if}
</Tabs>
<Skeleton loading={!job} layout={[[5]]} />
{#if job}
<div class="flex flex-row border rounded-md p-2 mt-2 max-h-1/2 overflow-auto">
{#if viewTab == 'logs'}
<div class="w-full">
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={!(job && 'logs' in job && job.logs)}
content={job?.logs}
/>
</div>
{:else if viewTab == 'code'}
{#if job && 'raw_code' in job && job.raw_code}
<div class="text-xs">
<HighlightCode lines language={job.language} code={job.raw_code} />
</div>
{:else if job}
No code is available
{:else}
<Skeleton layout={[[5]]} />
{/if}
{:else if job !== undefined && 'result' in job && job.result !== undefined}
<DisplayResult workspaceId={job?.workspace_id} jobId={job?.id} {result} disableExpand />
{:else if job}
No output is available yet
{/if}
</div>
{/if}
{:else if job && `running` in job ? job.running : false}
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
content={job?.logs}
isLoading
/>
{/if}
</div>
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Filter } from 'lucide-svelte'
import { Popup, Button } from '../common'
</script>
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
>
<svelte:fragment slot="button">
<Button color="dark" size="xs" nonCaptureEvent={true}>
<div class="flex flex-row gap-1 items-center">
<Filter size={16} />
Filters
</div>
</Button>
</svelte:fragment>
<div class="flex flex-col w-80">
<slot name="filters" />
</div>
</Popup>
@@ -0,0 +1,196 @@
<script lang="ts">
import { goto } from '$app/navigation'
import type { Job } from '$lib/gen'
import {
displayDate,
displayDaysAgo,
forLater,
msToSec,
truncateHash,
truncateRev
} from '$lib/utils'
import { faRobot, faBarsStaggered } from '@fortawesome/free-solid-svg-icons'
import { onDestroy, onMount } from 'svelte'
import Icon from 'svelte-awesome'
import { Badge, Button } from '../common'
import ScheduleEditor from '../ScheduleEditor.svelte'
import Row from '../table/Row.svelte'
import Cell from '../table/Cell.svelte'
import {
Calendar,
CalendarCheck,
Check,
FastForward,
Hourglass,
ListFilter,
Play,
X
} from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
const SMALL_ICON_SCALE = 0.7
export let job: Job
export let selectedId: string | undefined = undefined
let scheduleEditor: ScheduleEditor
let time = Date.now()
let interval
onMount(() => {
interval = setInterval(() => {
time = Date.now()
}, 1000)
})
onDestroy(() => {
interval && clearInterval(interval)
})
function endedDate(started_at: string, duration_ms: number): string {
const started = new Date(started_at)
started.setMilliseconds(started.getMilliseconds() + duration_ms)
return `${started.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}`
}
</script>
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<Row
hoverable
selected={selectedId === job.id}
on:click={() => {
selectedId = job.id
dispatch('select')
}}
>
<Cell first>
<div>
{#if 'success' in job && job.success}
{#if job.is_skipped}
<Badge color="green" rounded>
<FastForward size={14} />
</Badge>
{:else}
<Badge color="green" baseClass="!px-1.5">
<Check size={14} />
</Badge>
{/if}
{:else if 'success' in job}
<Badge color="red" baseClass="!px-1.5">
<X size={14} />
</Badge>
{:else if 'running' in job && job.running}
<Badge color="yellow" baseClass="!px-1.5">
<Play size={14} />
</Badge>
{:else if job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for)}
<Badge color="blue" baseClass="!px-1.5">
<Calendar size={14} />
</Badge>
{:else}
<Badge baseClass="!px-1.5">
<Hourglass size={14} />
</Badge>
{/if}
</div>
</Cell>
<Cell>
<div class="flex flex-row items-center gap-1 text-gray-500 dark:text-gray-300 text-2xs">
{#if 'started_at' in job && job.started_at}
{#if job?.['duration_ms']}
Ended {endedDate(job.started_at, job?.['duration_ms'])}
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
(Ran in {msToSec(job.duration_ms)}s)
{/if}
{:else}
<div>
Started
{#key time}
{displayDaysAgo(job.started_at ?? '')}
{/key}
</div>
{/if}
{/if}
{#if job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for)}
<CalendarCheck size={14} />
Scheduled
{#if 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
for {displayDate(job.scheduled_for ?? '')}
{/if}
{/if}
</div>
</Cell>
<Cell>
<div class="flex flex-row text-sm">
{#if job === undefined}
No job found
{:else}
<div class="flex flex-row space-x-2">
<div class="whitespace-nowrap text-xs font-semibold">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
<a href="/run/{job.id}?workspace={job.workspace_id}">{job.script_path} </a>
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterByPath', job.script_path)
}}
>
<ListFilter size={10} />
</Button>
</div>
{:else if 'job_kind' in job && job.job_kind == 'preview'}
<a href="/run/{job.id}?workspace={job.workspace_id}">Preview without path </a>
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
<a href="/run/{job.id}?workspace={job.workspace_id}"
>lock deps of {truncateHash(job.script_hash ?? '')}</a
>
{:else if 'job_kind' in job && job.job_kind == 'identity'}
<a href="/run/{job.id}?workspace={job.workspace_id}">no op</a>
{/if}
</div>
</div>
{/if}
</div>
{#if job && job.parent_job}
{#if job.is_flow_step}
<Icon class="text-secondary" data={faBarsStaggered} scale={SMALL_ICON_SCALE} />
<span class="mx-1">
Step of flow <a href={`/run/${job.parent_job}`}>{truncateRev(job.parent_job, 6)} </a>
</span>
{:else}
<Icon class="text-secondary" data={faRobot} scale={SMALL_ICON_SCALE} />
<span class="mx-1">
Parent <a href={`/run/${job.parent_job}`}>{job.parent_job}</a>
</span>
{/if}
{/if}
</Cell>
<Cell last>
{#if job && job.schedule_path}
<div class="flex flex-row items-center gap-1">
<Calendar size={14} />
<Button
size="xs2"
color="light"
btnClasses="font-normal"
on:click={() => scheduleEditor?.openEdit(job.schedule_path ?? '', job.job_kind == 'flow')}
>
{job.schedule_path}
</Button>
</div>
{:else}
{job.created_by}
{/if}
</Cell>
</Row>
@@ -0,0 +1,152 @@
<script lang="ts">
import { Button, Popup } from '../common'
import { page } from '$app/stores'
import { setQuery } from '$lib/navigation'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import Tooltip from '../Tooltip.svelte'
import { goto } from '$app/navigation'
import AutoComplete from 'simple-svelte-autocomplete'
import { ChevronDown, Filter, X } from 'lucide-svelte'
import JsonEditor from '../apps/editor/settingsPanel/inputEditor/JsonEditor.svelte'
export let paths: string[] = []
export let selectedPath: string | undefined = undefined
export let success: boolean | undefined = undefined
export let isSkipped: boolean | undefined = undefined
export let argFilter: string
export let argError: string
export let resultFilter: string
export let resultError: string
export let jobKindsCat: string
</script>
<div class="flex flex-col items-start gap-6 xl:gap-2 xl:flex-row mt-4 xl:mt-0">
{#key selectedPath}
<div class="relative">
{#if selectedPath}
<button
class="absolute top-2 right-2 z-50"
on:click={() => {
selectedPath = undefined
goto('/runs')
}}
>
<X size={14} />
</button>
{:else}
<ChevronDown class="absolute top-2 right-2" size={14} />
{/if}
<span class="text-xs absolute -top-4">Path</span>
<AutoComplete
items={paths}
value={selectedPath}
bind:selectedItem={selectedPath}
inputClassName="!h-[30px] py-1 !text-xs !w-64"
hideArrow
className={selectedPath ? '!font-bold' : ''}
dropdownClassName="!font-normal !w-64 !max-w-64"
/>
</div>
{/key}
<div class="flex flex-col xl:flex-row gap-6 xl:gap-2 w-full">
<div class="relative">
<span class="text-xs absolute -top-4">Kind</span>
<ToggleButtonGroup
bind:selected={jobKindsCat}
on:selected={(e) => {
const url = new URL($page.url)
url.searchParams.set('job_kinds', e.detail)
goto(url)
}}
>
<ToggleButton value="all" label="All" />
<ToggleButton value="runs" label="Runs" />
<ToggleButton value="previews" label="Previews" />
<ToggleButton value="dependencies" label="Dependencies" />
</ToggleButtonGroup>
</div>
<div class="relative">
<span class="text-xs absolute -top-4">Status</span>
<ToggleButtonGroup
bind:selected={success}
on:selected={async () =>
await setQuery($page.url, 'success', success === undefined ? success : String(success))}
>
<ToggleButton value={undefined} label="All" />
<ToggleButton value={true} label="Success" class="whitespace-nowrap" />
<ToggleButton value={false} label="Failure" class="whitespace-nowrap" />
</ToggleButtonGroup>
</div>
<div class="relative">
<span class="text-xs absolute -top-4">
Flow
<Tooltip light>Skipped flows are flows that did an early break</Tooltip></span
>
<ToggleButtonGroup
bind:selected={isSkipped}
on:selected={async () =>
await setQuery(
$page.url,
'is_skipped',
isSkipped === undefined ? isSkipped : String(isSkipped)
)}
>
<ToggleButton value={undefined} label="All" class="whitespace-nowrap" />
<ToggleButton value={false} label="Not skipped" class="whitespace-nowrap" />
<ToggleButton value={true} label="Skipped" class="whitespace-nowrap" />
</ToggleButtonGroup>
</div>
</div>
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
let:close
>
<svelte:fragment slot="button">
<Button color="light" size="xs" nonCaptureEvent={true} variant="border">
<div class="flex flex-row gap-1 items-center">
<Filter size={16} />
Filter by args/result
</div>
</Button>
</svelte:fragment>
<div class="flex flex-col w-72 p-2 gap-2">
<span class="text-sm eading-6 font-semibold">Filters</span>
<span class="text-xs leading-6">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<span class="text-xs eading-6 font-semibold">Filter by args</span>
<JsonEditor on:change bind:error={argError} bind:code={argFilter} />
<span class="text-xs eading-6 font-semibold">Filter by result</span>
<JsonEditor on:change bind:error={resultError} bind:code={resultFilter} />
<div class="flex flex-row gap-2 justify-between">
<Button
size="xs"
color="light"
on:click={() => {
argFilter = ''
resultFilter = ''
}}
>
Clear
</Button>
<Button
size="xs"
color="dark"
on:click={() => {
close(null)
}}
>
Close
</Button>
</div>
</div>
</Popup>
</div>
@@ -0,0 +1,97 @@
<script lang="ts">
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
export let jobs: Job[] = []
export let selectedId: string | undefined = undefined
export let nbObJobs: number = 30
export let loadMoreQuantity: number = 30
function groupJobsByDay(jobs: Job[]): Record<string, Job[]> {
const groupedLogs: Record<string, Job[]> = {}
if (!jobs) return groupedLogs
for (const job of jobs) {
if ('started_at' in job && job.started_at) {
const date = new Date(job.started_at)
date.setMilliseconds(date.getMilliseconds() + (job['duration_ms'] ?? 0))
const day = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
if (!groupedLogs[day]) {
groupedLogs[day] = []
}
groupedLogs[day].push(job)
}
}
for (const day in groupedLogs) {
groupedLogs[day].sort((a, b) => {
return new Date(b.started_at!).getTime() - new Date(a.started_at!).getTime()
})
}
const sortedLogs: Record<string, Job[]> = {}
Object.keys(groupedLogs)
.sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime()
})
.forEach((key) => {
sortedLogs[key] = groupedLogs[key]
})
return sortedLogs
}
$: groupedJobs = groupJobsByDay(jobs.slice(0, nbObJobs))
</script>
<DataTable
rounded={false}
size="sm"
on:loadMore={() => console.log('load more')}
loadMore={loadMoreQuantity}
shouldLoadMore={nbObJobs < jobs.length}
on:loadMore={() => (nbObJobs += loadMoreQuantity)}
>
<Head>
<Cell first head class="w-8" />
<Cell head>Timestamp</Cell>
<Cell head>Path</Cell>
<Cell head last>Triggered by</Cell>
</Head>
<tbody class="divide-y">
{#each Object.entries(groupedJobs) as [date, jobsByDay]}
<tr class="border-t">
<Cell
first
colspan="6"
scope="colgroup"
class="bg-surface-secondary/30 py-2 border-b font-semibold"
>
{date}
</Cell>
</tr>
{#each jobsByDay as job (job.id)}
<RunRow {job} bind:selectedId on:select on:filterByPath />
{/each}
{/each}
</tbody>
{#if jobs.length == 0}
<tr>
<td colspan="4" class="text-center py-8">
<div class="text-xs text-secondary"> No jobs found for the selected filters. </div>
</td>
</tr>
{/if}
</DataTable>
@@ -7,7 +7,7 @@
<script lang="ts">
import { createEventDispatcher, setContext } from 'svelte'
import Button from '../common/button/Button.svelte'
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-svelte'
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
export let paginated: boolean = false
@@ -66,11 +66,11 @@
{/if}
</div>
{:else if shouldLoadMore}
<div class="bg-surface border-t flex flex-row justify-end p-1 items-center gap-2">
<div class="bg-surface border-t flex flex-row justify-center py-4 items-center gap-2">
<Button color="light" size="xs2" on:click={() => dispatch('loadMore')}>
<div class="flex flex-row gap-1 items-center">
Load {loadMore} more
<ArrowRightIcon size={16} />
<ArrowDownIcon size={16} />
</div>
</Button>
</div>
@@ -1,3 +1,6 @@
<thead class="bg-surface-secondary sticky top-0">
<slot />
<div class="absolute top-2 right-2">
<slot name="headerAction" />
</div>
</thead>
+1 -1
View File
@@ -10,7 +10,7 @@
<tr
class={twMerge(
hoverable ? 'hover:bg-surface-hover cursor-pointer' : '',
selected ? 'bg-surface-selected' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all'
)}
on:click={() => {
@@ -87,7 +87,6 @@
}
}
let notfound = false
let forceCancel = false
</script>
@@ -6,29 +6,25 @@
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import JobDetail from '$lib/components/jobs/JobDetail.svelte'
import { Button, Skeleton } from '$lib/components/common'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import { goto } from '$app/navigation'
import PageHeader from '$lib/components/PageHeader.svelte'
import RunChart from '$lib/components/RunChart.svelte'
import { faSearchMinus } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import AutoComplete from 'simple-svelte-autocomplete'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import Slider from '$lib/components/Slider.svelte'
import JsonEditor from '$lib/components/apps/editor/settingsPanel/inputEditor/JsonEditor.svelte'
import { openStore } from '$lib/components/jobs/JobPreview.svelte'
import { setQuery } from '$lib/navigation'
import JobPreview from '$lib/components/runs/JobPreview.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { RefreshCcw } from 'lucide-svelte'
import { RefreshCw } from 'lucide-svelte'
import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte'
import RunsTable from '$lib/components/runs/RunsTable.svelte'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import RunsFilter from '$lib/components/runs/RunsFilter.svelte'
import MobileFilters from '$lib/components/runs/MobileFilters.svelte'
import Toggle from '$lib/components/Toggle.svelte'
let jobs: Job[] | undefined
let error: Error | undefined
let intervalId: NodeJS.Timer | undefined
let selectedId: string | undefined = undefined
let success: boolean | undefined =
$page.url.searchParams.get('success') != undefined
@@ -45,12 +41,10 @@
let maxTs = $page.url.searchParams.get('max_ts') ?? undefined
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
let nbOfJobs = 30
let nbObJobs = 30
$: path = $page.params.path
$: jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
$: jobKinds = computeJobKinds(jobKindsCat)
function computeJobKinds(jobKindsCat: string | undefined): string {
@@ -67,16 +61,6 @@
$: ($workspaceStore && loadJobs()) || (path && success && isSkipped && jobKinds)
let filterTimeout: NodeJS.Timeout | undefined = undefined
function debounceSyncer() {
if (resultError == '' && argError == '') {
filterTimeout && clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
loadJobs()
}, 2000)
}
}
async function fetchJobs(
startedBefore: string | undefined,
startedAfter: string | undefined
@@ -100,22 +84,18 @@
})
}
let loading: boolean = false
async function loadJobs(): Promise<void> {
loading = true
jobs = undefined
try {
const newJobs = await fetchJobs(maxTs, minTs)
jobs = newJobs
} catch (err) {
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
error = err
console.error(JSON.stringify(err))
}
}
async function loadOlderJobs() {
if (jobs) {
nbOfJobs += 30
}
loading = false
}
async function syncer() {
@@ -143,8 +123,8 @@
let sync = true
onMount(() => {
loadPaths()
$openStore = ''
intervalId = setInterval(syncer, 5000)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
sync = false
@@ -154,6 +134,20 @@
})
})
onDestroy(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
$: if (!intervalId && autoRefresh) {
intervalId = setInterval(syncer, 5000)
}
$: if (intervalId && !autoRefresh) {
clearInterval(intervalId)
intervalId = undefined
}
let paths: string[] = []
async function loadPaths() {
@@ -162,10 +156,6 @@
paths = npaths_scripts.concat(npaths_flows).sort()
}
function syncWithUrl(arg: string, value: string) {
setQueryWithoutLoad($page.url, [{ key: arg, value }])
}
async function syncTsWithURL(minTs?: string, maxTs?: string) {
setQueryWithoutLoad($page.url, [
{ key: 'min_ts', value: minTs },
@@ -173,19 +163,11 @@
])
}
$: syncWithUrl('job_kinds', jobKindsCat)
$: syncWithUrl('arg', argFilter)
$: syncWithUrl('result', resultFilter)
$: syncTsWithURL(minTs, maxTs)
$: completedJobs =
jobs?.filter((x) => x.type == 'CompletedJob').map((x) => x as CompletedJob) ?? []
onDestroy(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
let searchPath = ''
$: searchPath = path
@@ -197,8 +179,24 @@
let argError = ''
let resultError = ''
let filterTimeout: NodeJS.Timeout | undefined = undefined
function reloadLogsWithoutFilterError() {
if (resultError == '' && argError == '') {
filterTimeout && clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
loadJobs()
}, 2000)
}
}
const manualDates = [
{
label: 'Last 1000 runs',
setMinMax: () => {
minTs = undefined
maxTs = undefined
}
},
{
label: 'Last 30 seconds',
setMinMax: () => {
@@ -233,210 +231,236 @@
minTs = new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
}
},
{
label: 'Last 7 days',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
}
},
{
label: 'Last month',
setMinMax: () => {
minTs = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
maxTs = new Date().toISOString()
}
}
]
let selectedManualDate = 0
let autoRefresh: boolean = true
let runDrawer: Drawer
</script>
<CenteredPage>
<PageHeader
title="Runs {path ? `of ${path}` : ''}"
tooltip="All past and schedule executions of scripts and flows, including previews.
You only see your own runs or runs of groups you belong to unless you are an admin."
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
/>
<div class="w-full h-screen">
<div class="px-2">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1">
<h1 class="!text-2xl font-semibold leading-6 tracking-tight"> Runs </h1>
<div class="max-w-7x mt-2">
<div class="flex flex-row space-x-4">
<select
bind:value={success}
on:change={async () => await setQuery($page.url, 'success', String(success))}
>
<option value={undefined}>Successful and error jobs</option>
<option value={true}>Only successful jobs</option>
<option value={false}>Only error jobs</option>
</select>
<select
bind:value={isSkipped}
on:change={async () => await setQuery($page.url, 'is_skipped', String(isSkipped))}
>
<option value={false}>Ignore skipped flow jobs</option>
<option value={true}>If a flow job, show only if it was not skipped</option>
<option value={undefined}>Show flow jobs regardless of being skipped or not</option>
</select>
<Tooltip>Skipped flows are flows that did an early break</Tooltip>
</div>
<div>
<div class="my-2 pb-2">
<Tabs
selected={jobKindsCat}
on:selected={(e) => {
const url = new URL($page.url)
url.searchParams.set('job_kinds', e.detail)
goto(url)
}}
<Tooltip
light
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
scale={0.9}
wrapperClass="flex items-center"
>
<Tab value="all">All</Tab>
<Tab value="runs">Runs</Tab>
<Tab value="previews">Previews</Tab>
<Tab value="dependencies">Dependencies</Tab>
</Tabs>
All past and schedule executions of scripts and flows, including previews. You only see
your own runs or runs of groups you belong to unless you are an admin.
</Tooltip>
</div>
<div class="border mb-4">
<RunChart
jobs={completedJobs}
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
jobs = await fetchJobs(maxTs, minTs)
<div class="hidden xl:block">
<RunsFilter
bind:isSkipped
{paths}
{jobKindsCat}
bind:selectedPath={searchPath}
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
on:change={reloadLogsWithoutFilterError}
on:clearFilters={() => {
minTs = undefined
maxTs = undefined
autoRefresh = true
}}
/>
</div>
<div class="flex flex-row gap-x-2 w-full mb-2 mt-8">
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Min datetime</span>
<input
type="text"
value={minTs ?? 'zoom x axis to set min (drag with ctrl)'}
disabled
/>
<CalendarPicker
date={minTs}
label="Min datetimes"
on:change={async ({ detail }) => {
minTs = new Date(detail).toISOString()
jobs = await fetchJobs(maxTs, minTs)
}}
/>
</div>
</div>
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Max datetime</span>
<input type="text" value={maxTs ?? 'zoom x axis to set max'} disabled />
<CalendarPicker
date={maxTs}
label="Max datetimes"
on:change={async ({ detail }) => {
maxTs = new Date(detail).toISOString()
jobs = await fetchJobs(maxTs, minTs)
}}
/>
</div>
</div>
<Button
color="light"
size="xs2"
wrapperClasses="border rounded-md"
on:click={() => {
manualDates[selectedManualDate].setMinMax()
loadJobs()
}}
dropdownItems={[
...manualDates.map((d, i) => ({
label: d.label,
onClick: () => {
selectedManualDate = i
d.setMinMax()
loadJobs()
}
}))
]}
>
<div class="flex flex-row items-center gap-2">
<RefreshCcw size={14} />
{manualDates[selectedManualDate].label}
</div>
</Button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2 mb-2 w-full flex-wrap">
<div>
<div class="flex flex-row gap-x-2">
{#key path}
<AutoComplete
items={paths}
value={path}
bind:selectedItem={searchPath}
placeholder="Search by path"
/>
{/key}
<Button
title="Clear path and time filters"
variant="border"
on:click={async () => {
<div class="xl:hidden">
<MobileFilters>
<svelte:fragment slot="filters">
<span class="text-xs font-semibold leading-6">Filters</span>
<RunsFilter
bind:isSkipped
{paths}
{jobKindsCat}
bind:selectedPath={searchPath}
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
on:change={reloadLogsWithoutFilterError}
on:clearFilters={() => {
minTs = undefined
maxTs = undefined
jobs = undefined
await goto('/runs?' + $page.url.searchParams.toString())
loadJobs()
autoRefresh = true
}}
size="xs"
>
<Icon data={faSearchMinus} />
</Button>
</div>
</div>
<div
><Slider
text="Filter by args {argFilter ? '(set)' : ''}"
tooltip={'Filter by a json being a subset of the args. Try \'{"foo": "bar"}\''}
><JsonEditor
on:change={debounceSyncer}
bind:error={argError}
bind:code={argFilter}
/></Slider
></div
>
<div
><Slider
text="Filter by result {resultFilter ? '(set)' : ''}"
tooltip={'Filter by a json being a subset of the result. Try \'{"foo": "bar"}\''}
><JsonEditor
on:change={debounceSyncer}
bind:error={resultError}
bind:code={resultFilter}
/></Slider
></div
>
/>
</svelte:fragment>
</MobileFilters>
</div>
<Skeleton loading={!jobs} layout={[[6], 1, [6], 1, [6], 1, [6], 1, [6]]} />
{#if jobs}
<div class="space-y-0">
{#each jobs.slice(0, nbOfJobs) as job (job.id)}
<JobDetail {job} />
<div class="line w-20 h-4" />
{/each}
</div>
{#if jobs.length == 0}
<NoItemFound />
{/if}
{/if}
</div>
{#if error}
<div>
{JSON.stringify(error)}
</div>
{/if}
</div>
{#if (jobs?.length ?? 0) >= nbOfJobs}
<div class="text-center pb-6">
<button class=" mt-4 text-blue-500 text-center text-sm" on:click={loadOlderJobs}>
Load older jobs
</button>
</div>
{/if}
</CenteredPage>
<div class=" p-2">
<RunChart
jobs={completedJobs}
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
jobs = await fetchJobs(maxTs, minTs)
}}
/>
</div>
<div class="flex flex-col gap-1 md:flex-row w-full p-4 justify-between">
<div class="flex flex-row gap-1 w-full">
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Min datetime</span>
<style>
.line {
background: repeating-linear-gradient(to bottom, transparent 0 4px, #999 4px 8px) 50%/1px 100%
no-repeat;
}
</style>
<input type="text" value={minTs ?? 'zoom x axis to set min (drag with ctrl)'} disabled />
<CalendarPicker
date={minTs}
label="Min datetimes"
on:change={async ({ detail }) => {
minTs = new Date(detail).toISOString()
jobs = await fetchJobs(maxTs, minTs)
}}
/>
</div>
</div>
<div class="relative w-full">
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Max datetime</span>
<input type="text" value={maxTs ?? 'zoom x axis to set max'} disabled />
<CalendarPicker
date={maxTs}
label="Max datetimes"
on:change={async ({ detail }) => {
maxTs = new Date(detail).toISOString()
jobs = await fetchJobs(maxTs, minTs)
}}
/>
</div>
</div>
</div>
<div class="flex flex-row gap-2 items-center">
<Button
size="xs"
color="light"
variant="border"
on:click={() => {
minTs = undefined
maxTs = undefined
autoRefresh = true
selectedManualDate = 0
selectedId = undefined
loadJobs()
}}
>
Reset
</Button>
<Button
color="light"
size="xs"
wrapperClasses="border rounded-md"
on:click={() => {
manualDates[selectedManualDate].setMinMax()
loadJobs()
}}
dropdownItems={[
...manualDates.map((d, i) => ({
label: d.label,
onClick: () => {
selectedManualDate = i
d.setMinMax()
loadJobs()
}
}))
]}
>
<div class="flex flex-row items-center gap-2">
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
{manualDates[selectedManualDate].label}
</div>
</Button>
<Toggle
size="xs"
bind:checked={autoRefresh}
options={{ right: 'Auto-refresh' }}
textClass="whitespace-nowrap"
/>
</div>
</div>
<SplitPanesWrapper class="hidden md:block">
<Splitpanes>
<Pane size={60} minSize={50}>
{#if jobs}
<RunsTable
{jobs}
bind:selectedId
bind:nbObJobs
loadMoreQuantity={30}
on:filterByPath={(e) => {
searchPath = e.detail
}}
/>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</Pane>
<Pane size={40} minSize={15} class="border-t">
{#if selectedId}
{#key selectedId}
<JobPreview id={selectedId} />
{/key}
{:else}
<div class="text-xs m-4">No job selected</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
<div class="md:hidden">
{#if jobs}
<RunsTable
{jobs}
bind:selectedId
bind:nbObJobs
loadMoreQuantity={30}
on:select={() => {
runDrawer.openDrawer()
}}
/>
{/if}
</div>
</div>
<Drawer bind:this={runDrawer}>
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
{#if selectedId}
<JobPreview id={selectedId} />
{/if}
</DrawerContent>
</Drawer>