fix(frontend): add virtual list (#2218)

* fix(frontend): add virtual list

* feat(frontend): add virtual list to runs page

* feat(frontend): fix everything

* feat(frontend): add message when the limit is reached
This commit is contained in:
Faton Ramadani
2023-09-05 16:49:25 +02:00
committed by GitHub
parent 6791abbe4d
commit 9b60efa864
5 changed files with 422 additions and 125 deletions
+22
View File
@@ -39,8 +39,10 @@
"svelte-chartjs": "^3.1.0",
"svelte-dnd-action": "^0.9.22",
"svelte-exmarkdown": "^2.1.0",
"svelte-infinite-loading": "^1.3.8",
"svelte-portal": "^2.2.0",
"svelte-timezone-picker": "^2.0.3",
"svelte-tiny-virtual-list": "^2.0.5",
"tailwind-merge": "^1.13.2",
"vscode-ws-jsonrpc": "3.0.0",
"windmill-parser-wasm": "^1.154.3",
@@ -8762,6 +8764,11 @@
"svelte": "^3.19.0 || ^4.0.0-next.0"
}
},
"node_modules/svelte-infinite-loading": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/svelte-infinite-loading/-/svelte-infinite-loading-1.3.8.tgz",
"integrity": "sha512-hn4o848LKd2Q+M11hiMWnfFxM1GHKVDi92HPZ1FYvfed4bEeRZL+QvFAQzhy1SACq6Si0CAJcQFUZpIYmAEnpQ=="
},
"node_modules/svelte-multiselect": {
"version": "8.6.2",
"resolved": "https://registry.npmjs.org/svelte-multiselect/-/svelte-multiselect-8.6.2.tgz",
@@ -8899,6 +8906,11 @@
"svelte": "^3.25.0"
}
},
"node_modules/svelte-tiny-virtual-list": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-2.0.5.tgz",
"integrity": "sha512-xg9ckb8UeeIme4/5qlwCrl2QNmUZ8SCQYZn3Ji83cUsoASqRNy3KWjpmNmzYvPDqCHSZjruBBsoB7t5hwuzw5g=="
},
"node_modules/svelte2tsx": {
"version": "0.6.19",
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.6.19.tgz",
@@ -16039,6 +16051,11 @@
"dev": true,
"requires": {}
},
"svelte-infinite-loading": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/svelte-infinite-loading/-/svelte-infinite-loading-1.3.8.tgz",
"integrity": "sha512-hn4o848LKd2Q+M11hiMWnfFxM1GHKVDi92HPZ1FYvfed4bEeRZL+QvFAQzhy1SACq6Si0CAJcQFUZpIYmAEnpQ=="
},
"svelte-multiselect": {
"version": "8.6.2",
"resolved": "https://registry.npmjs.org/svelte-multiselect/-/svelte-multiselect-8.6.2.tgz",
@@ -16113,6 +16130,11 @@
"compute-scroll-into-view": "^1.0.16"
}
},
"svelte-tiny-virtual-list": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-2.0.5.tgz",
"integrity": "sha512-xg9ckb8UeeIme4/5qlwCrl2QNmUZ8SCQYZn3Ji83cUsoASqRNy3KWjpmNmzYvPDqCHSZjruBBsoB7t5hwuzw5g=="
},
"svelte2tsx": {
"version": "0.6.19",
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.6.19.tgz",
+2
View File
@@ -103,8 +103,10 @@
"svelte-chartjs": "^3.1.0",
"svelte-dnd-action": "^0.9.22",
"svelte-exmarkdown": "^2.1.0",
"svelte-infinite-loading": "^1.3.8",
"svelte-portal": "^2.2.0",
"svelte-timezone-picker": "^2.0.3",
"svelte-tiny-virtual-list": "^2.0.5",
"tailwind-merge": "^1.13.2",
"vscode-ws-jsonrpc": "3.0.0",
"windmill-parser-wasm": "^1.154.3",
+62 -46
View File
@@ -6,8 +6,7 @@
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,
Check,
@@ -21,12 +20,16 @@
import { createEventDispatcher } from 'svelte'
import TimeAgo from '../TimeAgo.svelte'
import { forLater } from '$lib/forLater'
import { twMerge } from 'tailwind-merge'
import Portal from 'svelte-portal'
const dispatch = createEventDispatcher()
const SMALL_ICON_SCALE = 0.7
export let job: Job
export let selectedId: string | undefined = undefined
export let containerWidth: number = 0
let scheduleEditor: ScheduleEditor
function endedDate(started_at: string, duration_ms: number): string {
@@ -40,48 +43,55 @@
}
</script>
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<Portal>
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
</Portal>
<Row
hoverable
selected={selectedId === job.id}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={twMerge(
'hover:bg-surface-hover cursor-pointer',
selectedId === job.id ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all',
'flex flex-row items-center h-full'
)}
style="width: {containerWidth}px"
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} />
<div class="w-1/12 flex justify-center">
{#if 'success' in job && job.success}
{#if job.is_skipped}
<Badge color="green" rounded>
<FastForward size={14} />
</Badge>
{:else}
<Badge baseClass="!px-1.5">
<Hourglass size={14} />
<Badge color="green" baseClass="!px-1.5">
<Check size={14} />
</Badge>
{/if}
</div>
</Cell>
<Cell>
{: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>
<div class="w-4/12 flex justify-start">
<div class="flex flex-row items-center gap-1 text-gray-500 dark:text-gray-300 text-2xs">
{#if job}
{#if 'started_at' in job && job.started_at}
@@ -103,9 +113,9 @@
{/if}
{/if}
</div>
</Cell>
</div>
<Cell>
<div class="w-4/12 flex justify-star">
<div class="flex flex-row text-sm">
{#if job === undefined}
No job found
@@ -114,7 +124,9 @@
<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>
<a href="/run/{job.id}?workspace={job.workspace_id}" class="truncate w-8/12">
{job.script_path}
</a>
<Button
size="xs2"
color="light"
@@ -142,9 +154,9 @@
{: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
>
<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}
@@ -164,12 +176,14 @@
{:else}
<Icon class="text-secondary" data={faRobot} scale={SMALL_ICON_SCALE} />
<span class="mx-1">
Parent <a href={`/run/${job.parent_job}?workspace=${job.workspace_id}`}>{job.parent_job}</a>
Parent <a href={`/run/${job.parent_job}?workspace=${job.workspace_id}`}
>{job.parent_job}</a
>
</span>
{/if}
{/if}
</Cell>
<Cell last>
</div>
<div class="w-3/12 flex justify-start">
{#if job && job.schedule_path}
<div class="flex flex-row items-center gap-1">
<Calendar size={14} />
@@ -184,7 +198,9 @@
</div>
{:else}
<div class="flex flex-row gap-1 items-center">
{job.created_by}
<div class="text-xs">
{job.created_by}
</div>
<Button
size="xs2"
color="light"
@@ -196,5 +212,5 @@
</Button>
</div>
{/if}
</Cell>
</Row>
</div>
</div>
+118 -41
View File
@@ -1,9 +1,9 @@
<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'
import VirtualList from 'svelte-tiny-virtual-list'
import { onMount } from 'svelte'
import InfiniteLoading from 'svelte-infinite-loading'
export let jobs: Job[] = []
export let selectedId: string | undefined = undefined
@@ -13,6 +13,7 @@
function getTime(job: Job): string | undefined {
return job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
}
function groupJobsByDay(jobs: Job[]): Record<string, Job[]> {
const groupedLogs: Record<string, Job[]> = {}
@@ -57,44 +58,120 @@
}
$: groupedJobs = groupJobsByDay(jobs.slice(0, nbOfJobs))
type FlatJobs =
| {
type: 'date'
date: string
}
| {
type: 'job'
job: Job
}
function flattenJobs(groupedJobs: Record<string, Job[]>): Array<FlatJobs> {
const flatJobs: Array<FlatJobs> = []
for (const [date, jobsByDay] of Object.entries(groupedJobs)) {
flatJobs.push({ type: 'date', date })
for (const job of jobsByDay) {
flatJobs.push({ type: 'job', job })
}
}
return flatJobs
}
$: flatJobs = flattenJobs(groupedJobs)
let stickyIndices: number[] = []
$: {
stickyIndices = []
let index = 0
for (const entry of flatJobs) {
if (entry.type === 'date') {
stickyIndices.push(index)
}
index++
}
}
let tableHeight: number = 0
let header: number = 0
let containerWidth: number = 0
const MAX_ITEMS = 1000
function infiniteHandler({ detail: { loaded, error, complete } }) {
try {
nbOfJobs += loadMoreQuantity
if (nbOfJobs >= MAX_ITEMS) {
complete()
} else {
loaded()
}
} catch (e) {
error()
}
}
onMount(() => {
tableHeight = document.querySelector('#runs-table-wrapper')!.parentElement?.clientHeight ?? 0
})
</script>
<DataTable
rounded={false}
size="sm"
loadMore={loadMoreQuantity}
shouldLoadMore={nbOfJobs < jobs.length}
on:loadMore={() => (nbOfJobs += loadMoreQuantity)}
>
<Head>
<Cell first head class="w-8" />
<Cell head>Timestamp</Cell>
<Cell head>Path</Cell>
<Cell head last>Triggered by</Cell>
</Head>
<div class="divide-y min-w-[640px]" id="runs-table-wrapper" bind:clientWidth={containerWidth}>
<div
class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4 z-50"
bind:clientHeight={header}
>
<div class="w-1/12" />
<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>
</div>
<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 on:filterByUser on:filterByFolder />
{/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>
<VirtualList width="100%" height={tableHeight - header} itemCount={flatJobs.length} itemSize={42}>
<div slot="item" let:index let:style {style} class="w-full">
{@const jobOrDate = flatJobs[index]}
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div class="bg-surface-secondary/30 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}
bind:selectedId
on:select
on:filterByPath
on:filterByUser
on:filterByFolder
{containerWidth}
/>
</div>
{/if}
{:else}
{JSON.stringify(jobOrDate)}
{/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}
<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}
@@ -382,7 +382,16 @@
cancelAllJobs = false
}}
/>
<div class="w-full h-screen">
<Drawer bind:this={runDrawer}>
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
{#if selectedId}
<JobPreview id={selectedId} />
{/if}
</DrawerContent>
</Drawer>
<div class="w-full h-screen hidden md:block">
<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">
@@ -552,9 +561,9 @@
</div>
</div>
<SplitPanesWrapper class="hidden md:block">
<SplitPanesWrapper>
<Splitpanes>
<Pane size={60} minSize={50}>
<Pane size={60} minSize={60}>
{#if jobs}
<RunsTable
{jobs}
@@ -595,40 +604,211 @@
</Pane>
</Splitpanes>
</SplitPanesWrapper>
<div class="md:hidden">
{#if jobs}
<RunsTable
{jobs}
bind:selectedId
bind:nbOfJobs
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
}}
/>
{/if}
</div>
</div>
<Drawer bind:this={runDrawer}>
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
{#if selectedId}
<JobPreview id={selectedId} />
{/if}
</DrawerContent>
</Drawer>
<div class="md:hidden h-[calc(100vh-48px)]">
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={50} minSize={20}>
<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>
<Tooltip
light
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
scale={0.9}
wrapperClass="flex items-center"
>
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="hidden xl:block">
<RunsFilter
bind:isSkipped
bind:user
bind:folder
bind:path
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
bind:jobKindsCat
on:change={reloadLogsWithoutFilterError}
{usernames}
{folders}
{paths}
/>
</div>
<div class="xl:hidden">
<MobileFilters>
<svelte:fragment slot="filters">
<span class="text-xs font-semibold leading-6">Filters</span>
<RunsFilter
bind:isSkipped
{paths}
{usernames}
{folders}
bind:jobKindsCat
bind:folder
bind:path
bind:user
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
on:change={reloadLogsWithoutFilterError}
/>
</svelte:fragment>
</MobileFilters>
</div>
</div>
</div>
<div class="p-2 w-full">
<RunChart
maxIsNow={maxTs == undefined}
jobs={completedJobs}
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
}}
/>
</div>
<div class="flex flex-col gap-1 md:flex-row w-full p-4">
<div class="flex gap-2 grow mb-2">
<div class="flex gap-1 relative max-w-36 min-w-[50px]">
<div class="text-xs absolute -top-4 truncate">Jobs waiting for a worker</div>
<div class="mt-1">{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div>
</div>
<div class="flex"
><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
>
</div>
<div class="flex flex-row gap-1 w-full max-w-xl">
<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()
}}
/>
</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()
}}
/>
</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
jobs = undefined
completedJobs = undefined
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>
</Pane>
<Pane size={50} minSize={20}>
<div class="overflow-y-hidden h-full">
<RunsTable
{jobs}
bind:selectedId
bind:nbOfJobs
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
}}
/>
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</div>