diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d3ae32969a..4fc4ffdfe0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7777,6 +7777,10 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/JobId" + - name: remove_ansi_warnings + in: query + schema: + type: boolean responses: "200": description: job details @@ -7785,6 +7789,23 @@ paths: schema: type: string + /w/{workspace}/jobs_u/get_completed_logs_tail/{id}: + get: + summary: get completed job logs tail + operationId: getCompletedJobLogsTail + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: completed job logs tail + content: + text/plain: + schema: + type: string + /w/{workspace}/jobs_u/get_args/{id}: get: summary: get job args diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index f7312e4929..2e1296c7e1 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -272,6 +272,7 @@ pub fn workspace_unauthed_service() -> Router { .route("/get_root_job_id/:id", get(get_root_job)) .route("/get/:id", get(get_job)) .route("/get_logs/:id", get(get_job_logs)) + .route("/get_completed_logs_tail/:id", get(get_completed_job_logs_tail)) .route("/get_args/:id", get(get_args)) .route("/get_flow_debug_info/:id", get(get_flow_job_debug_info)) .route("/completed/get/:id", get(get_completed_job)) @@ -1361,11 +1362,55 @@ async fn get_logs_from_disk( return None; } +async fn get_completed_job_logs_tail( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::JsonResult { + + let tags = opt_authed + .as_ref() + .map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec())) + .flatten(); + + let record = sqlx::query!( + "SELECT created_by AS \"created_by!\", coalesce(job_logs.logs, '') as logs + FROM v2_job + LEFT JOIN job_logs ON job_logs.job_id = v2_job.id + WHERE v2_job.id = $1 AND v2_job.workspace_id = $2 AND ($3::text[] IS NULL OR v2_job.tag = ANY($3)) + ORDER BY job_logs.log_offset DESC + LIMIT 100", + id, + w_id, + tags.as_ref().map(|v| v.as_slice()) + ) + .fetch_optional(&db) + .await?; + + if let Some(record) = record { + if opt_authed.is_none() && record.created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )); + } + + let logs = record.logs.unwrap_or_default(); + Ok(Json(logs)) + } else { + Err(Error::NotFound("Job not found".to_string()).into()) + } +} +#[derive(Debug, Deserialize)] +struct QueryJobLogs { + remove_ansi_warnings: Option, +} + async fn get_job_logs( OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, + Query(query_job_logs): Query, ) -> error::Result { // let audit_author: AuditAuthor = match opt_authed { // Some(authed) => (&authed).into(), @@ -1419,10 +1464,14 @@ async fn get_job_logs( { return r.map(content_plain); } - let logs = format!( - "to remove ansi colors, use: | sed 's/\\x1B\\[[0-9;]\\{{1,\\}}[A-Za-z]//g'\n{}", + let logs = if query_job_logs.remove_ansi_warnings.unwrap_or(false) { logs - ); + } else { + format!( + "to remove ansi colors, use: | sed 's/\\x1B\\[[0-9;]\\{{1,\\}}[A-Za-z]//g'\n{}", + logs + ) + }; Ok(content_plain(Body::from(logs))) } else { let text = sqlx::query!( diff --git a/frontend/src/lib/components/AllFlowLogs.svelte b/frontend/src/lib/components/AllFlowLogs.svelte deleted file mode 100644 index 8be5872278..0000000000 --- a/frontend/src/lib/components/AllFlowLogs.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -
- {#if states != undefined} - {#each Object.entries($states ?? {}) as [id, status] (id)} -

Step {id}

- {#each Object.entries(status.byJob ?? {}) as jobS} - {@const job = jobS[0]} - - {/each} -
- {/each} - {:else} - - {/if} -
diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 33f5c7a6de..d36a557b3d 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -50,7 +50,7 @@ import { Triggers } from './triggers/triggers.svelte' import { TestSteps } from './flows/testSteps.svelte' import { ModulesTestStates } from './modulesTest.svelte' - import type { DurationStatus, GraphModuleState } from './graph' + import type { GraphModuleState } from './graph' import { updateDerivedModuleStatesFromTestJobs } from './flows/utils' let flowCopilotContext: FlowCopilotContext = { @@ -589,9 +589,7 @@ const localModuleStates: Writable> = $derived( flowPreviewContent?.getLocalModuleStates() ?? writable({}) ) - const localDurationStatuses: Writable> = $derived( - flowPreviewContent?.getLocalDurationStatuses() ?? writable({}) - ) + const suspendStatus: Writable> = $derived( flowPreviewContent?.getSuspendStatus() ?? writable({}) ) @@ -839,7 +837,6 @@ onTestFlow={flowPreviewButtons?.runPreview} {job} isOwner={flowPreviewContent?.getIsOwner()} - {localDurationStatuses} {suspendStatus} onOpenDetails={flowPreviewButtons?.openPreview} /> diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index da9fb506ca..0eb3802a40 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -78,7 +78,7 @@ import { Triggers } from './triggers/triggers.svelte' import { TestSteps } from './flows/testSteps.svelte' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' - import type { DurationStatus, GraphModuleState } from './graph' + import type { GraphModuleState } from './graph' import { setStepHistoryLoaderContext, StepHistoryLoader, @@ -936,9 +936,6 @@ const localModuleStates: Writable> = $derived( flowPreviewContent?.getLocalModuleStates() ?? writable({}) ) - const localDurationStatuses: Writable> = $derived( - flowPreviewContent?.getLocalDurationStatuses() ?? writable({}) - ) const suspendStatus: Writable> = $derived( flowPreviewContent?.getSuspendStatus() ?? writable({}) ) @@ -1239,7 +1236,6 @@ onHideJobStatus={resetModulesStates} {individualStepTests} {job} - {localDurationStatuses} {suspendStatus} {showJobStatus} onDelete={(id) => { diff --git a/frontend/src/lib/components/FlowJobResult.svelte b/frontend/src/lib/components/FlowJobResult.svelte index 5a443b3dd4..d806b2fd07 100644 --- a/frontend/src/lib/components/FlowJobResult.svelte +++ b/frontend/src/lib/components/FlowJobResult.svelte @@ -2,14 +2,6 @@ import { Loader2 } from 'lucide-svelte' import DisplayResult from './DisplayResult.svelte' import LogViewer from './LogViewer.svelte' - import { JobService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' - import DrawerContent from './common/drawer/DrawerContent.svelte' - import { Drawer } from './common' - import AllFlowLogs from './AllFlowLogs.svelte' - import type { DurationStatus } from './graph' - import type { Writable } from 'svelte/store' - import { untrack } from 'svelte' interface Props { waitingForExecutor?: boolean @@ -24,7 +16,6 @@ tag?: string | undefined workspaceId?: string | undefined refreshLog?: boolean - durationStates: Writable> | undefined downloadLogs?: boolean tagLabel?: string | undefined } @@ -33,7 +24,7 @@ waitingForExecutor = false, result, result_stream, - logs = $bindable(), + logs, col = false, noBorder = false, loading, @@ -41,64 +32,11 @@ jobId = undefined, tag = undefined, workspaceId = undefined, - refreshLog = false, - durationStates, downloadLogs = true, tagLabel = undefined }: Props = $props() - - let lastJobId: string | undefined = $state(undefined) - let drawer: Drawer | undefined = $state(undefined) - - let iteration = 0 - let logOffset = 0 - - async function diffJobId() { - if (jobId != lastJobId) { - lastJobId = jobId - logs = undefined - logOffset = 0 - iteration = 0 - getLogs() - } - } - - async function getLogs() { - iteration += 1 - if (jobId) { - const getUpdate = await JobService.getJobUpdates({ - workspace: workspaceId ?? $workspaceStore!, - id: jobId, - running: loading ?? false, - logOffset: logOffset == 0 ? (logs?.length ? logs?.length + 1 : 0) : logOffset - }) - logs = (logs ?? '').concat(getUpdate.new_logs ?? '') - logOffset = getUpdate.log_offset ?? 0 - } - if (refreshLog) { - setTimeout( - () => { - if (refreshLog) { - getLogs() - } - }, - iteration < 10 ? 1000 : iteration < 20 ? 2000 : 5000 - ) - } - } - $effect(() => { - jobId - untrack(() => { - jobId != lastJobId && diffJobId() - }) - }) - - -
-
+ import { + ChevronDown, + ChevronRight, + GitBranch, + Repeat, + Code, + ArrowDownToLine, + ArrowDownFromLine, + FoldVertical, + UnfoldVertical + } from 'lucide-svelte' + import { base } from '$lib/base' + import { workspaceStore } from '$lib/stores' + import { truncateRev } from '$lib/utils' + import ObjectViewer from './propertyPicker/ObjectViewer.svelte' + import LogViewer from './LogViewer.svelte' + import FlowLogViewer from './FlowLogViewer.svelte' + import type { FlowModuleValue, FlowStatusModule, Job } from '$lib/gen' + import { twMerge } from 'tailwind-merge' + import FlowJobsMenu from './flows/map/FlowJobsMenu.svelte' + import BarsStaggered from './icons/BarsStaggered.svelte' + import type { GraphModuleState } from './graph/model' + import type { Writable } from 'svelte/store' + import type { FlowLogEntry } from './FlowLogUtils' + + type RootJobData = Partial + + interface Props { + logEntries: FlowLogEntry[] + localModuleStates: Writable> + rootJob: RootJobData + expandedRows: Record + allExpanded?: boolean + showResultsInputs?: boolean + toggleExpanded: (id: string) => void + toggleExpandAll?: () => void + workspaceId: string | undefined + render: boolean + level?: number + flowId: string + onSelectedIteration: ( + detail: + | { id: string; index: number; manuallySet: true; moduleId: string } + | { manuallySet: false; moduleId: string } + ) => Promise + getSelectedIteration: (stepId: string) => number + flowSummary?: string + } + + let { + logEntries, + localModuleStates, + rootJob, + expandedRows, + allExpanded, + showResultsInputs, + toggleExpanded, + toggleExpandAll, + workspaceId, + render, + level = 0, + flowId = 'root', + onSelectedIteration, + getSelectedIteration, + flowSummary + }: Props = $props() + + function getJobLink(jobId: string | undefined): string { + if (!jobId) return '' + return `${base}/run/${jobId}?workspace=${workspaceId ?? $workspaceStore}` + } + + function getStatusColor(status: FlowStatusModule['type'] | undefined): string { + const statusColors = { + Success: 'text-green-500', + Failure: 'text-red-500', + InProgress: 'text-yellow-500', + WaitingForPriorSteps: 'text-gray-400', + WaitingForEvents: 'text-purple-400', + WaitingForExecutor: 'text-gray-400' + } + return status ? statusColors[status] : 'text-gray-400' + } + + function getFlowStatus(job: RootJobData): FlowStatusModule['type'] | undefined { + if (job.type === 'CompletedJob') { + return job.success ? 'Success' : 'Failure' + } else if (job.type === 'QueuedJob') { + return 'InProgress' + } else { + return undefined + } + } + + function getStepProgress(job: RootJobData, totalSteps: number): string { + if (totalSteps === 0) return '' + + // If flow is completed, show total steps + if (job.type === 'CompletedJob') { + return ` (${totalSteps} step${totalSteps === 1 ? '' : 's'})` + } + + // If flow is running, use flow_status.step if available (like JobStatus.svelte) + if (job.type === 'QueuedJob') { + if (job.flow_status?.step !== undefined) { + const currentStep = (job.flow_status.step ?? 0) + 1 + return ` (step ${currentStep} of ${totalSteps})` + } + + return '' + } + + return '' + } + + function isExpanded(id: string, isRunning: boolean = false): boolean { + // If explicitly set in expandedRows, use that value + // Otherwise, fall back to allExpanded + return expandedRows[id] ?? (allExpanded || isRunning) + } + + function hasEmptySubflow(stepId: string, stepType: FlowModuleValue['type'] | undefined): boolean { + const state = $localModuleStates[stepId] + + if (!state || !stepType) return false + return ( + ['forloopflow', 'whileloopflow'].includes(stepType) && + (!state.flow_jobs || state.flow_jobs.length === 0) + ) + } + + // Find all parents of error steps + function findParentsOfErrors(entries: FlowLogEntry[]): Set { + const parentsWithErrors = new Set() + + function traverseEntries(entryList: FlowLogEntry[], parentId?: string) { + let hasChildError = false + + for (const entry of entryList) { + let currentEntryHasError = false + + // Check if this entry has subflows with errors + if (entry.subflows && entry.subflows.length > 0) { + for (const subflow of entry.subflows) { + const subflowHasError = traverseEntries(subflow, entry.stepId) + if (subflowHasError) { + currentEntryHasError = true + parentsWithErrors.add(entry.stepId) + } + } + } + + // Check if this entry itself has an error (but don't flag it - only its parents) + const stepStatus = $localModuleStates[entry.stepId]?.type + if (stepStatus === 'Failure') { + currentEntryHasError = true + // Don't add the entry itself to parentsWithErrors + } + + // If this entry has an error, mark its parent + if (currentEntryHasError && parentId) { + parentsWithErrors.add(parentId) + hasChildError = true + } + } + + return hasChildError + } + + traverseEntries(entries, flowId) + return parentsWithErrors + } + + // Get flow info for display + const flowInfo = $derived.by(() => { + const parentsWithErrors = findParentsOfErrors(logEntries) + return { + jobId: rootJob.id, + inputs: rootJob.args || {}, + result: rootJob.type === 'CompletedJob' ? rootJob.result : undefined, + logs: rootJob.logs || '', + status: rootJob.type, + label: flowSummary, + hasErrors: parentsWithErrors.has(flowId), + parentsWithErrors + } + }) + + +{#if render} + {#if level === 0 && toggleExpandAll} +
+
+ +
+ +
+
+ +
+ {/if} +
    + +
  • +
    + {#if level > 0} + + {:else} + +
    + {/if} +
    +
    + + +
    0 ? 'cursor-pointer' : '', + rootJob.type === undefined ? 'opacity-50' : '' + )} + onclick={level > 0 ? () => toggleExpanded(`flow-${flowId}`) : undefined} + > +
    + + {@render flowIcon(getFlowStatus(rootJob), flowInfo.hasErrors)} + +
    + + {flowId === 'root' ? 'Flow' : 'Subflow'} + {#if flowInfo.label} + : {flowInfo.label} + {/if} + {getStepProgress(rootJob, logEntries.length)} + +
    +
    + + {#if flowInfo.jobId} + e.stopPropagation()} + > + {truncateRev(flowInfo.jobId, 6)} + + {/if} +
    + + {#if level === 0 || isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')} +
    + + {#if flowInfo.logs} + + {/if} + + +
      + + {#if showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0} +
    • +
      + +
      + +
      + + +
      toggleExpanded(`flow-${flowId}-input`)} + > +
      + + Inputs +
      +
      + + {#if isExpanded(`flow-${flowId}-input`)} +
      +
      + +
      +
      + {/if} +
      +
    • + {/if} + + {#if logEntries.length > 0} + {#each logEntries as entry (entry.id)} + {@const isLeafStep = + entry.stepType !== 'branchall' && + entry.stepType !== 'branchone' && + entry.stepType !== 'forloopflow' && + entry.stepType !== 'whileloopflow'} + {@const status = $localModuleStates[entry.stepId]?.type} + {@const isRunning = status === 'InProgress' || status === 'WaitingForExecutor'} + {@const hasEmptySubflowValue = hasEmptySubflow(entry.stepId, entry.stepType)} + {@const isCollapsible = !hasEmptySubflowValue} +
    • +
      + {#if isCollapsible} + + {:else} + +
      + {/if} +
      +
      + + +
      toggleExpanded(entry.id) : undefined} + > +
      + + {@render stepIcon( + entry.stepType, + status as FlowStatusModule['type'], + flowInfo.parentsWithErrors.has(entry.stepId) + )} + +
      + + + {entry.stepId} + + {#if entry.stepType === 'forloopflow'} + For loop + {:else if entry.stepType === 'whileloopflow'} + While loop + {:else if entry.stepType === 'branchall'} + Branch to all + {:else if entry.stepType === 'branchone'} + Branch to one + {:else if entry.stepType === 'flow'} + Subflow + {:else} + Step + {/if} + {#if entry.summary} + : {entry.summary} + {/if} + {#if hasEmptySubflowValue} + + {#if entry.stepType === 'forloopflow' || entry.stepType === 'whileloopflow'} + (empty loop) + {:else if entry.stepType === 'branchall' || entry.stepType === 'branchone'} + (no branch) + {/if} + + {/if} + + {#if !hasEmptySubflowValue && $localModuleStates[entry.stepId]?.flow_jobs && (entry.stepType === 'forloopflow' || entry.stepType === 'whileloopflow')} + + e.stopPropagation()}> + + + {#if entry.stepType === 'forloopflow'} + {`/${$localModuleStates[entry.stepId]?.iteration_total ?? 0}`} + {/if} + + {/if} +
      +
      + + {#if isLeafStep} + {@const jobId = $localModuleStates[entry.stepId]?.job_id} + + {truncateRev(jobId ?? '', 6)} + + {/if} +
      + + {#if isCollapsible && isExpanded(entry.id, isRunning)} + {@const args = $localModuleStates[entry.stepId]?.args} + {@const logs = $localModuleStates[entry.stepId]?.logs} + {@const result = $localModuleStates[entry.stepId]?.result} + {@const jobId = $localModuleStates[entry.stepId]?.job_id} +
      + + {#if entry.subflows && entry.subflows.length > 0} + {#each entry.subflows as subflow, index} + {@const subflowLabel = entry.subflowsSummary?.[index]} + {@const subflowJob = { + id: jobId, + type: + $localModuleStates[entry.stepId]?.type === 'Failure' || + $localModuleStates[entry.stepId]?.type === 'Success' + ? 'CompletedJob' + : ('QueuedJob' as Job['type']), + logs, + result, + args, + success: $localModuleStates[entry.stepId]?.type === 'Success' + }} +
      + + +
      + {/each} + + {:else} + {#if showResultsInputs && isLeafStep && args && Object.keys(args).length > 0} +
      + + +
      toggleExpanded(`${entry.id}-input`)} + > + {#if isExpanded(`${entry.id}-input`)} + + {:else} + + {/if} + Input +
      + {#if isExpanded(`${entry.id}-input`)} +
      + +
      + {/if} +
      + {/if} + + + {#if logs} + + {:else if jobId && !entry.subflows?.[0]?.length} +
      +
      + No logs available +
      +
      + {/if} + + + + {#if showResultsInputs && isLeafStep && result !== undefined && (status === 'Success' || status === 'Failure')} +
      + + +
      toggleExpanded(`${entry.id}-result`)} + > + {#if isExpanded(`${entry.id}-result`)} + + {:else} + + {/if} + Result +
      + {#if isExpanded(`${entry.id}-result`)} +
      + +
      + {/if} +
      + {/if} + {/if} +
      + {/if} +
      +
    • + {/each} + {/if} + + + {#if showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob'} +
    • +
      + +
      +
      + + +
      toggleExpanded(`flow-${flowId}-result`)} + > +
      + + Results +
      +
      + + {#if isExpanded(`flow-${flowId}-result`)} +
      +
      + +
      +
      + {/if} +
      +
    • + {/if} +
    +
    + {/if} +
    +
  • +
+{/if} + +{#snippet flowIcon(status: FlowStatusModule['type'] | undefined, hasErrors: boolean)} + {@const colorClass = getStatusColor(status)} +
+ + {#if hasErrors && status !== 'Failure'} + ! + {/if} +
+{/snippet} + +{#snippet stepIcon( + stepType: string | undefined, + status: FlowStatusModule['type'] | undefined, + hasErrors: boolean +)} + {@const colorClass = getStatusColor(status)} + {@const animationClass = status === 'InProgress' ? 'animate-pulse' : ''} + {@const classes = `${colorClass} ${animationClass} flex-shrink-0`} +
+ {#if stepType === 'flow'} + + {:else if stepType === 'forloopflow' || stepType === 'whileloopflow'} + + {:else if stepType === 'branchall' || stepType === 'branchone'} + + {:else} + + {/if} + {#if hasErrors && status !== 'Failure'} + ! + {/if} +
+{/snippet} + + diff --git a/frontend/src/lib/components/FlowLogViewerWrapper.svelte b/frontend/src/lib/components/FlowLogViewerWrapper.svelte new file mode 100644 index 0000000000..b0091b4cf3 --- /dev/null +++ b/frontend/src/lib/components/FlowLogViewerWrapper.svelte @@ -0,0 +1,216 @@ + + +
+ +
diff --git a/frontend/src/lib/components/FlowPreviewResult.svelte b/frontend/src/lib/components/FlowPreviewResult.svelte index c83bf41d73..ae2865ddb7 100644 --- a/frontend/src/lib/components/FlowPreviewResult.svelte +++ b/frontend/src/lib/components/FlowPreviewResult.svelte @@ -5,7 +5,6 @@ import FlowStatusWaitingForEvents from './FlowStatusWaitingForEvents.svelte' import type { FlowStatusModule, Job } from '$lib/gen' import { emptyString } from '$lib/utils' - import type { DurationStatus } from './graph' import type { Writable } from 'svelte/store' import Badge from './common/badge/Badge.svelte' @@ -15,7 +14,6 @@ isOwner: boolean hideFlowResult: boolean hideDownloadLogs: boolean - localDurationStatuses: Writable> innerModules: FlowStatusModule[] suspendStatus: Writable> hideJobId?: boolean @@ -29,7 +27,6 @@ isOwner, hideFlowResult, hideDownloadLogs, - localDurationStatuses, innerModules, suspendStatus, hideJobId, @@ -54,7 +51,6 @@ loading={job['running'] == true} result={job.result} logs={job.logs} - durationStates={localDurationStatuses} downloadLogs={!hideDownloadLogs} />
diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index a479ac438e..22c1a9f18b 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -120,4 +120,6 @@ bind:rightColumnSelect {render} {customUi} + graphTabOpen={true} + isNodeSelected={true} /> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 4846956038..6800d7583d 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -34,6 +34,7 @@ import { buildPrefix } from './graph/graphBuilder.svelte' import { parseInputArgsAssets } from './assets/lib' import FlowPreviewResult from './FlowPreviewResult.svelte' + import FlowLogViewerWrapper from './FlowLogViewerWrapper.svelte' import type { FlowGraphAssetContext } from './flows/types' import { createState } from '$lib/svelte5Utils.svelte' import JobLoader from './JobLoader.svelte' @@ -97,6 +98,9 @@ customUi?: { tagLabel?: string | undefined } + graphTabOpen: boolean + isNodeSelected: boolean + loadExtraLogs?: (logs: string) => void } let { @@ -127,7 +131,10 @@ localModuleStates = writable({}), localDurationStatuses = writable({}), customUi, - onResultStreamUpdate = undefined + onResultStreamUpdate = undefined, + graphTabOpen, + isNodeSelected, + loadExtraLogs = undefined }: Props = $props() let resultStreams: Record = $state({}) @@ -542,6 +549,14 @@ }, resultStreamUpdate({ id, result_stream }: { id: string; result_stream?: string }) { onResultStreamUpdate?.({ jobId: id, result_stream }) + }, + loadExtraLogs({ id, logs }: { id: string; logs: string }) { + if (id == jobId && job) { + job.logs = logs + } + if (loadExtraLogs) { + loadExtraLogs(logs) + } } }) } @@ -926,6 +941,38 @@ let subflowsSize = $state(500) + async function onSelectedIteration( + detail: + | { id: string; index: number; manuallySet: true; moduleId: string } + | { manuallySet: false; moduleId: string } + ) { + if (detail.manuallySet) { + let rootJobId = detail.id + await tick() + + let previousId = $localModuleStates[detail.moduleId]?.selectedForloop + if (previousId) { + await globalRefreshes?.[detail.moduleId]?.(true, previousId) + } + + $localModuleStates[detail.moduleId] = { + ...$localModuleStates[detail.moduleId], + selectedForloop: detail.id, + selectedForloopIndex: detail.index, + selectedForLoopSetManually: true + } + + await tick() + + await globalRefreshes?.[detail.moduleId]?.(false, rootJobId) + } else { + $localModuleStates[detail.moduleId] = { + ...$localModuleStates[detail.moduleId], + selectedForLoopSetManually: false + } + } + } + $effect(() => { flowJobIds?.moduleId && untrack(() => onFlowModuleId()) }) @@ -939,10 +986,14 @@ $effect(() => { flowJobIds?.moduleId && untrack(() => onModuleIdChange()) }) - let selected = $derived(isListJob ? 'sequence' : 'graph') + let selected = $derived(isListJob ? 'sequence' : 'graph') as 'sequence' | 'graph' | 'logs' + + let animateLogsTab = $state(false) + + let noLogs = $derived(graphTabOpen && !isNodeSelected) - + {#if notAnonynmous} As a non logged in user, you can only see jobs ran by anonymous users like you @@ -999,7 +1050,6 @@ {isOwner} {hideFlowResult} {hideDownloadLogs} - {localDurationStatuses} {innerModules} {suspendStatus} {hideJobId} @@ -1011,6 +1061,12 @@ {#if innerModules.length > 0 && !isListJob} Graph + Logs Details {:else} @@ -1096,6 +1152,8 @@ innerJobLoaded(job, j, false, force) }} {onResultStreamUpdate} + graphTabOpen={selected == 'graph' && graphTabOpen} + isNodeSelected={forloop_selected == loopJobId} /> {/if} @@ -1172,6 +1230,8 @@ {workspaceId} jobId={failedRetry} {onResultStreamUpdate} + graphTabOpen={selected == 'graph' && graphTabOpen} + isNodeSelected={retry_selected == failedRetry} /> {/each} @@ -1205,6 +1265,8 @@ onJobsLoaded(mod, job, force) }} {onResultStreamUpdate} + graphTabOpen={selected == 'graph' && graphTabOpen} + isNodeSelected={false} /> {:else if mod.flow_jobs?.length == 0 && mod.job == '00000000-0000-0000-0000-000000000000'}
no subflow (empty loop?)
@@ -1236,7 +1298,14 @@ let { job, force } = e.detail onJobsLoaded(mod, job, force) }} + loadExtraLogs={(logs) => { + setModuleState(mod.id ?? '', { + logs + }) + }} {onResultStreamUpdate} + graphTabOpen={selected == 'graph' && graphTabOpen} + isNodeSelected={$localModuleStates?.[selectedNode ?? '']?.job_id == mod.job} /> {/if} {:else} @@ -1252,6 +1321,15 @@
Empty flow
{/if} +
+ +
{#if render} {#if job.raw_flow && !isListJob} @@ -1305,33 +1383,7 @@ selectedNode = e.id } }} - onSelectedIteration={async (detail) => { - if (detail.manuallySet) { - let rootJobId = detail.id - await tick() - - let previousId = $localModuleStates[detail.moduleId]?.selectedForloop - if (previousId) { - await globalRefreshes?.[detail.moduleId]?.(true, previousId) - } - - $localModuleStates[detail.moduleId] = { - ...$localModuleStates[detail.moduleId], - selectedForloop: detail.id, - selectedForloopIndex: detail.index, - selectedForLoopSetManually: true - } - - await tick() - - await globalRefreshes?.[detail.moduleId]?.(false, rootJobId) - } else { - $localModuleStates[detail.moduleId] = { - ...$localModuleStates[detail.moduleId], - selectedForLoopSetManually: false - } - } - }} + {onSelectedIteration} earlyStop={job.raw_flow?.skip_expr !== undefined} cache={job.raw_flow?.cache_ttl !== undefined} modules={job.raw_flow?.modules ?? []} @@ -1386,7 +1438,6 @@ col result={job['result']} logs={job.logs ?? ''} - durationStates={localDurationStatuses} downloadLogs={!hideDownloadLogs} /> {:else if selectedNode == 'start'} @@ -1464,7 +1515,6 @@ result={node.result} tag={node.tag} logs={node.logs} - durationStates={localDurationStatuses} downloadLogs={!hideDownloadLogs} /> {:else} diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 803ecc8d3c..618cb15984 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -1,3 +1,9 @@ + + - {#if job && isOwner !== undefined && localDurationStatuses && suspendStatus} + {#if job && isOwner !== undefined && suspendStatus}
#{selected == -1 ? '?' : selected + 1} - + {#if showIcon} + + {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index b4ddb597ad..7636649f4f 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -48,11 +48,11 @@ import type { TriggerContext } from '../triggers' import { workspaceStore } from '$lib/stores' import SubflowBound from './renderers/nodes/SubflowBound.svelte' - import { deepEqual } from 'fast-equals' import ViewportResizer from './ViewportResizer.svelte' import AssetNode, { computeAssetNodes } from './renderers/nodes/AssetNode.svelte' import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte' import type { FlowGraphAssetContext } from '../flows/types' + import { ChangeTracker } from '$lib/svelte5Utils.svelte' let useDataflow: Writable = writable(false) @@ -348,14 +348,7 @@ } } - let lastModules = $state.snapshot(modules) - let moduleCounter = $state(0) - function onModulesChange2(modules) { - if (!deepEqual(modules, lastModules)) { - lastModules = $state.snapshot(modules) - moduleCounter++ - } - } + let moduleTracker = new ChangeTracker($state.snapshot(modules)) let nodes = $state.raw([]) let edges = $state.raw([]) @@ -426,10 +419,10 @@ }) $effect(() => { readFieldsRecursively(modules) - untrack(() => onModulesChange2(modules)) + untrack(() => moduleTracker.track($state.snapshot(modules))) }) let graph = $derived.by(() => { - moduleCounter + moduleTracker.counter return graphBuilder( untrack(() => modules), { diff --git a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte index ff10a65c8b..bab46277cc 100644 --- a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte +++ b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte @@ -26,7 +26,9 @@ {#if job && !hideJobId}
- Flow: + {#if ['flow', 'flowpreview', 'flownode'].includes(job.job_kind)} + Flow: + {/if} { - job?.logs == undefined && job && viewTab == 'logs' && untrack(() => jobLoader?.getLogs()) - }) + $effect(() => { job?.id && lastJobId !== job.id && untrack(() => job && getConcurrencyKey(job)) }) @@ -82,7 +80,7 @@ let jobLoader: JobLoader | undefined = $state(undefined) - +
{#if job} diff --git a/frontend/src/lib/components/search/RunsSearch.svelte b/frontend/src/lib/components/search/RunsSearch.svelte index 64aa8e24cc..c12c14f988 100644 --- a/frontend/src/lib/components/search/RunsSearch.svelte +++ b/frontend/src/lib/components/search/RunsSearch.svelte @@ -9,7 +9,7 @@ import QuickMenuItem from './QuickMenuItem.svelte' import { goto } from '$app/navigation' import { displayDateOnly } from '$lib/utils' - import JobPreview from '../runs/JobPreview.svelte' + import JobPreview from '../runs/JobRunsPreview.svelte' let debounceTimeout: any = undefined const debouncePeriod: number = 1000 diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index d738dbf89d..c17f2418af 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -1,6 +1,7 @@ // https://github.com/sveltejs/svelte/issues/14600 import { untrack } from 'svelte' +import { deepEqual } from 'fast-equals' import type { StateStore } from './utils' export function withProps(component: Component, props: Props) { @@ -69,3 +70,31 @@ export function usePromise( return ret } + +/** + * Generic change tracker class that monitors changes in state using deep equality comparison + * and provides a counter to trigger Svelte 5 reactivity. Similar to the pattern used in + * FlowGraphV2.svelte's onModulesChange2 function. + */ +export class ChangeTracker { + counter = $state(0) + #lastState: T | undefined + + constructor(initialValue?: T) { + this.#lastState = initialValue ? initialValue : undefined + } + + /** + * Check if the value has changed and update the counter to trigger reactivity + * @param value - The current value to check for changes + * @returns true if the value changed, false otherwise + */ + track(value: T): boolean { + if (!deepEqual(value, this.#lastState)) { + this.#lastState = value + this.counter++ + return true + } + return false + } +} diff --git a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte index 8b6c30e4b7..26ca843381 100644 --- a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte @@ -16,7 +16,7 @@ import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common' import RunChart from '$lib/components/RunChart.svelte' - import JobPreview from '$lib/components/runs/JobPreview.svelte' + import JobRunsPreview from '$lib/components/runs/JobRunsPreview.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte' @@ -834,7 +834,7 @@ {#if selectedIds[0] === '-'}
There is no information available for this job
{:else} - + {/if} {/if} @@ -1179,7 +1179,7 @@ {#if selectedIds[0] === '-'}
There is no information available for this job
{:else} -