mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
feat(frontend): add flow log view (#6330)
* componentize detail module * Add logs tab * add flow log viewer * fetch logs * handle subflows * add forloop iteration picker * polish * expand steps log by default * move logic handling to wrapper component * improve iteration picker * clean code * polishing * Add flow start and flow end rows * fix infinite loop * nit * use list instead of table * use custom id for collapsing subflow * remove debug logs * Use status dot instead of text * fetch log from moduleState * wip * only fetch subflow jobs from cache if job is completed * Add job polling for expanded steps * handle subflows * Init logs for steps * update localModuleState logs * use selected iteration from local module state * handle branchone * Add branch one and branch all label * remove redondant innerModule prop * Improve UX * Add expand/collapse * Add filter to hide result and inputs * Steps are now flow children * improve UX * Open flow and steps sction when executing * Handle empty subflows * remove unnecessary sequence viewer component * nit * use iteration picker in log view * Replace dot with step type icon * indicate subflows * add step number and progression * Incorporate inputs and results in the list of steps * Add error indicator when subflow has error * improve topbar * improve log polling * Improve log polling * Add root flow log fetching and polling * Add debounce for loading subflow jobs * write a function to build the tree view from the graph * remove unnecessary log polling * fix flow result display * flag errors * preprocessor * remove all flow logs drawer * grenerate graph from component * wip * Check module change before building graph * nit * fix log overflow * fix log viewer borders * mini jobs run preview fix * elegent job logs loading * nit * nit * nit * all * all * all * all --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
committed by
GitHub
co-authored by
Ruben Fiszel
parent
3066bccad4
commit
235354fe13
@@ -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
|
||||
|
||||
@@ -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<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::JsonResult<String> {
|
||||
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
async fn get_job_logs(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
opt_tokened: OptTokened,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
Query(query_job_logs): Query<QueryJobLogs>,
|
||||
) -> error::Result<Response> {
|
||||
// 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!(
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import JobLogs from './JobLogs.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import type { DurationStatus } from './graph'
|
||||
import type { Writable } from 'svelte/store'
|
||||
|
||||
interface Props {
|
||||
states: Writable<Record<string, DurationStatus>> | undefined
|
||||
}
|
||||
|
||||
let { states }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
{#if states != undefined}
|
||||
{#each Object.entries($states ?? {}) as [id, status] (id)}
|
||||
<div class="pb-12"
|
||||
><h1 class="mb-2">Step {id}</h1>
|
||||
{#each Object.entries(status.byJob ?? {}) as jobS}
|
||||
{@const job = jobS[0]}
|
||||
<div>
|
||||
<a
|
||||
class="text-xs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="{base}/run/{job}?workspace={$workspaceStore}"
|
||||
>
|
||||
{job}
|
||||
</a><JobLogs jobId={job} /></div
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<Loader2 size={14} class="animate-spin " />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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<Record<string, GraphModuleState>> = $derived(
|
||||
flowPreviewContent?.getLocalModuleStates() ?? writable({})
|
||||
)
|
||||
const localDurationStatuses: Writable<Record<string, DurationStatus>> = $derived(
|
||||
flowPreviewContent?.getLocalDurationStatuses() ?? writable({})
|
||||
)
|
||||
|
||||
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $derived(
|
||||
flowPreviewContent?.getSuspendStatus() ?? writable({})
|
||||
)
|
||||
@@ -839,7 +837,6 @@
|
||||
onTestFlow={flowPreviewButtons?.runPreview}
|
||||
{job}
|
||||
isOwner={flowPreviewContent?.getIsOwner()}
|
||||
{localDurationStatuses}
|
||||
{suspendStatus}
|
||||
onOpenDetails={flowPreviewButtons?.openPreview}
|
||||
/>
|
||||
|
||||
@@ -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<Record<string, GraphModuleState>> = $derived(
|
||||
flowPreviewContent?.getLocalModuleStates() ?? writable({})
|
||||
)
|
||||
const localDurationStatuses: Writable<Record<string, DurationStatus>> = $derived(
|
||||
flowPreviewContent?.getLocalDurationStatuses() ?? writable({})
|
||||
)
|
||||
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $derived(
|
||||
flowPreviewContent?.getSuspendStatus() ?? writable({})
|
||||
)
|
||||
@@ -1239,7 +1236,6 @@
|
||||
onHideJobStatus={resetModulesStates}
|
||||
{individualStepTests}
|
||||
{job}
|
||||
{localDurationStatuses}
|
||||
{suspendStatus}
|
||||
{showJobStatus}
|
||||
onDelete={(id) => {
|
||||
|
||||
@@ -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<Record<string, DurationStatus>> | 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()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Explore all steps' logs" on:close={drawer.closeDrawer}
|
||||
><AllFlowLogs states={durationStates} /></DrawerContent
|
||||
>
|
||||
</Drawer>
|
||||
<div
|
||||
class:border={!noBorder}
|
||||
class="grid {!col
|
||||
@@ -116,9 +54,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-auto {col ? '' : 'max-h-80'} relative">
|
||||
<div class="absolute z-40 text-xs top-0 left-1"
|
||||
><button class="" onclick={drawer.openDrawer}>explore all steps' logs</button></div
|
||||
>
|
||||
<LogViewer
|
||||
{tagLabel}
|
||||
download={downloadLogs}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FlowModuleValue } from '$lib/gen'
|
||||
|
||||
export interface FlowLogEntry {
|
||||
id: string
|
||||
stepId: string
|
||||
stepNumber?: number
|
||||
summary?: string
|
||||
stepType?: FlowModuleValue['type']
|
||||
subflows?: FlowLogEntry[][]
|
||||
subflowsSummary?: string[]
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
<script lang="ts">
|
||||
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<Job>
|
||||
|
||||
interface Props {
|
||||
logEntries: FlowLogEntry[]
|
||||
localModuleStates: Writable<Record<string, GraphModuleState>>
|
||||
rootJob: RootJobData
|
||||
expandedRows: Record<string, boolean>
|
||||
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<void>
|
||||
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<string> {
|
||||
const parentsWithErrors = new Set<string>()
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if render}
|
||||
{#if level === 0 && toggleExpandAll}
|
||||
<div class="flex justify-end gap-4 items-center p-2 bg-surface-secondary border-b">
|
||||
<div class="flex items-center gap-2 whitespace-nowrap">
|
||||
<label
|
||||
for="showResultsInputs"
|
||||
class="text-xs text-tertiary hover:text-primary transition-colors"
|
||||
>Show inputs/results</label
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showResultsInputs"
|
||||
id="showResultsInputs"
|
||||
bind:checked={showResultsInputs}
|
||||
class="w-3 h-4 accent-primary -my-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={toggleExpandAll}
|
||||
class="text-xs text-tertiary hover:text-primary transition-colors flex items-center gap-2 min-w-24 justify-end"
|
||||
>
|
||||
{allExpanded ? 'Collapse All' : 'Expand All'}
|
||||
{#if allExpanded}
|
||||
<FoldVertical size={16} />
|
||||
{:else}
|
||||
<UnfoldVertical size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="w-full font-mono text-xs bg-surface-secondary list-none">
|
||||
<!-- Flow entry -->
|
||||
<li class="border-b flex flex-row">
|
||||
<div class="py-2 leading-tight align-top">
|
||||
{#if level > 0}
|
||||
<button
|
||||
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
|
||||
onclick={() => toggleExpanded(`flow-${flowId}`)}
|
||||
>
|
||||
{#if isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Root flow - no collapse button, just spacing -->
|
||||
<div class="w-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow min-w-0 leading-tigh">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'py-1 flex items-center justify-between pr-2',
|
||||
level > 0 ? 'cursor-pointer' : '',
|
||||
rootJob.type === undefined ? 'opacity-50' : ''
|
||||
)}
|
||||
onclick={level > 0 ? () => toggleExpanded(`flow-${flowId}`) : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-2 grow min-w-0">
|
||||
<!-- Flow icon -->
|
||||
{@render flowIcon(getFlowStatus(rootJob), flowInfo.hasErrors)}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono">
|
||||
{flowId === 'root' ? 'Flow' : 'Subflow'}
|
||||
{#if flowInfo.label}
|
||||
: {flowInfo.label}
|
||||
{/if}
|
||||
<span class="text-tertiary">{getStepProgress(rootJob, logEntries.length)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flowInfo.jobId}
|
||||
<a
|
||||
href={getJobLink(flowInfo.jobId)}
|
||||
class="text-xs text-primary hover:underline font-mono"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{truncateRev(flowInfo.jobId, 6)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if level === 0 || isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
|
||||
<div class="mb-2 transition-all duration-200 ease-in-out w-full">
|
||||
<!-- Flow logs -->
|
||||
{#if flowInfo.logs}
|
||||
<LogViewer
|
||||
content={flowInfo.logs}
|
||||
jobId={flowInfo.jobId}
|
||||
isLoading={false}
|
||||
small={true}
|
||||
download={false}
|
||||
noAutoScroll={true}
|
||||
tag={undefined}
|
||||
noPadding
|
||||
wrapperClass="w-full mb-2 pr-2"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Flow steps - nested as children -->
|
||||
<ul class="w-full font-mono text-xs bg-surface-secondary list-none border-l">
|
||||
<!-- Flow inputs as first row entry -->
|
||||
{#if showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0}
|
||||
<li class="border-b flex flex-row w-full">
|
||||
<div class="py-2 leading-tight align-top">
|
||||
<button
|
||||
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
|
||||
onclick={() => toggleExpanded(`flow-${flowId}-input`)}
|
||||
>
|
||||
{#if isExpanded(`flow-${flowId}-input`)}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grow min-w-0 leading-tight">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="py-1 flex items-center justify-between pr-2 cursor-pointer"
|
||||
onclick={() => toggleExpanded(`flow-${flowId}-input`)}
|
||||
>
|
||||
<div class="flex items-center gap-2 grow min-w-0">
|
||||
<ArrowDownToLine size={10} />
|
||||
<span class="text-xs font-mono">Inputs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isExpanded(`flow-${flowId}-input`)}
|
||||
<div class="my-1 transition-all duration-200 ease-in-out">
|
||||
<div class="pl-4">
|
||||
<ObjectViewer json={flowInfo.inputs} pureViewer={true} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/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}
|
||||
<li class="border-b flex flex-row">
|
||||
<div class="py-2 leading-tight align-top">
|
||||
{#if isCollapsible}
|
||||
<button
|
||||
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
|
||||
onclick={() => toggleExpanded(entry.id)}
|
||||
>
|
||||
{#if isExpanded(entry.id, isRunning)}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Empty subflow - no collapse button, just spacing -->
|
||||
<div class="w-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full leading-tight grow min-w-0">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'py-1 flex items-center justify-between pr-2',
|
||||
isCollapsible ? 'cursor-pointer' : '',
|
||||
status === 'WaitingForPriorSteps' ||
|
||||
status === 'WaitingForEvents' ||
|
||||
status === 'WaitingForExecutor' ||
|
||||
status === undefined
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
)}
|
||||
onclick={isCollapsible ? () => toggleExpanded(entry.id) : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-2 grow min-w-0">
|
||||
<!-- Step icon -->
|
||||
{@render stepIcon(
|
||||
entry.stepType,
|
||||
status as FlowStatusModule['type'],
|
||||
flowInfo.parentsWithErrors.has(entry.stepId)
|
||||
)}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono">
|
||||
<b>
|
||||
{entry.stepId}
|
||||
</b>
|
||||
{#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}
|
||||
<span class="text-tertiary">
|
||||
{#if entry.stepType === 'forloopflow' || entry.stepType === 'whileloopflow'}
|
||||
(empty loop)
|
||||
{:else if entry.stepType === 'branchall' || entry.stepType === 'branchone'}
|
||||
(no branch)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if !hasEmptySubflowValue && $localModuleStates[entry.stepId]?.flow_jobs && (entry.stepType === 'forloopflow' || entry.stepType === 'whileloopflow')}
|
||||
<span
|
||||
class="text-xs font-mono font-medium inline-flex items-center grow min-w-0 -my-2"
|
||||
>
|
||||
<span onclick={(e) => e.stopPropagation()}>
|
||||
<FlowJobsMenu
|
||||
moduleId={entry.stepId}
|
||||
id={entry.stepId}
|
||||
{onSelectedIteration}
|
||||
flowJobsSuccess={$localModuleStates[entry.stepId]
|
||||
?.flow_jobs_success}
|
||||
flowJobs={$localModuleStates[entry.stepId]?.flow_jobs}
|
||||
selected={$localModuleStates[entry.stepId]
|
||||
?.selectedForloopIndex ?? 0}
|
||||
selectedManually={$localModuleStates[entry.stepId]
|
||||
?.selectedForLoopSetManually ?? false}
|
||||
showIcon={false}
|
||||
/>
|
||||
</span>
|
||||
{#if entry.stepType === 'forloopflow'}
|
||||
{`/${$localModuleStates[entry.stepId]?.iteration_total ?? 0}`}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLeafStep}
|
||||
{@const jobId = $localModuleStates[entry.stepId]?.job_id}
|
||||
<a
|
||||
href={getJobLink(jobId ?? '')}
|
||||
class="text-xs text-primary hover:underline font-mono"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{truncateRev(jobId ?? '', 6)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
<div class="my-1 transition-all duration-200 ease-in-out">
|
||||
<!-- Show child steps if they exist -->
|
||||
{#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'
|
||||
}}
|
||||
<div class="border-l mb-2">
|
||||
<!-- Recursively render child steps using FlowLogViewer -->
|
||||
<FlowLogViewer
|
||||
logEntries={subflow}
|
||||
{localModuleStates}
|
||||
rootJob={subflowJob}
|
||||
{expandedRows}
|
||||
{allExpanded}
|
||||
{showResultsInputs}
|
||||
{toggleExpanded}
|
||||
toggleExpandAll={undefined}
|
||||
{workspaceId}
|
||||
{render}
|
||||
level={level + 1}
|
||||
flowId={`${entry.stepId}-subflow-${index}`}
|
||||
flowSummary={subflowLabel}
|
||||
{onSelectedIteration}
|
||||
{getSelectedIteration}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Show input arguments -->
|
||||
{:else}
|
||||
{#if showResultsInputs && isLeafStep && args && Object.keys(args).length > 0}
|
||||
<div class="mb-2">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-1 cursor-pointer hover:text-primary text-xs font-mono font-medium mb-1"
|
||||
onclick={() => toggleExpanded(`${entry.id}-input`)}
|
||||
>
|
||||
{#if isExpanded(`${entry.id}-input`)}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
Input
|
||||
</div>
|
||||
{#if isExpanded(`${entry.id}-input`)}
|
||||
<div class="pl-4">
|
||||
<ObjectViewer json={args} pureViewer={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Show logs if they exist -->
|
||||
{#if logs}
|
||||
<LogViewer
|
||||
content={logs}
|
||||
jobId={jobId ?? ''}
|
||||
isLoading={false}
|
||||
small={true}
|
||||
download={false}
|
||||
noAutoScroll={true}
|
||||
tag={undefined}
|
||||
noPadding
|
||||
wrapperClass="w-full mb-2 pr-2"
|
||||
/>
|
||||
{:else if jobId && !entry.subflows?.[0]?.length}
|
||||
<div class="mb-2">
|
||||
<div class="text-xs text-tertiary font-mono">
|
||||
No logs available
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Show result if completed -->
|
||||
|
||||
{#if showResultsInputs && isLeafStep && result !== undefined && (status === 'Success' || status === 'Failure')}
|
||||
<div class="mb-2 mt-2">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-1 cursor-pointer hover:text-primary text-xs font-mono font-medium mb-1"
|
||||
onclick={() => toggleExpanded(`${entry.id}-result`)}
|
||||
>
|
||||
{#if isExpanded(`${entry.id}-result`)}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
Result
|
||||
</div>
|
||||
{#if isExpanded(`${entry.id}-result`)}
|
||||
<div class="pl-4">
|
||||
<ObjectViewer json={result} pureViewer={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Flow result as last row entry -->
|
||||
{#if showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob'}
|
||||
<li class="border-b flex">
|
||||
<div class="py-2 leading-tight align-top">
|
||||
<button
|
||||
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
|
||||
onclick={() => toggleExpanded(`flow-${flowId}-result`)}
|
||||
>
|
||||
{#if isExpanded(`flow-${flowId}-result`)}
|
||||
<ChevronDown size={8} />
|
||||
{:else}
|
||||
<ChevronRight size={8} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full leading-tight">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="py-1 flex items-center justify-between pr-2 cursor-pointer"
|
||||
onclick={() => toggleExpanded(`flow-${flowId}-result`)}
|
||||
>
|
||||
<div class="flex items-center gap-2 grow min-w-0">
|
||||
<ArrowDownFromLine size={10} />
|
||||
<span class="text-xs font-mono">Results</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isExpanded(`flow-${flowId}-result`)}
|
||||
<div class="my-1 transition-all duration-200 ease-in-out">
|
||||
<div class="pl-4">
|
||||
<ObjectViewer json={flowInfo.result} pureViewer={true} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#snippet flowIcon(status: FlowStatusModule['type'] | undefined, hasErrors: boolean)}
|
||||
{@const colorClass = getStatusColor(status)}
|
||||
<div class="relative flex items-center">
|
||||
<BarsStaggered
|
||||
size={10}
|
||||
class={twMerge(colorClass, status === 'InProgress' ? 'animate-pulse' : '', 'flex-shrink-0')}
|
||||
/>
|
||||
{#if hasErrors && status !== 'Failure'}
|
||||
<span
|
||||
class="text-red-500 -ml-0.5 -mr-1.5"
|
||||
title="A subflow or a step has failed but failure was skipped">!</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/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`}
|
||||
<div class="relative flex items-center">
|
||||
{#if stepType === 'flow'}
|
||||
<BarsStaggered size={10} class={classes} />
|
||||
{:else if stepType === 'forloopflow' || stepType === 'whileloopflow'}
|
||||
<Repeat size={10} class={classes} />
|
||||
{:else if stepType === 'branchall' || stepType === 'branchone'}
|
||||
<GitBranch size={10} class={classes} />
|
||||
{:else}
|
||||
<Code strokeWidth={2.5} size={10} class={classes} />
|
||||
{/if}
|
||||
{#if hasErrors && status !== 'Failure'}
|
||||
<span class="text-red-500 -ml-0.5 -mr-1.5" title="A subflow or a step has failed">!</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.transition-all {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import type { Job } from '$lib/gen'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import type { GraphModuleState } from './graph'
|
||||
import FlowLogViewer from './FlowLogViewer.svelte'
|
||||
import { graphBuilder, type NodeLayout } from './graph/graphBuilder.svelte'
|
||||
import type { FlowLogEntry } from './FlowLogUtils'
|
||||
import { untrack } from 'svelte'
|
||||
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
job: Job
|
||||
localModuleStates: Writable<Record<string, GraphModuleState>>
|
||||
workspaceId: string | undefined
|
||||
render: boolean
|
||||
onSelectedIteration: (
|
||||
detail:
|
||||
| { id: string; index: number; manuallySet: true; moduleId: string }
|
||||
| { manuallySet: false; moduleId: string }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
let { job, localModuleStates, workspaceId, render, onSelectedIteration }: Props = $props()
|
||||
|
||||
// State for tracking expanded rows - using Record to allow explicit control
|
||||
let expandedRows: Record<string, boolean> = $state({})
|
||||
let allExpanded = $state(false)
|
||||
let showResultsInputs = $state(true)
|
||||
|
||||
let emptyEventHandler = {
|
||||
deleteBranch: () => {},
|
||||
insert: () => {},
|
||||
select: () => {},
|
||||
changeId: () => {},
|
||||
delete: () => {},
|
||||
newBranch: () => {},
|
||||
move: () => {},
|
||||
selectedIteration: () => {},
|
||||
simplifyFlow: () => {},
|
||||
expandSubflow: () => {},
|
||||
minimizeSubflow: () => {},
|
||||
updateMock: () => {},
|
||||
testUpTo: () => {},
|
||||
editInput: () => {},
|
||||
testFlow: () => {},
|
||||
cancelTestFlow: () => {},
|
||||
openPreview: () => {},
|
||||
hideJobStatus: () => {}
|
||||
}
|
||||
|
||||
let modules = $derived(job.raw_flow?.modules ?? [])
|
||||
let moduleTracker = new ChangeTracker($state.snapshot(job.raw_flow?.modules ?? []))
|
||||
$effect(() => {
|
||||
readFieldsRecursively(modules)
|
||||
untrack(() => moduleTracker.track($state.snapshot(modules)))
|
||||
})
|
||||
|
||||
let nodes: NodeLayout[] | undefined = $derived.by(() => {
|
||||
moduleTracker.counter
|
||||
const graph = graphBuilder(
|
||||
untrack(() => modules),
|
||||
{
|
||||
disableAi: false,
|
||||
insertable: false,
|
||||
flowModuleStates: undefined,
|
||||
selectedId: undefined,
|
||||
path: undefined,
|
||||
newFlow: false,
|
||||
cache: false,
|
||||
earlyStop: false,
|
||||
editMode: false,
|
||||
isOwner: false,
|
||||
isRunning: false,
|
||||
individualStepTests: false,
|
||||
flowJob: undefined,
|
||||
showJobStatus: false,
|
||||
suspendStatus: writable({}),
|
||||
flowHasChanged: false
|
||||
},
|
||||
untrack(() => job.raw_flow?.failure_module),
|
||||
untrack(() => job.raw_flow?.preprocessor_module),
|
||||
emptyEventHandler, // eventHandler - empty for logs view
|
||||
undefined,
|
||||
false, // useDataflow
|
||||
undefined, // selectedId
|
||||
undefined, // moving
|
||||
undefined, // simplifiableFlow
|
||||
undefined, // triggerNode path
|
||||
{} // expandedSubflows
|
||||
)
|
||||
return graph.error ? undefined : graph.nodes
|
||||
})
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
// If not in record, use opposite of allExpanded as new state
|
||||
// If in record, toggle the current state
|
||||
const currentState = expandedRows[id] ?? allExpanded
|
||||
expandedRows[id] = !currentState
|
||||
}
|
||||
|
||||
function getSelectedIteration(stepId: string): number {
|
||||
return $localModuleStates[stepId]?.selectedForloopIndex ?? 0
|
||||
}
|
||||
|
||||
function toggleExpandAll() {
|
||||
allExpanded = !allExpanded
|
||||
expandedRows = {}
|
||||
}
|
||||
|
||||
// Build tree structure from modules using bottom-up traversal from result node
|
||||
function buildFlowTree(nodes: NodeLayout[]): FlowLogEntry[] {
|
||||
// Index nodes for quick access
|
||||
const nodeById: Record<string, NodeLayout> = {}
|
||||
for (const n of nodes) nodeById[n.id] = n
|
||||
|
||||
function traverseFromId(id: string): FlowLogEntry[] {
|
||||
const entries: FlowLogEntry[] = []
|
||||
const currentNode = nodeById[id]
|
||||
|
||||
if (currentNode.type === 'module') {
|
||||
entries.push({
|
||||
id: currentNode.id,
|
||||
stepId: currentNode.id,
|
||||
stepNumber: 0,
|
||||
summary: currentNode.data.module.summary ?? '',
|
||||
stepType: currentNode.data.module.value.type
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
currentNode.type === 'whileLoopStart' ||
|
||||
currentNode.type === 'branchOneStart' ||
|
||||
currentNode.type === 'branchAllStart'
|
||||
) {
|
||||
// Reaching the end of a subflow
|
||||
return entries
|
||||
}
|
||||
|
||||
let nextParentId = currentNode.parentIds?.[0]
|
||||
if (!nextParentId) {
|
||||
// Reached the root of the flow
|
||||
return entries
|
||||
}
|
||||
|
||||
if (
|
||||
currentNode.type === 'forLoopEnd' ||
|
||||
currentNode.type === 'whileLoopEnd' ||
|
||||
currentNode.type === 'branchOneEnd'
|
||||
) {
|
||||
const subflow = traverseFromId(nextParentId)
|
||||
const subflowId = currentNode.id.slice(0, -4) // Remove '-end' suffix
|
||||
const subflowNode = nodeById[subflowId]
|
||||
const subflowSummary = currentNode.type === 'branchOneEnd' ? 'branch' : 'iteration'
|
||||
if (subflowNode.type === 'module') {
|
||||
entries.push({
|
||||
id: subflowId,
|
||||
stepId: subflowId,
|
||||
subflows: [subflow],
|
||||
stepType:
|
||||
currentNode.type === 'forLoopEnd'
|
||||
? 'forloopflow'
|
||||
: currentNode.type === 'whileLoopEnd'
|
||||
? 'whileloopflow'
|
||||
: 'branchone',
|
||||
summary: subflowNode.data.module.summary ?? '',
|
||||
subflowsSummary: [subflowSummary]
|
||||
})
|
||||
nextParentId = subflowNode.parentIds?.[0] ?? ''
|
||||
}
|
||||
} else if (currentNode.type === 'branchAllEnd') {
|
||||
const subflowId = currentNode.id.slice(0, -4) // Remove '-end' suffix
|
||||
const subflowNode = nodeById[subflowId]
|
||||
if (subflowNode.type === 'module' && subflowNode.data.module.value.type === 'branchall') {
|
||||
const subflows = currentNode.parentIds?.map((id) => traverseFromId(id)) ?? []
|
||||
const subflowsSummary = subflowNode.data.module.value.branches.map((b) => b.summary ?? '')
|
||||
entries.push({
|
||||
id: subflowId,
|
||||
stepId: subflowId,
|
||||
subflows: subflows,
|
||||
stepType: 'branchall',
|
||||
subflowsSummary
|
||||
})
|
||||
nextParentId = subflowNode.parentIds?.[0] ?? '' // a module can only have one parent
|
||||
}
|
||||
}
|
||||
|
||||
// Get entries from parent nodes
|
||||
const parentEntries = traverseFromId(nextParentId)
|
||||
return [...parentEntries, ...entries]
|
||||
}
|
||||
|
||||
// Start from the result node and traverse backwards
|
||||
return traverseFromId('result')
|
||||
}
|
||||
|
||||
let logEntries = $derived(nodes ? buildFlowTree(nodes) : [])
|
||||
</script>
|
||||
|
||||
<div class="w-full rounded-md overflow-hidden border">
|
||||
<FlowLogViewer
|
||||
{logEntries}
|
||||
{localModuleStates}
|
||||
rootJob={job}
|
||||
{expandedRows}
|
||||
{allExpanded}
|
||||
{showResultsInputs}
|
||||
{toggleExpanded}
|
||||
{toggleExpandAll}
|
||||
{onSelectedIteration}
|
||||
{workspaceId}
|
||||
{render}
|
||||
{getSelectedIteration}
|
||||
flowId="root"
|
||||
/>
|
||||
</div>
|
||||
@@ -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<Record<string, DurationStatus>>
|
||||
innerModules: FlowStatusModule[]
|
||||
suspendStatus: Writable<Record<string, { job: Job; nb: number }>>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,4 +120,6 @@
|
||||
bind:rightColumnSelect
|
||||
{render}
|
||||
{customUi}
|
||||
graphTabOpen={true}
|
||||
isNodeSelected={true}
|
||||
/>
|
||||
|
||||
@@ -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<string, string | undefined> = $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)
|
||||
</script>
|
||||
|
||||
<JobLoader workspaceOverride={workspaceId} noCode noLogs bind:this={jobLoader} />
|
||||
<JobLoader workspaceOverride={workspaceId} {noLogs} noCode bind:this={jobLoader} />
|
||||
{#if notAnonynmous}
|
||||
<Alert type="error" title="Required Auth">
|
||||
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}
|
||||
<Tabs class="mx-auto {wideResults ? '' : 'max-w-7xl'}" bind:selected>
|
||||
<Tab value="graph"><span class="font-semibold text-md">Graph</span></Tab>
|
||||
<Tab
|
||||
value="logs"
|
||||
class={animateLogsTab
|
||||
? 'animate-pulse animate-duration-1000 bg-surface-inverse text-primary-inverse'
|
||||
: ''}><span class="font-semibold">Logs</span></Tab
|
||||
>
|
||||
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
|
||||
</Tabs>
|
||||
{:else}
|
||||
@@ -1096,6 +1152,8 @@
|
||||
innerJobLoaded(job, j, false, force)
|
||||
}}
|
||||
{onResultStreamUpdate}
|
||||
graphTabOpen={selected == 'graph' && graphTabOpen}
|
||||
isNodeSelected={forloop_selected == loopJobId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1172,6 +1230,8 @@
|
||||
{workspaceId}
|
||||
jobId={failedRetry}
|
||||
{onResultStreamUpdate}
|
||||
graphTabOpen={selected == 'graph' && graphTabOpen}
|
||||
isNodeSelected={retry_selected == failedRetry}
|
||||
/>
|
||||
</div>
|
||||
{/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'}
|
||||
<div class="text-secondary">no subflow (empty loop?)</div>
|
||||
@@ -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 @@
|
||||
<div class="p-2 text-tertiary text-sm italic">Empty flow</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="{selected != 'logs' ? 'hidden' : ''} mx-auto h-[800px]">
|
||||
<FlowLogViewerWrapper
|
||||
{job}
|
||||
{localModuleStates}
|
||||
{workspaceId}
|
||||
{render}
|
||||
{onSelectedIteration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#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}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<script context="module" lang="ts">
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
const plimit = pLimit(5)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Job,
|
||||
@@ -24,6 +30,7 @@
|
||||
started?: ({ id }: { id: string }) => void
|
||||
running?: ({ id }: { id: string }) => void
|
||||
resultStreamUpdate?: ({ id, result_stream }: { id: string; result_stream?: string }) => void
|
||||
loadExtraLogs?: ({ id, logs }: { id: string; logs: string }) => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -83,6 +90,8 @@
|
||||
let lastStartedAt: number = Date.now()
|
||||
let currentId: string | undefined = $state(undefined)
|
||||
let noPingTimeout: NodeJS.Timeout | undefined = undefined
|
||||
let lastNoLogs = $state(noLogs)
|
||||
let lastCompletedJobId = $state<string | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
let newIsLoading = currentId !== undefined
|
||||
@@ -93,6 +102,32 @@
|
||||
})
|
||||
})
|
||||
|
||||
const noLogsChangeRestartEvent = 'SSE restart after no logs change'
|
||||
$effect(() => {
|
||||
if (noLogs != lastNoLogs) {
|
||||
lastNoLogs = noLogs
|
||||
if (!noLogs) {
|
||||
currentEventSource?.onerror?.(new Event(noLogsChangeRestartEvent))
|
||||
const lastJobId = lastCompletedJobId
|
||||
if (lastJobId && (job || lastCallbacks?.loadExtraLogs)) {
|
||||
plimit(() =>
|
||||
JobService.getCompletedJobLogsTail({
|
||||
workspace: $workspaceStore!,
|
||||
id: lastJobId
|
||||
})
|
||||
).then((res) => {
|
||||
if (res && job) {
|
||||
job.logs = res
|
||||
}
|
||||
if (res && lastCallbacks?.loadExtraLogs) {
|
||||
lastCallbacks.loadExtraLogs({ id: lastJobId, logs: res })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function clearCurrentId() {
|
||||
if (currentId) {
|
||||
if (allowConcurentRequests) {
|
||||
@@ -106,6 +141,7 @@
|
||||
export async function abstractRun(fn: () => Promise<string>, callbacks?: Callbacks) {
|
||||
try {
|
||||
isLoading = true
|
||||
lastCompletedJobId = undefined
|
||||
clearCurrentJob()
|
||||
lastCallbacks = callbacks
|
||||
noPingTimeout = undefined
|
||||
@@ -251,7 +287,6 @@
|
||||
const id = currentId
|
||||
if (id) {
|
||||
lastCallbacks?.cancel?.({ id })
|
||||
lastCallbacks = undefined
|
||||
clearCurrentId()
|
||||
// Clean up SSE connection
|
||||
currentEventSource?.close()
|
||||
@@ -272,7 +307,6 @@
|
||||
if (currentId && !allowConcurentRequests) {
|
||||
job = undefined
|
||||
lastCallbacks?.cancel?.({ id: currentId })
|
||||
lastCallbacks = undefined
|
||||
await cancelJob()
|
||||
}
|
||||
}
|
||||
@@ -298,7 +332,7 @@
|
||||
// Clean up any existing SSE connection
|
||||
currentEventSource?.close()
|
||||
currentEventSource = undefined
|
||||
|
||||
lastCallbacks = callbacks
|
||||
// Try SSE first, fall back to polling if needed
|
||||
if (supportsSSE()) {
|
||||
await loadTestJobWithSSE(testId, 0, callbacks)
|
||||
@@ -497,6 +531,7 @@
|
||||
callbacks?.change?.(job)
|
||||
|
||||
clearCurrentId()
|
||||
lastCompletedJobId = id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,9 +708,16 @@
|
||||
console.warn('SSE error:', error)
|
||||
currentEventSource?.close()
|
||||
currentEventSource = undefined
|
||||
if (attempt < 3) {
|
||||
console.log(`SSE error (1), retrying ... attempt: ${attempt + 1}/3`)
|
||||
setTimeout(() => loadTestJobWithSSE(id, attempt + 1, callbacks), 1000)
|
||||
let delay = 1000
|
||||
let isNoLogsChange = error.type == noLogsChangeRestartEvent
|
||||
if (isNoLogsChange) {
|
||||
delay = 0
|
||||
}
|
||||
if (attempt < 3 || isNoLogsChange) {
|
||||
if (!isNoLogsChange) {
|
||||
console.log(`SSE error (1), retrying ... attempt: ${attempt + 1}/3`)
|
||||
}
|
||||
setTimeout(() => loadTestJobWithSSE(id, attempt + 1, callbacks), delay)
|
||||
} else {
|
||||
// Fall back to polling on error
|
||||
setTimeout(() => syncer(id, callbacks), 1000)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
|
||||
import { JobService } from '$lib/gen'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
content: string | undefined
|
||||
@@ -32,6 +33,7 @@
|
||||
download?: boolean
|
||||
customEmptyMessage?: string
|
||||
tagLabel?: string
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -48,7 +50,8 @@
|
||||
noAutoScroll = false,
|
||||
download = true,
|
||||
customEmptyMessage = 'No logs are available yet',
|
||||
tagLabel = undefined
|
||||
tagLabel = undefined,
|
||||
noPadding = false
|
||||
}: Props = $props()
|
||||
|
||||
// @ts-ignore
|
||||
@@ -271,7 +274,12 @@
|
||||
: 'top-2'} left-36">mem peak: {(mem / 1024).toPrecision(4)}MB</span
|
||||
>
|
||||
{/if}
|
||||
<pre class="whitespace-pre break-words {small ? '!text-2xs' : '!text-xs'} w-full p-2"
|
||||
<pre
|
||||
class={twMerge(
|
||||
'whitespace-pre break-words w-full',
|
||||
small ? '!text-2xs' : '!text-xs',
|
||||
noPadding ? '' : 'p-2'
|
||||
)}
|
||||
>{#if content}{@const len =
|
||||
(content?.length ?? 0) +
|
||||
(loadedFromObjectStore?.length ?? 0)}{#if downloadStartUrl}<button onclick={getStoreLogs}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
|
||||
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
|
||||
import type { DurationStatus, GraphModuleState } from '../graph'
|
||||
import type { GraphModuleState } from '../graph'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
onHideJobStatus?: () => void
|
||||
individualStepTests?: boolean
|
||||
job?: Job
|
||||
localDurationStatuses?: Writable<Record<string, DurationStatus>>
|
||||
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
|
||||
showJobStatus?: boolean
|
||||
onDelete?: (id: string) => void
|
||||
@@ -83,7 +82,6 @@
|
||||
onHideJobStatus,
|
||||
individualStepTests = false,
|
||||
job,
|
||||
localDurationStatuses,
|
||||
suspendStatus,
|
||||
showJobStatus,
|
||||
onDelete,
|
||||
@@ -186,7 +184,6 @@
|
||||
{onTestFlow}
|
||||
{job}
|
||||
{isOwner}
|
||||
{localDurationStatuses}
|
||||
{suspendStatus}
|
||||
onOpenDetails={onOpenPreview}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import { computeMissingInputWarnings } from '../missingInputWarnings'
|
||||
import FlowResult from './FlowResult.svelte'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import type { DurationStatus } from '$lib/components/graph'
|
||||
|
||||
interface Props {
|
||||
noEditor?: boolean
|
||||
@@ -33,7 +32,6 @@
|
||||
onTestFlow?: () => void
|
||||
job?: Job
|
||||
isOwner?: boolean
|
||||
localDurationStatuses?: Writable<Record<string, DurationStatus>>
|
||||
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
|
||||
onOpenDetails?: () => void
|
||||
}
|
||||
@@ -50,7 +48,6 @@
|
||||
onTestFlow,
|
||||
job,
|
||||
isOwner,
|
||||
localDurationStatuses,
|
||||
suspendStatus,
|
||||
onOpenDetails
|
||||
}: Props = $props()
|
||||
@@ -100,7 +97,7 @@
|
||||
{onTestFlow}
|
||||
/>
|
||||
{:else if $selectedId === 'Result'}
|
||||
<FlowResult {noEditor} {job} {isOwner} {localDurationStatuses} {suspendStatus} {onOpenDetails} />
|
||||
<FlowResult {noEditor} {job} {isOwner} {suspendStatus} {onOpenDetails} />
|
||||
{:else if $selectedId === 'constants'}
|
||||
<FlowConstants {noEditor} />
|
||||
{:else if $selectedId === 'failure'}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import FlowPreviewResult from '$lib/components/FlowPreviewResult.svelte'
|
||||
import type { DurationStatus } from '$lib/components/graph'
|
||||
import type { Job } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { Writable } from 'svelte/store'
|
||||
@@ -10,18 +9,16 @@
|
||||
interface Props {
|
||||
job?: Job
|
||||
isOwner?: boolean
|
||||
localDurationStatuses?: Writable<Record<string, DurationStatus>>
|
||||
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
|
||||
noEditor: boolean
|
||||
onOpenDetails?: () => void
|
||||
}
|
||||
|
||||
let { job, isOwner, localDurationStatuses, suspendStatus, noEditor, onOpenDetails }: Props =
|
||||
$props()
|
||||
let { job, isOwner, suspendStatus, noEditor, onOpenDetails }: Props = $props()
|
||||
</script>
|
||||
|
||||
<FlowCard {noEditor} title="Flow result">
|
||||
{#if job && isOwner !== undefined && localDurationStatuses && suspendStatus}
|
||||
{#if job && isOwner !== undefined && suspendStatus}
|
||||
<div class="px-4 py-2">
|
||||
<FlowPreviewResult
|
||||
{job}
|
||||
@@ -29,7 +26,6 @@
|
||||
{isOwner}
|
||||
hideFlowResult={false}
|
||||
hideDownloadLogs={false}
|
||||
{localDurationStatuses}
|
||||
innerModules={[]}
|
||||
{suspendStatus}
|
||||
{extra}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
selected: number
|
||||
selectedManually: boolean | undefined
|
||||
onSelectedIteration: onSelectedIteration
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +25,8 @@
|
||||
selected,
|
||||
selectedManually,
|
||||
onSelectedIteration,
|
||||
moduleId
|
||||
moduleId,
|
||||
showIcon = true
|
||||
}: Props = $props()
|
||||
|
||||
let filter: number | undefined = $state(undefined)
|
||||
@@ -127,7 +129,9 @@
|
||||
meltElement={trigger}
|
||||
>
|
||||
#{selected == -1 ? '?' : selected + 1}
|
||||
<ListFilter size={15} />
|
||||
{#if showIcon}
|
||||
<ListFilter size={15} />
|
||||
{/if}
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -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<boolean | undefined> = writable<boolean | undefined>(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<Node[]>([])
|
||||
let edges = $state.raw<Edge[]>([])
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
{#if job && !hideJobId}
|
||||
<div>
|
||||
<div class="text-primary whitespace-nowrap truncate text-sm">
|
||||
<span class="font-semibold mr-1">Flow:</span>
|
||||
{#if ['flow', 'flowpreview', 'flownode'].includes(job.job_kind)}
|
||||
<span class="font-semibold mr-1">Flow:</span>
|
||||
{/if}
|
||||
<a
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
|
||||
+2
-4
@@ -72,9 +72,7 @@
|
||||
})
|
||||
)
|
||||
})
|
||||
$effect(() => {
|
||||
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)
|
||||
</script>
|
||||
|
||||
<JobLoader noLogs workspaceOverride={workspace} bind:job={currentJob} bind:this={jobLoader} />
|
||||
<JobLoader workspaceOverride={workspace} bind:job={currentJob} bind:this={jobLoader} />
|
||||
|
||||
<div class="p-4 flex flex-col gap-2 items-start h-full">
|
||||
{#if job}
|
||||
@@ -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
|
||||
|
||||
@@ -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, Props>(component: Component, props: Props) {
|
||||
@@ -69,3 +70,31 @@ export function usePromise<T>(
|
||||
|
||||
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<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] === '-'}
|
||||
<div class="p-4">There is no information available for this job</div>
|
||||
{:else}
|
||||
<JobPreview blankLink id={selectedIds[0]} workspace={selectedWorkspace} />
|
||||
<JobRunsPreview blankLink id={selectedIds[0]} workspace={selectedWorkspace} />
|
||||
{/if}
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
@@ -1179,7 +1179,7 @@
|
||||
{#if selectedIds[0] === '-'}
|
||||
<div class="p-4">There is no information available for this job</div>
|
||||
{:else}
|
||||
<JobPreview
|
||||
<JobRunsPreview
|
||||
on:filterByConcurrencyKey={filterByConcurrencyKey}
|
||||
on:filterByWorker={filterByWorker}
|
||||
id={selectedIds[0]}
|
||||
|
||||
Reference in New Issue
Block a user