mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
feat: timelines for apps
This commit is contained in:
@@ -4651,6 +4651,7 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
- $ref: "#/components/parameters/GetStarted"
|
||||
responses:
|
||||
"200":
|
||||
description: result
|
||||
@@ -4662,6 +4663,8 @@ paths:
|
||||
completed:
|
||||
type: boolean
|
||||
result: {}
|
||||
started:
|
||||
type: boolean
|
||||
required:
|
||||
- completed
|
||||
- result
|
||||
@@ -6405,6 +6408,11 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
GetStarted:
|
||||
name: get_started
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
schemas:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas"
|
||||
|
||||
@@ -2758,26 +2758,54 @@ async fn get_completed_job_result(
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CompletedJobResult<'c> {
|
||||
started: Option<bool>,
|
||||
completed: bool,
|
||||
result: Option<&'c JsonRawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GetCompletedJobQuery {
|
||||
get_started: Option<bool>,
|
||||
}
|
||||
|
||||
async fn get_completed_job_result_maybe(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
|
||||
) -> error::Result<Response> {
|
||||
let result_o =
|
||||
sqlx::query("SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2")
|
||||
.bind(id)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(result) = result_o {
|
||||
let res = RawResult::from_row(&result)?;
|
||||
Ok(Json(CompletedJobResult { completed: true, result: Some(res.result) }).into_response())
|
||||
Ok(Json(CompletedJobResult {
|
||||
started: Some(true),
|
||||
completed: true,
|
||||
result: Some(res.result),
|
||||
})
|
||||
.into_response())
|
||||
} else if get_started.is_some_and(|x| x) {
|
||||
let started = sqlx::query_scalar!(
|
||||
"SELECT running FROM queue WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
Ok(
|
||||
Json(CompletedJobResult { started: Some(started), completed: false, result: None })
|
||||
.into_response(),
|
||||
)
|
||||
} else {
|
||||
Ok(Json(CompletedJobResult { completed: false, result: None }).into_response())
|
||||
Ok(
|
||||
Json(CompletedJobResult { started: None, completed: false, result: None })
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,14 +121,16 @@
|
||||
>{#if min && total}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
{#each items?.[k] ?? [] as b}
|
||||
<TimelineBar
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
started_at={b.started_at}
|
||||
len={b?.started_at ? b?.duration_ms ?? now - b?.started_at : 0}
|
||||
running={b?.duration_ms == undefined}
|
||||
/>
|
||||
<div class="flex w-full">
|
||||
<TimelineBar
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
started_at={b.started_at}
|
||||
len={b?.started_at ? b?.duration_ms ?? now - b?.started_at : 0}
|
||||
running={b?.duration_ms == undefined}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}</div
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export let job: { completed: boolean; result: any; id: string } | undefined = undefined
|
||||
export let workspaceOverride: string | undefined = undefined
|
||||
export let notfound = false
|
||||
export let isEditor = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -26,8 +27,10 @@
|
||||
|
||||
$: isLoading = currentId !== undefined
|
||||
|
||||
let running = false
|
||||
export async function abstractRun(fn: () => Promise<string>) {
|
||||
try {
|
||||
running = false
|
||||
isLoading = true
|
||||
clearCurrentJob()
|
||||
const startedAt = Date.now()
|
||||
@@ -106,6 +109,7 @@
|
||||
export async function cancelJob() {
|
||||
const id = currentId
|
||||
if (id) {
|
||||
dispatch('cancel', id)
|
||||
currentId = undefined
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
@@ -121,6 +125,7 @@
|
||||
|
||||
export async function clearCurrentJob() {
|
||||
if (currentId) {
|
||||
dispatch('cancel', currentId)
|
||||
job = undefined
|
||||
await cancelJob()
|
||||
}
|
||||
@@ -145,8 +150,13 @@
|
||||
try {
|
||||
let maybe_job = await JobService.getCompletedJobResultMaybe({
|
||||
workspace: workspace ?? '',
|
||||
id
|
||||
id,
|
||||
getStarted: isEditor
|
||||
})
|
||||
if (maybe_job.started && !running) {
|
||||
running = true
|
||||
dispatch('running', id)
|
||||
}
|
||||
if (maybe_job.completed) {
|
||||
isCompleted = true
|
||||
if (currentId === id) {
|
||||
|
||||
@@ -9,25 +9,31 @@
|
||||
export let len: number
|
||||
export let id: string
|
||||
export let running: boolean
|
||||
export let concat: boolean = false
|
||||
export let gray: boolean = false
|
||||
</script>
|
||||
|
||||
{#if min && started_at}
|
||||
<div class="flex w-full">
|
||||
{#if min && started_at != undefined}
|
||||
{#if !concat}
|
||||
<div style="width: {((started_at - min) / total) * 100}%" class="h-4" />
|
||||
<Popover
|
||||
style="width: {(len / total) * 100}%"
|
||||
class="h-4 {running
|
||||
? 'bg-blue-400/90'
|
||||
: 'bg-blue-500/90'} rounded-sm center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
|
||||
{/if}
|
||||
<Popover
|
||||
style="width: {(len / total) * 100}%"
|
||||
class="h-4 {gray
|
||||
? 'bg-gray-500'
|
||||
: running
|
||||
? 'bg-blue-400/90'
|
||||
: 'bg-blue-500/90'} rounded-sm center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
|
||||
>
|
||||
<svelte:fragment slot="text"
|
||||
><a href="/run/{id}" class="inline-flex items-center gap-1" target="_blank"
|
||||
>{id} <ExternalLink size={14} /></a
|
||||
></svelte:fragment
|
||||
>
|
||||
<svelte:fragment slot="text"
|
||||
><a href="/run/{id}" class="inline-flex items-center gap-1" target="_blank"
|
||||
>{id} <ExternalLink size={14} /></a
|
||||
></svelte:fragment
|
||||
>
|
||||
{#if len > 0}
|
||||
<span class={len / total < 0.09 ? '-ml-14 text-primary' : ''}
|
||||
>{#if len}{msToSec(len, 1)}s{/if}</span
|
||||
>
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
@@ -344,14 +344,15 @@
|
||||
...oldJob,
|
||||
...(result ? { result } : {}),
|
||||
...(transformer ? { transformer } : {}),
|
||||
duration_ms: oldJob?.started_at ? Date.now() - oldJob?.started_at : 1
|
||||
error,
|
||||
duration_ms: oldJob?.started_compute_at ? Date.now() - oldJob?.started_compute_at : 1
|
||||
}
|
||||
|
||||
$jobsById[jobId] = job
|
||||
}
|
||||
|
||||
if (error) {
|
||||
$errorByComponent[id] = { id, error }
|
||||
$errorByComponent[id] = { id: jobId, error }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,9 +450,9 @@
|
||||
let cancellableRun: ((inlineScript?: InlineScript) => CancelablePromise<void>) | undefined =
|
||||
undefined
|
||||
|
||||
let didInitialRun = false
|
||||
let didInitialRun = $initialized.initialized
|
||||
onMount(() => {
|
||||
didInitialRun = false
|
||||
didInitialRun = $initialized.initialized
|
||||
cancellableRun = (inlineScript?: InlineScript) => {
|
||||
let rejectCb: (err: Error) => void
|
||||
let p: Partial<CancelablePromise<void>> = new Promise<void>((resolve, reject) => {
|
||||
@@ -523,7 +524,9 @@
|
||||
{/if}
|
||||
|
||||
<ResultJobLoader
|
||||
{isEditor}
|
||||
on:started={(e) => {
|
||||
console.log('started', e.detail)
|
||||
loading = true
|
||||
setJobId(e.detail)
|
||||
dispatch('started', e.detail)
|
||||
@@ -537,8 +540,19 @@
|
||||
on:cancel={(e) => {
|
||||
let jobId = e.detail
|
||||
let job = $jobsById[jobId]
|
||||
if (job && job.started_at) {
|
||||
$jobsById[jobId] = { ...job, duration_ms: Date.now() - job.started_at }
|
||||
if (job && job.started_at && !job.duration_ms) {
|
||||
$jobsById[jobId] = {
|
||||
...job,
|
||||
duration_ms: Date.now() - (job.started_compute_at ?? job.started_at)
|
||||
}
|
||||
}
|
||||
}}
|
||||
on:running={(e) => {
|
||||
console.log('running', e.detail)
|
||||
let jobId = e.detail
|
||||
let job = $jobsById[jobId]
|
||||
if (job && !job.started_compute_at) {
|
||||
$jobsById[jobId] = { ...job, started_compute_at: Date.now() }
|
||||
}
|
||||
}}
|
||||
on:doneError={(e) => {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
let scale = writable(100)
|
||||
|
||||
setContext<AppEditorContext>('AppEditorContext', {
|
||||
refreshComponents: writable(undefined),
|
||||
history,
|
||||
pickVariableCallback,
|
||||
movingcomponents: writable(undefined),
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
Laptop2,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
Smartphone
|
||||
} from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
@@ -106,7 +107,8 @@
|
||||
openDebugRun
|
||||
} = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
const { history, jobsDrawerOpen } = getContext<AppEditorContext>('AppEditorContext')
|
||||
const { history, jobsDrawerOpen, refreshComponents } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
const loading = {
|
||||
publish: false,
|
||||
@@ -657,9 +659,12 @@
|
||||
'border flex gap-1 truncate justify-between flex-row w-full items-center p-2 rounded-md cursor-pointer hover:bg-surface-secondary hover:text-blue-400',
|
||||
selectedJob.error ? 'border border-red-500 text-primary' : '',
|
||||
selectedJob.error && $errorByComponent[selectedJob.component]?.id == id
|
||||
? 'bg-red-400'
|
||||
: '',
|
||||
selectedJobId == id ? 'bg-surface-secondary text-blue-600' : ''
|
||||
? selectedJobId == id
|
||||
? 'bg-red-600 !border-blue-600'
|
||||
: 'bg-red-400'
|
||||
: selectedJobId == id
|
||||
? 'text-blue-600'
|
||||
: ''
|
||||
)}
|
||||
on:click={() => {
|
||||
selectedJobId = id
|
||||
@@ -827,6 +832,16 @@
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
on:click={() => {
|
||||
$refreshComponents?.()
|
||||
}}
|
||||
size="xs"
|
||||
title="Refresh App"
|
||||
>
|
||||
Refresh App <RefreshCw size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
color="light"
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
let debounced = debounce(() => computeItems($jobs), 30)
|
||||
$: $jobs && $jobsById && debounced()
|
||||
|
||||
let items: Record<string, { started_at?: number; duration_ms?: number; id: string }[]> = {}
|
||||
let items: Record<
|
||||
string,
|
||||
{ started_at?: number; started_compute_at?: number; duration_ms?: number; id: string }[]
|
||||
> = {}
|
||||
|
||||
export function reset() {
|
||||
min = undefined
|
||||
@@ -27,7 +30,10 @@
|
||||
|
||||
let isStillRunning = false
|
||||
|
||||
let nitems: Record<string, { started_at?: number; duration_ms?: number; id: string }[]> = {}
|
||||
let nitems: Record<
|
||||
string,
|
||||
{ started_at?: number; started_compute_at?: number; duration_ms?: number; id: string }[]
|
||||
> = {}
|
||||
jobs.forEach((k) => {
|
||||
let v = $jobsById[k]
|
||||
if (v.started_at) {
|
||||
@@ -41,8 +47,8 @@
|
||||
isStillRunning = true
|
||||
}
|
||||
if (!isStillRunning) {
|
||||
if (v.started_at && v.duration_ms) {
|
||||
let lmax = v.started_at + v.duration_ms
|
||||
if (v.started_compute_at && v.duration_ms) {
|
||||
let lmax = v.started_compute_at + v.duration_ms
|
||||
if (!nmax) {
|
||||
nmax = lmax
|
||||
} else {
|
||||
@@ -53,7 +59,12 @@
|
||||
if (!nitems[v.component]) {
|
||||
nitems[v.component] = []
|
||||
}
|
||||
nitems[v.component].push({ started_at: v.started_at, duration_ms: v.duration_ms, id: v.job })
|
||||
nitems[v.component].push({
|
||||
started_at: v.started_at,
|
||||
duration_ms: v.duration_ms,
|
||||
started_compute_at: v.started_compute_at,
|
||||
id: v.job
|
||||
})
|
||||
})
|
||||
|
||||
Object.values(nitems).forEach((v) => {
|
||||
@@ -96,6 +107,19 @@
|
||||
{JSON.stringify(items, null, 4)}
|
||||
</pre> -->
|
||||
<div class="divide-y">
|
||||
<div class="flex flex-row-reverse mb-2 items-center text-sm text-secondary px-2">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Waiting for executor</div>
|
||||
<div class="h-4 w-4 bg-gray-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Execution</div>
|
||||
<div class="h-4 w-4 bg-blue-500/90" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each Object.entries(items) as [k, v]}
|
||||
<div class="px-2 py-2 grid grid-cols-12 w-full"
|
||||
><div class="col-span-2">{k}</div>
|
||||
@@ -103,14 +127,33 @@
|
||||
>{#if min && total}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
{#each v ?? [] as b}
|
||||
<TimelineBar
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
started_at={b.started_at}
|
||||
len={b?.started_at ? b?.duration_ms ?? now - b?.started_at : 0}
|
||||
running={b?.duration_ms == undefined}
|
||||
/>
|
||||
{@const waitingLen = b?.started_at
|
||||
? b.started_compute_at
|
||||
? b.started_compute_at - b?.started_at
|
||||
: now - b?.started_at
|
||||
: 0}
|
||||
<div class="flex w-full">
|
||||
<TimelineBar
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
gray
|
||||
started_at={b.started_at}
|
||||
len={waitingLen < 100 ? 0 : waitingLen}
|
||||
running={b?.started_compute_at == undefined}
|
||||
/>
|
||||
{#if b.started_compute_at}
|
||||
<TimelineBar
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
concat
|
||||
started_at={b.started_compute_at}
|
||||
len={b.started_compute_at ? b?.duration_ms ?? now - b?.started_compute_at : 0}
|
||||
running={b?.duration_ms == undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}</div
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import Button from '../../common/button/Button.svelte'
|
||||
import type { AppViewerContext } from '../types'
|
||||
import type { AppEditorContext, AppViewerContext } from '../types'
|
||||
import { allItems } from '../utils'
|
||||
import ButtonDropdown from '$lib/components/common/button/ButtonDropdown.svelte'
|
||||
import { MenuItem } from '@rgossiaux/svelte-headlessui'
|
||||
import { classNames } from '$lib/utils'
|
||||
|
||||
const { runnableComponents, app, initialized } = getContext<AppViewerContext>('AppViewerContext')
|
||||
const appEditorContext = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
let loading: boolean = false
|
||||
let timeout: NodeJS.Timer | undefined = undefined
|
||||
let interval: number | undefined = undefined
|
||||
@@ -17,10 +19,16 @@
|
||||
|
||||
$: !firstLoad &&
|
||||
$initialized.initializedComponents?.length ==
|
||||
allItems($app.grid, $app.subgrids).length + $app.hiddenInlineScripts.length &&
|
||||
allItems($app.grid, $app.subgrids).length + ($app.hiddenInlineScripts?.length ?? 0) &&
|
||||
refresh()
|
||||
$: componentNumber = Object.values($runnableComponents).filter((x) => x.autoRefresh).length
|
||||
|
||||
onMount(() => {
|
||||
if (appEditorContext) {
|
||||
appEditorContext.refreshComponents.set(refresh)
|
||||
}
|
||||
})
|
||||
|
||||
function onClick(stopAfterClear = true) {
|
||||
if (timeout) {
|
||||
clearInterval(timeout)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const { app } = getContext<AppViewerContext>('AppViewerContext')
|
||||
</script>
|
||||
|
||||
{#each $app.hiddenInlineScripts as action, index}
|
||||
{#each $app.hiddenInlineScripts ?? [] as action, index}
|
||||
{#if !action.hidden}
|
||||
<BackgroundScriptOutput id={BG_PREFIX + index} name={action.name} first={index === 0} />
|
||||
{/if}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
renameComponent(from, to, item.data)
|
||||
})
|
||||
|
||||
$app.hiddenInlineScripts.forEach((x) => {
|
||||
$app.hiddenInlineScripts?.forEach((x) => {
|
||||
processRunnable(from, to, x)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ export type AppViewerContext = {
|
||||
error?: any
|
||||
transformer?: { result?: string; error?: string }
|
||||
started_at?: number
|
||||
started_compute_at?: number
|
||||
duration_ms?: number
|
||||
}>>,
|
||||
noBackend: boolean
|
||||
@@ -249,6 +250,7 @@ export type AppViewerContext = {
|
||||
}
|
||||
|
||||
export type AppEditorContext = {
|
||||
refreshComponents: Writable<(() => void) | undefined>
|
||||
history: History<App> | undefined
|
||||
pickVariableCallback: Writable<((path: string) => void) | undefined>
|
||||
selectedComponentInEditor: Writable<string | undefined>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function isFlowTainted(flow: Flow) {
|
||||
}
|
||||
|
||||
export function isAppTainted(app: App) {
|
||||
return !(app.grid.length === 0 && app.hiddenInlineScripts.length === 0)
|
||||
return !(app.grid.length === 0 && app.hiddenInlineScripts?.length === 0)
|
||||
}
|
||||
|
||||
export function updateFlowModuleById(
|
||||
|
||||
Reference in New Issue
Block a user