Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions ui/src/lib/editor/FlowView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,40 @@

const graph = $derived(dataFlowGraph(workflow))

// SVG text neither wraps nor takes text-overflow, so a label longer than
// the box ran out of its right edge. Budgets are characters at the box's
// inner width (BOX_W less the 10px inset each side) for each line's font
// - bold 12px sans for the name, 10px mono for the detail - and the
// clipPath below catches what a wider glyph set still pushes past.
const NAME_CHARS = 20
const NAME_CHARS_WITH_TAG = 15 // the entry tag sits in the top-right corner
const DETAIL_CHARS = 26

/** The text cut to `max` characters with an ellipsis where it was cut: a
* name is told apart by how it starts, a path by how it ends. */
function fit(text: string, max: number, keep: 'head' | 'tail'): string {
if (text.length <= max) return text
return keep === 'head'
? text.slice(0, max - 1) + '…'
: '…' + text.slice(text.length - max + 1)
}
/** The parts of a node's labels that did not fit, in full, for its tooltip. */
function overflowTitle(node: FlowNode): string {
const nameShown = fit(
node.name,
node.isEntryPoint ? NAME_CHARS_WITH_TAG : NAME_CHARS,
'head',
)
return [
nameShown === node.name ? null : node.name,
fit(node.detail, DETAIL_CHARS, 'tail') === node.detail
? null
: node.detail,
]
.filter(Boolean)
.join('\n')
}

// Layered left-to-right layout: a node's layer is one past the deepest
// producer that feeds it directly, so entry points (no previous_result
// input) sit in the first column and depth reads as real dependency
Expand Down Expand Up @@ -157,6 +191,9 @@
>
<path d="M 0 0 L 10 5 L 0 10 z" class="arrowhead" />
</marker>
<clipPath id="flow-nodebox">
<rect width={BOX_W} height={BOX_H} rx="8" />
</clipPath>
</defs>

