feat(frontend): Filter runs by user (#2187)

* feat(frontend): Filter runs by user

* feat(frontend): Improve UI

* feat(frontend): Add filter by folder

* feat(frontend): Clean up filters

* feat(frontend): Clean up filters

* feat(frontend): add shortcut to filter by folder

* fix(frontend): Fix filters

* fix(frontend): remove logs

* fix(frontend): Fix filter by syncing

* fix(frontend): refactor all filters

* fix(frontend): refactor all filters

* fix(frontend): refactor all filters

* fix(frontend): refactor all filters

* fix(frontend): add missing tooltips

* fix(frontend): correcly set args and result filters + handle status filter
This commit is contained in:
Faton Ramadani
2023-08-29 15:42:56 +02:00
committed by GitHub
parent dfa9ed5c00
commit 095969f125
6 changed files with 358 additions and 132 deletions
@@ -5,6 +5,8 @@
import { twMerge } from 'tailwind-merge'
import Popover from '$lib/components/Popover.svelte'
import { Info } from 'lucide-svelte'
export let value: any
export let label: string | undefined = undefined
export let iconOnly: boolean = false
@@ -14,6 +16,7 @@
export let selectedColor: string = '#3b82f6'
export let small: boolean = false
export let iconProps: Record<string, any> = {}
export let showTooltipIcon: boolean = false
const { select, selected } = getContext<ToggleButtonContext>('ToggleButtonGroup')
</script>
@@ -45,6 +48,9 @@
{#if label && !iconOnly}
{label}
{/if}
{#if showTooltipIcon}
<Info size={14} class="text-gray-400" />
{/if}
</Tab>
<svelte:fragment slot="text">
{tooltip}
+37 -3
View File
@@ -8,7 +8,16 @@
import ScheduleEditor from '../ScheduleEditor.svelte'
import Row from '../table/Row.svelte'
import Cell from '../table/Cell.svelte'
import { Calendar, Check, FastForward, Hourglass, ListFilter, Play, X } from 'lucide-svelte'
import {
Calendar,
Check,
FastForward,
Folder,
Hourglass,
ListFilter,
Play,
X
} from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import TimeAgo from '../TimeAgo.svelte'
import { forLater } from '$lib/forLater'
@@ -101,7 +110,7 @@
{#if job === undefined}
No job found
{:else}
<div class="flex flex-row space-x-2">
<div class="flex flex-row gap-1">
<div class="whitespace-nowrap text-xs font-semibold">
{#if job.script_path}
<div class="flex flex-row gap-1 items-center">
@@ -115,6 +124,20 @@
>
<ListFilter size={10} />
</Button>
{#if job.script_path?.startsWith('f/')}
<Button
size="xs2"
color="light"
on:click={() => {
// split script_path by / and get the second element
const folder = job.script_path?.split('/')[1]
dispatch('filterByFolder', folder)
}}
>
<Folder size={10} />
</Button>
{/if}
</div>
{:else if 'job_kind' in job && job.job_kind == 'preview'}
<a href="/run/{job.id}?workspace={job.workspace_id}">Preview without path </a>
@@ -158,7 +181,18 @@
</Button>
</div>
{:else}
{job.created_by}
<div class="flex flex-row gap-1 items-center">
{job.created_by}
<Button
size="xs2"
color="light"
on:click={() => {
dispatch('filterByUser', job.created_by)
}}
>
<ListFilter size={10} />
</Button>
</div>
{/if}
</Cell>
</Row>
@@ -1,17 +1,15 @@
<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'
import Toggle from '../Toggle.svelte'
export let paths: string[] = []
export let selectedPath: string | undefined = undefined
// Filters
export let path: string | null = null
export let success: boolean | undefined = undefined
export let isSkipped: boolean | undefined = undefined
export let argFilter: string
@@ -19,87 +17,187 @@
export let resultFilter: string
export let resultError: string
export let jobKindsCat: string
export let user: string | null = null
export let folder: string | null = null
// Autocomplete data
export let paths: string[] = []
export let usernames: string[] = []
export let folders: string[] = []
let copyArgFilter = argFilter
let copyResultFilter = resultFilter
export let filterBy: 'path' | 'user' | 'folder' = 'path'
let manuallySet = false
$: {
if (path !== null && path !== '' && filterBy !== 'path') {
manuallySet = true
filterBy = 'path'
} else if (user !== null && user !== '' && filterBy !== 'user') {
manuallySet = true
filterBy = 'user'
} else if (folder !== null && folder !== '' && filterBy !== 'folder') {
manuallySet = true
filterBy = 'folder'
}
}
</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="flex flex-col xl:flex-row gap-6 xl:gap-2 w-full">
<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"
/>
<span class="text-xs absolute -top-4">Filter by</span>
<ToggleButtonGroup
bind:selected={filterBy}
on:selected={() => {
if (!manuallySet) {
path = null
user = null
folder = null
} else {
manuallySet = false
}
}}
>
<ToggleButton value="path" label="Path" />
<ToggleButton value="user" label="User" />
<ToggleButton value="folder" label="Folder" />
</ToggleButtonGroup>
</div>
{/key}
</div>
{#if filterBy == 'user'}
{#key user}
<div class="relative">
{#if user}
<button
class="absolute top-2 right-2 z-50"
on:click={() => {
user = null
}}
>
<X size={14} />
</button>
{:else}
<ChevronDown class="absolute top-2 right-2" size={14} />
{/if}
<span class="text-xs absolute -top-4">User</span>
<AutoComplete
items={usernames}
value={user}
bind:selectedItem={user}
inputClassName="!h-[30px] py-1 !text-xs !w-64"
hideArrow
className={user ? '!font-bold' : ''}
dropdownClassName="!font-normal !w-64 !max-w-64"
/>
</div>
{/key}
{/if}
{#if filterBy == 'folder'}
{#key folder}
<div class="relative">
{#if folder}
<button
class="absolute top-2 right-2 z-50"
on:click={() => {
folder = null
}}
>
<X size={14} />
</button>
{:else}
<ChevronDown class="absolute top-2 right-2" size={14} />
{/if}
<span class="text-xs absolute -top-4">Folder</span>
<AutoComplete
items={folders}
value={folder}
bind:selectedItem={folder}
inputClassName="!h-[30px] py-1 !text-xs !w-64"
hideArrow
className={folder ? '!font-bold' : ''}
dropdownClassName="!font-normal !w-64 !max-w-64"
/>
</div>
{/key}
{/if}
{#if filterBy === 'path'}
{#key path}
<div class="relative">
{#if path}
<button
class="absolute top-2 right-2 z-50"
on:click={() => {
path = null
}}
>
<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={path}
bind:selectedItem={path}
inputClassName="!h-[30px] py-1 !text-xs !w-64"
hideArrow
className={path ? '!font-bold' : ''}
dropdownClassName="!font-normal !w-64 !max-w-64"
/>
</div>
{/key}
{/if}
<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)
}}
>
<ToggleButtonGroup bind:selected={jobKindsCat}>
<ToggleButton value="all" label="All" />
<ToggleButton value="runs" label="Runs" />
<ToggleButton value="previews" label="Previews" />
<ToggleButton value="dependencies" label="Dependencies" />
<ToggleButton
value="runs"
label="Runs"
showTooltipIcon
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
/>
<ToggleButton
value="previews"
label="Previews"
showTooltipIcon
tooltip="Previews are jobs that have been started in the editor as 'Tests'"
/>
<ToggleButton
value="dependencies"
label="Deps"
showTooltipIcon
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect 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))}
>
<ToggleButtonGroup bind:selected={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
>
<div class="relative w-32">
<span class="text-xs absolute -top-4"> Show Skipped Flows </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 class="flex flex-row gap-1 items-center">
<Toggle size="xs" bind:checked={isSkipped} />
<Tooltip light>Skipped flows are flows that did an early break</Tooltip>
</div>
</div>
</div>
@@ -117,14 +215,26 @@
</Button>
</svelte:fragment>
<div class="flex flex-col w-72 p-2 gap-2">
<span class="text-sm eading-6 font-semibold">Filters</span>
<div class="flex flex-row w-full items-center justify-between">
<span class="text-sm eading-6 font-semibold">Filters</span>
<Button
size="xs"
color="light"
on:click={() => {
close(null)
}}
>
Close
</Button>
</div>
<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} />
<JsonEditor on:change bind:error={argError} bind:code={copyArgFilter} />
<span class="text-xs eading-6 font-semibold">Filter by result</span>
<JsonEditor on:change bind:error={resultError} bind:code={resultFilter} />
<JsonEditor on:change bind:error={resultError} bind:code={copyResultFilter} />
<div class="flex flex-row gap-2 justify-between">
<Button
@@ -133,18 +243,22 @@
on:click={() => {
argFilter = ''
resultFilter = ''
close(null)
}}
>
Clear
</Button>
<Button
size="xs"
color="dark"
on:click={() => {
argFilter = copyArgFilter
resultFilter = copyResultFilter
close(null)
}}
>
Close
Set
</Button>
</div>
</div>
@@ -86,7 +86,7 @@
</Cell>
</tr>
{#each jobsByDay as job (job.id)}
<RunRow {job} bind:selectedId on:select on:filterByPath />
<RunRow {job} bind:selectedId on:select on:filterByPath on:filterByUser on:filterByFolder />
{/each}
{/each}
</tbody>
+9 -9
View File
@@ -360,21 +360,21 @@ export async function copyToClipboard(value?: string, sendToast = true): Promise
.then(() => true)
.catch(() => false)
} else {
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
const textArea = document.createElement('textarea')
textArea.value = value
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea);
textArea.select();
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy');
success = true;
document.execCommand('copy')
success = true
} catch (error) {
// ignore (success = false)
} finally {
textArea.remove();
textArea.remove()
}
}
@@ -1,13 +1,19 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { JobService, Job, CompletedJob, ScriptService, FlowService } from '$lib/gen'
import { setQueryWithoutLoad } from '$lib/utils'
import {
JobService,
Job,
CompletedJob,
ScriptService,
FlowService,
UserService,
FolderService
} from '$lib/gen'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import { goto } from '$app/navigation'
import RunChart from '$lib/components/RunChart.svelte'
import JobPreview from '$lib/components/runs/JobPreview.svelte'
@@ -23,11 +29,18 @@
import Toggle from '$lib/components/Toggle.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { tweened, type Tweened } from 'svelte/motion'
import { goto } from '$app/navigation'
let jobs: Job[] | undefined
let intervalId: NodeJS.Timer | undefined
let selectedId: string | undefined = undefined
// All Filters
// Filter by
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')
// Rest of filters handled by RunsFilter
let success: boolean | undefined =
$page.url.searchParams.get('success') != undefined
? $page.url.searchParams.get('success') == 'true'
@@ -37,18 +50,54 @@
? $page.url.searchParams.get('is_skipped') == 'true'
: false
let argFilter: any = $page.url.searchParams.get('arg') ?? undefined
let resultFilter: any = $page.url.searchParams.get('result') ?? undefined
let argFilter: any = $page.url.searchParams.get('arg')
? JSON.parse(decodeURIComponent($page.url.searchParams.get('arg') ?? '{}'))
: undefined
let resultFilter: any = $page.url.searchParams.get('result')
? JSON.parse(decodeURIComponent($page.url.searchParams.get('result') ?? '{}'))
: undefined
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
let jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
// Handled on the main page
let minTs = $page.url.searchParams.get('min_ts') ?? undefined
let maxTs = $page.url.searchParams.get('max_ts') ?? undefined
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
// This reactive statement is used to sync the url with the current state of the filters
$: {
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())
}
// 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, { replaceState: true })
}
let nbOfJobs = 30
let queue_count: Tweened<number> | undefined = undefined
$: path = $page.params.path
$: jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
$: jobKinds = computeJobKinds(jobKindsCat)
function computeJobKinds(jobKindsCat: string | undefined): string {
@@ -63,7 +112,8 @@
}
}
$: ($workspaceStore && loadJobs()) || (path && success && isSkipped && jobKinds)
$: ($workspaceStore && loadJobs()) ||
(path && success && isSkipped && jobKinds && user && folder && minTs && maxTs)
async function fetchJobs(
startedBefore: string | undefined,
@@ -74,7 +124,9 @@
createdOrStartedBefore: startedBefore,
createdOrStartedAfter: startedAfter,
schedulePath,
scriptPathExact: path === '' ? undefined : path,
scriptPathExact: path === null || path === '' ? undefined : path,
createdBy: user === null || user === '' ? undefined : user,
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
jobKinds,
success,
isSkipped,
@@ -110,6 +162,7 @@
queue_count = tweened(qc, { duration: 1000 })
}
}
async function syncer() {
getCount()
if (sync && jobs && maxTs == undefined) {
@@ -150,6 +203,8 @@
onMount(() => {
mounted = true
loadPaths()
loadUsernames()
loadFolders()
intervalId = setInterval(syncer, 5000)
document.addEventListener('visibilitychange', () => {
@@ -177,6 +232,18 @@
}
let paths: string[] = []
let usernames: string[] = []
let folders: string[] = []
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 ?? '' })
@@ -184,15 +251,6 @@
paths = npaths_scripts.concat(npaths_flows).sort()
}
async function syncTsWithURL(minTs?: string, maxTs?: string) {
setQueryWithoutLoad($page.url, [
{ key: 'min_ts', value: minTs },
{ key: 'max_ts', value: maxTs }
])
}
$: syncTsWithURL(minTs, maxTs)
let completedJobs: CompletedJob[] | undefined = undefined
function computeCompletedJobs() {
@@ -200,15 +258,6 @@
jobs?.filter((x) => x.type == 'CompletedJob').map((x) => x as CompletedJob) ?? []
}
let searchPath = ''
$: searchPath = path
$: searchPath && onSearchPathChange()
function onSearchPathChange() {
goto(`/runs/${searchPath}?${$page.url.searchParams.toString()}`)
}
let argError = ''
let resultError = ''
let filterTimeout: NodeJS.Timeout | undefined = undefined
@@ -320,20 +369,19 @@
<div class="hidden xl:block">
<RunsFilter
bind:isSkipped
{paths}
{jobKindsCat}
bind:selectedPath={searchPath}
bind:user
bind:folder
bind:path
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
bind:jobKindsCat
on:change={reloadLogsWithoutFilterError}
on:clearFilters={() => {
minTs = undefined
maxTs = undefined
autoRefresh = true
}}
{usernames}
{folders}
{paths}
/>
</div>
<div class="xl:hidden">
@@ -343,19 +391,18 @@
<RunsFilter
bind:isSkipped
{paths}
{jobKindsCat}
bind:selectedPath={searchPath}
{usernames}
{folders}
bind:jobKindsCat
bind:folder
bind:path
bind:user
bind:success
bind:argFilter
bind:resultFilter
bind:argError
bind:resultError
on:change={reloadLogsWithoutFilterError}
on:clearFilters={() => {
minTs = undefined
maxTs = undefined
autoRefresh = true
}}
/>
</svelte:fragment>
</MobileFilters>
@@ -370,7 +417,6 @@
on:zoom={async (e) => {
minTs = e.detail.min.toISOString()
maxTs = e.detail.max.toISOString()
loadJobs()
}}
/>
</div>
@@ -403,7 +449,6 @@
label="Min datetimes"
on:change={async ({ detail }) => {
minTs = new Date(detail).toISOString()
loadJobs()
}}
/>
</div>
@@ -417,7 +462,6 @@
label="Max datetimes"
on:change={async ({ detail }) => {
maxTs = new Date(detail).toISOString()
loadJobs()
}}
/>
</div>
@@ -431,6 +475,7 @@
on:click={() => {
minTs = undefined
maxTs = undefined
autoRefresh = true
jobs = undefined
completedJobs = undefined
@@ -484,7 +529,19 @@
bind:selectedId
bind:nbOfJobs
on:filterByPath={(e) => {
searchPath = e.detail
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
}}
/>
{:else}
@@ -516,6 +573,21 @@
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>