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
1 change: 1 addition & 0 deletions ui/src/lib/editor/JsonEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
guides: { indentation: false },
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 13,
tabSize: 2,
fixedOverflowWidgets: true,
Expand Down
56 changes: 54 additions & 2 deletions ui/src/lib/pages/JobPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
} from '../runstate'
import { stepProgress } from '../progress'
import FlowView from '../editor/FlowView.svelte'
import JsonEditor from '../editor/JsonEditor.svelte'
import CopyButton from '../CopyButton.svelte'
import DownloadLink from '../DownloadLink.svelte'
import { notify } from '../toast'
Expand All @@ -39,6 +40,13 @@
// a workflow that pins its seed to a literal or names none at all -
// neither can be handed a different one, so the button stays away.
let seedVariable = $state<string | null>(null)
// Whether `definition` is the realized copy the run itself wrote (every
// mutable input pinned) or the definition as submitted - the run predates
// run tracking, or its run directory is gone. Named beside the JSON view.
let realized = $state(false)
// The JSON view of that definition - off until asked, since the flow graph
// already answers "what did this run do" for most readers
let showJson = $state(false)
let events = $state<JobEvent[]>([])
let error = $state('')
// arrival clocks for pipeline_step events, for the ETA estimate
Expand All @@ -51,6 +59,8 @@
events = []
definition = null
seedVariable = null
realized = false
showJson = false
// Under the flat output layout two runs write the same file names, so
// a map keyed by name would show the last job's recipe for this one
fileMeta = {}
Expand All @@ -64,6 +74,7 @@
if (stopped) return
definition = result.definition
seedVariable = result.seed_variable
realized = result.realized
})
.catch(() => {
/* no definition on file - the graph just does not appear */
Expand Down Expand Up @@ -440,14 +451,43 @@

{#if definition}
<section class="flowsection">
<h2>Workflow</h2>
<div class="flowhead">
<h2>Workflow</h2>
<span
class="muted jsonmark"
title={realized
? 'this is the realized copy the run itself wrote - every mutable input pinned to the value it used'
: 'this is the definition as submitted - no realized copy of this run is on file'}
>{realized ? 'realized' : 'as submitted'}</span
>
<span class="flex"></span>
<button
class="bare"
onclick={() => (showJson = !showJson)}
aria-expanded={showJson}
title={showJson
? 'hide the workflow this job ran'
: 'show the workflow this job ran, as JSON'}
>
{showJson ? 'hide' : 'show'} JSON
</button>
</div>
<FlowView
workflow={definition}
activeStep={running ? activeNode : undefined}
doneSteps={finishedSteps}
activeMember={activeMemberStep}
doneMembers={finishedMemberSteps}
/>
{#if showJson}
<div class="json">
<JsonEditor
value={JSON.stringify(definition, null, 2)}
readonly
height="520px"
/>
</div>
{/if}
</section>
{/if}

Expand Down Expand Up @@ -618,9 +658,21 @@
.flowsection {
margin-bottom: 1rem;
}
.flowsection h2 {
.flowhead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1rem;
margin-bottom: var(--space-2);
}
.flowhead h2 {
margin: 0;
}
/* Which copy the definition is - what the engine resolves, so mono */
.jsonmark {
font-family: var(--font-mono);
font-size: var(--t-xs);
}
.warnings {
color: var(--warn);
}
Expand Down
39 changes: 36 additions & 3 deletions ui/src/lib/pages/JobPage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from '@testing-library/svelte'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'
import { afterEach, expect, it, vi } from 'vitest'
import JobPage from './JobPage.svelte'
import { api } from '../api'
Expand All @@ -13,17 +13,29 @@ const stream = vi.hoisted(() => ({
const metadata = vi.hoisted(() => ({
byFile: {} as Record<string, Record<string, unknown>>,
}))
// The definition the job ran, for the flow view and the unsaved reasons
// The definition the job ran, for the flow view and the unsaved reasons,
// and whether it is the realized copy or the definition as submitted
const ran = vi.hoisted(() => ({
definition: null as Record<string, any> | null,
realized: true,
}))

// Monaco cannot boot in jsdom; the page's JSON view is tested through this
// stub, which renders the value it was handed as plain text
vi.mock('../editor/JsonEditor.svelte', async () => ({
default: (await import('./JsonEditorStub.svelte')).default,
}))

vi.mock('../api', () => ({
ApiError: class ApiError extends Error {},
api: {
getJob: vi.fn(() => Promise.resolve(detail.job)),
getJobWorkflow: vi.fn(() =>
Promise.resolve({ definition: ran.definition, seed_variable: null }),
Promise.resolve({
definition: ran.definition,
realized: ran.realized,
seed_variable: null,
}),
),
galleryMetadata: vi.fn((name: string) =>
Promise.resolve({
Expand Down Expand Up @@ -67,6 +79,7 @@ afterEach(() => {
stream.onEvent = null
metadata.byFile = {}
ran.definition = null
ran.realized = true
vi.mocked(api.galleryMetadata).mockClear()
})

Expand Down Expand Up @@ -351,3 +364,23 @@ it('says nothing about an acknowledgement a run did not carry', async () => {
await waitFor(() => expect(screen.getByText('j1')).toBeTruthy())
expect(screen.queryByText(/acknowledged/)).toBeNull()
})

it('offers the workflow JSON behind a toggle, labelled as the realized copy', async () => {
ran.definition = { steps: [{ name: 'base', pipeline: {} }] }
render(JobPage, { jobId: 'j1' })
await waitFor(() => expect(screen.getByText('realized')).toBeTruthy())
// The JSON is for the curious, not the default view
expect(screen.queryByTestId('json-editor')).toBeNull()
await fireEvent.click(screen.getByRole('button', { name: 'show JSON' }))
const shown = await screen.findByTestId('json-editor')
// What the viewer is handed is the definition the page fetched
expect(shown.textContent).toContain('"name": "base"')
expect(screen.getByRole('button', { name: 'hide JSON' })).toBeTruthy()
})

it('labels a definition with no realized copy on file as submitted', async () => {
ran.definition = { steps: [{ name: 'base', pipeline: {} }] }
ran.realized = false
render(JobPage, { jobId: 'j1' })
await waitFor(() => expect(screen.getByText('as submitted')).toBeTruthy())
})
8 changes: 8 additions & 0 deletions ui/src/lib/pages/JsonEditorStub.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
// Test stand-in for the Monaco-backed JsonEditor: renders the value as
// plain text so a test can assert what the page handed the viewer
// without booting Monaco in jsdom.
let { value }: { value: string } = $props()
</script>

<pre data-testid="json-editor">{value}</pre>
Loading