mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
* feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::<u32>() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
464 lines
14 KiB
Svelte
464 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { base } from '$lib/base'
|
|
import { displayDate, msToSec, emptyString } from '$lib/utils'
|
|
import { onDestroy } from 'svelte'
|
|
import { getDbClockNow } from '$lib/forLater'
|
|
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
|
|
import TimelineBar from './TimelineBar.svelte'
|
|
import LogViewer from './LogViewer.svelte'
|
|
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
|
import { CheckCircle2, XCircle } from 'lucide-svelte'
|
|
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
|
|
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
|
import { Button } from '$lib/components/common'
|
|
import { Alert } from '$lib/components/common'
|
|
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
|
|
interface Props {
|
|
flow_status: Record<string, WorkflowStatus>
|
|
flowDone?: boolean
|
|
stepResults?: Record<string, any>
|
|
result?: any
|
|
success?: boolean
|
|
autoExpandResult?: boolean
|
|
jobId?: string
|
|
}
|
|
|
|
let {
|
|
flow_status,
|
|
flowDone = false,
|
|
stepResults = {},
|
|
result = undefined,
|
|
success = true,
|
|
autoExpandResult = false,
|
|
jobId = undefined
|
|
}: Props = $props()
|
|
|
|
let resultExpanded = $state(false)
|
|
|
|
// Auto-expand result row when job completes (only if requested)
|
|
$effect(() => {
|
|
if (autoExpandResult && flowDone && result !== undefined) {
|
|
resultExpanded = true
|
|
}
|
|
})
|
|
|
|
let now = $state(getDbClockNow().getTime())
|
|
|
|
let interval = setInterval(() => {
|
|
if (!max) {
|
|
now = getDbClockNow().getTime()
|
|
}
|
|
}, 30)
|
|
|
|
onDestroy(() => {
|
|
interval && clearInterval(interval)
|
|
pollInterval && clearInterval(pollInterval)
|
|
})
|
|
|
|
let min = $derived(
|
|
Object.values(flow_status).reduce(
|
|
(a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity),
|
|
Infinity
|
|
)
|
|
)
|
|
let max = $derived(
|
|
flowDone
|
|
? Object.values(flow_status).reduce((a, b) => {
|
|
if (!b.started_at) return a
|
|
const startedAt = new Date(b.started_at).getTime()
|
|
// For cancelled steps without duration_ms, use `now` as end time
|
|
const endAt = b.duration_ms != undefined ? startedAt + b.duration_ms : now
|
|
return Math.max(a, endAt)
|
|
}, 0)
|
|
: undefined
|
|
)
|
|
let total = $derived(flowDone && max ? max - min : Math.max(now - min, 2000))
|
|
|
|
// Collapsible state
|
|
let expandedRows: Record<string, boolean> = $state({})
|
|
let childJobs: Record<string, Job & { result?: any }> = $state({})
|
|
let loadingJobs: Record<string, boolean> = $state({})
|
|
|
|
function isStep(key: string): boolean {
|
|
return key.startsWith('_step/')
|
|
}
|
|
|
|
function stepKey(key: string): string {
|
|
return key.slice('_step/'.length)
|
|
}
|
|
|
|
function toggleRow(id: string) {
|
|
expandedRows[id] = !expandedRows[id]
|
|
if (expandedRows[id] && !isStep(id) && !childJobs[id]) {
|
|
fetchChildJob(id)
|
|
}
|
|
}
|
|
|
|
async function fetchChildJob(id: string) {
|
|
const ws = $workspaceStore
|
|
if (!ws) return
|
|
loadingJobs[id] = true
|
|
try {
|
|
const job = await JobService.getJob({ workspace: ws, id })
|
|
childJobs[id] = job as Job & { result?: any }
|
|
} catch (e) {
|
|
console.error(`Failed to fetch job ${id}:`, e)
|
|
} finally {
|
|
loadingJobs[id] = false
|
|
}
|
|
}
|
|
|
|
// Poll for updates on expanded in-progress jobs
|
|
let pollInterval = setInterval(() => {
|
|
for (const [id, v] of Object.entries(flow_status)) {
|
|
if (isStep(id)) continue
|
|
const isRunning = !flowDone && v.duration_ms == undefined && v.started_at != undefined
|
|
if (expandedRows[id] && isRunning) {
|
|
fetchChildJob(id)
|
|
}
|
|
}
|
|
}, 2000)
|
|
|
|
// Approval action state
|
|
let approvalLoading: Record<string, boolean> = $state({})
|
|
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
|
|
|
|
async function handleApprove(key: string, formSchema: any) {
|
|
const ws = $workspaceStore
|
|
if (!ws || !jobId) return
|
|
approvalLoading[key] = true
|
|
try {
|
|
const payload =
|
|
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
|
|
await JobService.resumeSuspended({
|
|
workspace: ws,
|
|
jobId: jobId,
|
|
requestBody: { payload, approved: true }
|
|
})
|
|
sendUserToast('Approval submitted')
|
|
} catch (e: any) {
|
|
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
|
|
} finally {
|
|
approvalLoading[key] = false
|
|
}
|
|
}
|
|
|
|
let cancelLoading = $state(false)
|
|
async function handleCancel() {
|
|
const ws = $workspaceStore
|
|
if (!ws || !jobId) return
|
|
cancelLoading = true
|
|
try {
|
|
await JobService.resumeSuspended({
|
|
workspace: ws,
|
|
jobId: jobId,
|
|
requestBody: { approved: false }
|
|
})
|
|
sendUserToast('Job cancelled')
|
|
} catch (e: any) {
|
|
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
|
|
} finally {
|
|
cancelLoading = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if flow_status}
|
|
<div class="divide-y border-b">
|
|
<div class="px-2 py-2 grid grid-cols-12 w-full">
|
|
<div></div>
|
|
<div class="col-span-11 pt-1 px-2 flex text-2xs text-secondary justify-between">
|
|
<div>{min ? displayDate(new Date(min), true) : ''}</div>
|
|
{#if max && min}
|
|
<div class="hidden lg:block">{msToSec(max - min)}s</div>
|
|
{/if}
|
|
<div class="flex gap-1 items-center font-mono">
|
|
{max ? displayDate(new Date(max), true) : ''}
|
|
{#if !max && min}
|
|
{#if now}
|
|
{msToSec(now - min, 3)}s
|
|
{/if}
|
|
<Loader2 size={14} class="animate-spin" />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-row-reverse items-center text-sm text-secondary p-2">
|
|
<div class="flex gap-4 items-center text-2xs">
|
|
<div class="flex gap-2 items-center">
|
|
<div>Waiting for executor</div>
|
|
<div class="h-4 w-4 bg-gray-500"></div>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<div>Execution</div>
|
|
<div class="h-4 w-4 bg-blue-500/90"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{#each Object.entries(flow_status).sort(([, a], [, b]) => {
|
|
const ta = new Date(a.started_at ?? a.scheduled_for ?? 0).getTime()
|
|
const tb = new Date(b.started_at ?? b.scheduled_for ?? 0).getTime()
|
|
return ta - tb
|
|
}) as [k, v] (k)}
|
|
{@const isInlineStep = isStep(k)}
|
|
{@const isSleep = (v as any).sleep_duration_s != undefined}
|
|
{@const isApproval = (v as any).approval === true}
|
|
{@const isRunning = !flowDone && v.duration_ms == undefined && v.started_at != undefined}
|
|
{@const isDone = v.duration_ms != undefined || flowDone}
|
|
{@const isExpanded = expandedRows[k] ?? false}
|
|
<div class="border-b last:border-b-0">
|
|
{#if isSleep}
|
|
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2 text-tertiary">
|
|
<div class="w-3 flex-shrink-0"></div>
|
|
<Moon size={12} class="flex-shrink-0" />
|
|
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
|
|
</div>
|
|
{:else if isApproval}
|
|
{@const selfApprovalDisabled = (v as any).self_approval_disabled === true}
|
|
{@const formSchema = (v as any).form?.schema ?? (v as any).form}
|
|
{@const hasForm =
|
|
formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0}
|
|
{@const canApprove = !isDone && jobId}
|
|
<div class="w-full px-2 py-1.5 text-xs">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-3 flex-shrink-0"></div>
|
|
<ShieldCheck
|
|
size={12}
|
|
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
|
|
/>
|
|
<span class="italic text-secondary">
|
|
{v.name ?? stepKey(k)}
|
|
</span>
|
|
{#if !isDone}
|
|
<span class="text-tertiary flex items-center gap-1">
|
|
<Loader2 size={12} class="animate-spin" />
|
|
waiting
|
|
</span>
|
|
{#if canApprove}
|
|
<div class="ml-auto flex gap-1">
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
disabled={approvalLoading[k]}
|
|
onclick={() => handleApprove(k, formSchema)}
|
|
>
|
|
Approve
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
disabled={approvalLoading[k] || cancelLoading}
|
|
onclick={() => handleCancel()}
|
|
>
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
|
|
{/if}
|
|
</div>
|
|
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
|
|
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
|
|
Self-approval is disabled but allowed because you are an admin/owner
|
|
</div>
|
|
{/if}
|
|
{#if canApprove && hasForm}
|
|
<div class="mt-2 ml-5 max-w-md">
|
|
{#if emptyString($enterpriseLicense)}
|
|
<Alert
|
|
type="warning"
|
|
title="Adding a form to the approval page is an EE feature"
|
|
/>
|
|
{:else}
|
|
<SchemaForm
|
|
onlyMaskPassword
|
|
noVariablePicker
|
|
schema={{
|
|
properties: formSchema,
|
|
order: Object.keys(formSchema)
|
|
}}
|
|
bind:args={approvalFormArgs[k]}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<button
|
|
class="w-full px-2 py-2 text-xs hover:bg-surface-hover cursor-pointer flex items-center gap-2"
|
|
onclick={() => toggleRow(k)}
|
|
>
|
|
<div class="inline-flex gap-1 items-center flex-shrink-0">
|
|
<div class="w-3 flex-shrink-0">
|
|
{#if isExpanded}
|
|
<ChevronDown size={12} />
|
|
{:else}
|
|
<ChevronRight size={12} />
|
|
{/if}
|
|
</div>
|
|
{#if isInlineStep}
|
|
<span class="whitespace-nowrap text-secondary italic">
|
|
{v.name ?? stepKey(k)}
|
|
</span>
|
|
{:else}
|
|
<a
|
|
target="_blank"
|
|
class="inline-flex gap-2 items-baseline hover:underline whitespace-nowrap"
|
|
href="{base}/run/{k}"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
{v.name ?? k}
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center pt-1 min-h-6 w-full min-w-0">
|
|
{#if min && total}
|
|
{@const scheduledFor = v?.scheduled_for
|
|
? new Date(v?.scheduled_for).getTime()
|
|
: undefined}
|
|
{@const startedAt = v?.started_at ? new Date(v?.started_at).getTime() : undefined}
|
|
{@const waitingLen = scheduledFor
|
|
? startedAt
|
|
? startedAt - scheduledFor
|
|
: now - scheduledFor
|
|
: 0}
|
|
|
|
<div class="flex w-full">
|
|
{#if isInlineStep}
|
|
<!-- Inline steps have no waiting phase -->
|
|
{#if startedAt}
|
|
<TimelineBar
|
|
position="center"
|
|
id={k}
|
|
{total}
|
|
{min}
|
|
started_at={startedAt}
|
|
len={v.duration_ms ?? now - startedAt}
|
|
running={!flowDone && v.duration_ms == undefined}
|
|
/>
|
|
{/if}
|
|
{:else}
|
|
<TimelineBar
|
|
position="left"
|
|
id={k}
|
|
{total}
|
|
{min}
|
|
gray
|
|
started_at={scheduledFor}
|
|
len={waitingLen < 100 ? 0 : waitingLen - 100}
|
|
running={!flowDone && startedAt == undefined}
|
|
/>
|
|
{#if startedAt}
|
|
<TimelineBar
|
|
position={waitingLen < 100 ? 'center' : 'right'}
|
|
id={k}
|
|
{total}
|
|
{min}
|
|
concat
|
|
started_at={startedAt}
|
|
len={v.duration_ms ?? now - startedAt}
|
|
running={!flowDone && v.duration_ms == undefined}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
|
|
{#if isExpanded}
|
|
<div class="border-t bg-surface-secondary px-4 py-2">
|
|
{#if isInlineStep}
|
|
<!-- Inline step: show result from checkpoint -->
|
|
{@const result = stepResults[stepKey(k)]}
|
|
{#if isDone && result !== undefined}
|
|
<div>
|
|
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
|
<div class="max-h-40 overflow-auto">
|
|
<ObjectViewer json={result} pureViewer />
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
|
|
{/if}
|
|
{:else if loadingJobs[k] && !childJobs[k]}
|
|
<div class="flex items-center gap-2 text-xs text-secondary py-1">
|
|
<Loader2 size={12} class="animate-spin" />
|
|
Loading...
|
|
</div>
|
|
{:else if childJobs[k]}
|
|
{@const job = childJobs[k]}
|
|
<!-- Logs -->
|
|
{#if job.logs || isRunning}
|
|
<div class="mb-2">
|
|
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
|
|
<LogViewer
|
|
content={job.logs ?? ''}
|
|
jobId={k}
|
|
isLoading={isRunning}
|
|
small
|
|
tag={job.tag}
|
|
download={false}
|
|
noMaxH={false}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Result -->
|
|
{#if isDone && job.result !== undefined}
|
|
<div>
|
|
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
|
<div class="max-h-40 overflow-auto">
|
|
<ObjectViewer json={job.result} pureViewer />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="text-xs text-secondary py-1">No data available</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{#if flowDone && result !== undefined}
|
|
<div class="border-b last:border-b-0">
|
|
<button
|
|
class="w-full px-2 py-2 text-xs hover:bg-surface-hover cursor-pointer flex items-center gap-2"
|
|
onclick={() => (resultExpanded = !resultExpanded)}
|
|
>
|
|
<div class="inline-flex gap-1 items-center flex-shrink-0">
|
|
<div class="w-3 flex-shrink-0">
|
|
{#if resultExpanded}
|
|
<ChevronDown size={12} />
|
|
{:else}
|
|
<ChevronRight size={12} />
|
|
{/if}
|
|
</div>
|
|
<span class="whitespace-nowrap font-semibold flex items-center gap-1">
|
|
{#if success}
|
|
<CheckCircle2 size={12} class="text-green-600" />
|
|
{:else}
|
|
<XCircle size={12} class="text-red-600" />
|
|
{/if}
|
|
Result
|
|
</span>
|
|
</div>
|
|
</button>
|
|
{#if resultExpanded}
|
|
<div class="border-t bg-surface-secondary px-4 py-2">
|
|
<div class="max-h-60 overflow-auto">
|
|
<ObjectViewer json={result} pureViewer />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Loader2 class="animate-spin" />
|
|
{/if}
|