{#each layout.edgeLines ?? [] as edge, i (i)}
Expand All @@ -180,14 +217,26 @@
class:active={node.name === activeStep}
class:done={stateOf(node.name) === 'done'}
transform={`translate(${pos.x}, ${pos.y})`}
clip-path="url(#flow-nodebox)"
aria-label={`step ${node.name}, ${kindLabel(node.kind)}${stateOf(node.name) ? ', ' + stateOf(node.name) : ''}${node.isEntryPoint ? ', entry point' : ''}${fanIn ? ', fan-in: ' + fanIn.label : ''}`}
{...nodeAttributes(node.name)}
>
{#if overflowTitle(node)}
<title>{overflowTitle(node)}</title>
{/if}
<rect width={BOX_W} height={BOX_H} rx="8" class="box" />
<text x="10" y="20" class="stepname">{node.name}</text>
<text x="10" y="20" class="stepname"
>{fit(
node.name,
node.isEntryPoint ? NAME_CHARS_WITH_TAG : NAME_CHARS,
'head',
)}</text
>
<text x="10" y="37" class="stepkind">{kindLabel(node.kind)}</text>
{#if node.detail}
<text x="10" y="52" class="stepdetail">{node.detail}</text>
<text x="10" y="52" class="stepdetail"
>{fit(node.detail, DETAIL_CHARS, 'tail')}</text
>
{/if}
{#if node.isEntryPoint}
<text x={BOX_W - 8} y="14" class="entrytag" text-anchor="end"
Expand Down
52 changes: 52 additions & 0 deletions ui/src/lib/editor/FlowView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,55 @@ it('leaves every node unstyled when no run state is supplied', () => {
expect(node.classList.contains('active')).toBe(false)
}
})

it('keeps a long detail inside its box, showing the tail and the whole on hover', () => {
const composed = {
steps: [
{
name: 'shot1_inventory',
workflow: { path: 'templates/minimax/composable-reference-shot.json' },
},
],
}
const { container } = render(FlowView, { workflow: composed })
const node = nodeFor(container, 'shot1_inventory')
const detail = node.querySelector('.stepdetail')!
// A path is told apart by its end, so that is the part kept
expect(detail.textContent).toMatch(/^…/)
expect(detail.textContent).toMatch(/reference-shot\.json$/)
expect(detail.textContent!.length).toBeLessThan(
'templates/minimax/composable-reference-shot.json'.length,
)
expect(node.querySelector('title')?.textContent).toBe(
'templates/minimax/composable-reference-shot.json',
)
// The box clips whatever a wider glyph set still pushes past its edge
expect(node.getAttribute('clip-path')).toBe('url(#flow-nodebox)')
})

it('shortens a long step name from the end, leaving room for the entry tag', () => {
const long = {
steps: [
{
name: 'reference_to_video_audio_with_lipsync',
pipeline: { configuration: { component_type: 'Fake' } },
},
],
}
const { container } = render(FlowView, { workflow: long })
const node = nodeFor(container, 'reference_to_video_audio_with_lipsync')
const name = node.querySelector('.stepname')!
expect(name.textContent).toMatch(/…$/)
expect(name.textContent!.length).toBeLessThan(
'reference_to_video_audio_with_lipsync'.length,
)
// A name that fits gets no tooltip at all
expect(
nodeFor(render(FlowView, { workflow }).container, 'gen').querySelector(
'title',
),
).toBeNull()
expect(node.querySelector('title')?.textContent).toBe(
'reference_to_video_audio_with_lipsync',
)
})
94 changes: 80 additions & 14 deletions ui/src/lib/pages/JobPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
import { ApiError, api, outputUrl, streamJobEvents } from '../api'
import { confirmDialog } from '../confirm.svelte'
import { go } from '../router.svelte'
import { groupResultFiles, sectionBySubfolder } from '../results'
import {
groupResultFiles,
sectionBySubfolder,
unsavedSteps,
} from '../results'
import { finishedNodes, flowNodeName } from '../runstate'
import { stepProgress } from '../progress'
import FlowView from '../editor/FlowView.svelte'
import CopyButton from '../CopyButton.svelte'
Expand Down Expand Up @@ -157,15 +162,27 @@
const steps = $derived(
(events.find((e) => e.event === 'workflow_start')?.steps as string[]) ?? [],
)
const currentStep = $derived(
[...events].reverse().find((e) => e.event === 'step_start')?.step as
string | undefined,
// The step_start the run is on, as the engine named it: a for_each member
// keeps its '@', a sub-workflow's inner step its own name. The Progress
// list is written in these names, so it reads off this one.
const stepStart = $derived(
[...events].reverse().find((e) => e.event === 'step_start'),
)
const finishedSteps = $derived(
new Set(
events.filter((e) => e.event === 'step_end').map((e) => e.step as string),
const currentStep = $derived(stepStart?.step as string | undefined)
// The same step in the name the definition gives it, which is what the
// flow graph's nodes are called - see runstate.ts for why the two differ
const activeNode = $derived(
flowNodeName(
stepStart?.step as string | undefined,
stepStart?.parent_step as string | undefined,
),
)
// A sub-workflow's inner step has no row of its own in the Progress list,
// so what lights up while one runs is the composed step that queued it
const listStep = $derived(
steps.includes(currentStep ?? '') ? currentStep : activeNode,
)
const finishedSteps = $derived(finishedNodes(events as JobEvent[], steps))
// Scoped to the step now running: its phase, and its own denoise counter
const progress = $derived(stepProgress(events as JobEvent[]))
const denoise = $derived(progress.denoise)
Expand Down Expand Up @@ -246,6 +263,13 @@
const sectioned = $derived(
sections.length > 1 || sections[0].subfolder !== '',
)
// Steps that ran and wrote no file, with the definition's reason for each.
// Without this a workflow that keeps most of its steps in memory shows a
// short Results list and no word about the rest, which reads as outputs
// that went missing rather than as a choice the workflow made
const unsaved = $derived(
unsavedSteps(job?.manifest, events as JobEvent[], definition),
)
const running = $derived(job !== null && !TERMINAL.includes(job.status))
// A cancel requested while loading a model or running a task step has no
// checkpoint to catch it until that phase finishes - without this the UI
Expand Down Expand Up @@ -352,13 +376,13 @@
<div class="step">
<span
class="dot"
class:done={finishedSteps.has(step)}
class:active={step === currentStep && running}
class:done={finishedSteps.includes(step)}
class:active={step === listStep && running}
></span>
<span class:muted={step !== currentStep && !finishedSteps.has(step)}
<span class:muted={step !== listStep && !finishedSteps.includes(step)}
>{step}</span
>
{#if step === currentStep && running}
{#if step === listStep && running}
{#if denoise}
<div class="bar">
<div
Expand Down Expand Up @@ -393,13 +417,13 @@
<h2>Workflow</h2>
<FlowView
workflow={definition}
activeStep={running ? currentStep : undefined}
doneSteps={[...finishedSteps]}
activeStep={running ? activeNode : undefined}
doneSteps={finishedSteps}
/>
</section>
{/if}

{#if fileGroups.length}
{#if fileGroups.length || unsaved.length}
<div class="panel">
<h2>Results</h2>
{#if allReused}
Expand Down Expand Up @@ -491,6 +515,27 @@
{/each}
{/each}
{/each}
{#if unsaved.length}
<h3 class="subhead">Steps that wrote nothing</h3>
<ul class="unsaved">
{#each unsaved as entry (entry.node)}
<li>
<code>{entry.node}</code>
{#if entry.members.length}
<span
class="muted"
title="this step has for_each: {entry.members.join(', ')}"
>× {entry.members.length}</span
>
{/if}
<span class="muted why">
{#if entry.reason}<code>{entry.reason.key}</code>
{entry.reason.detail}{:else}no file written{/if}
</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}

Expand Down Expand Up @@ -682,4 +727,25 @@
.subhead + .stephead {
margin-top: 0;
}
/* The steps whose files are deliberately absent: the step name is what
the engine resolves, the reason is written for a person */
.unsaved {
list-style: none;
margin: 0;
padding: 0;
font-size: var(--t-sm);
}
.unsaved li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.15rem 0;
}
.unsaved code {
font-size: 0.8rem;
}
.unsaved .why {
min-width: 0;
}
</style>
Loading