feat: improve-replay-ui (#8250)

* Improve UI of script record

* Improve UI for scripts

* Remove Result & Logs loading container while flow not finised

* Improve Graph view

* Add click on a step mention

* Fix spacing when empty

* Fix step duration disappearing in recorded flows

* Modernize timeline tab

* Improve Script recording result UI

* feat: externalize recording player controls for fake-window embedding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: reorder FlowViewer tab sync effects for clarity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: eliminate tab sync effects in FlowViewer, use selectedTab directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove unnecessary untrack in FlowViewer tab init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip tab auto-selection when selectedTab is controlled externally

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: export recording types from package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: non-null assertion for recording.flow in FlowGraphViewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace banned $bindable(default_value) pattern and simplify tab sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use svelte 5 onclick syntax on replay page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip db clock endpoint during replay mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove line numbers from script recording code display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tristan TR
2026-03-26 19:52:15 +01:00
committed by GitHub
parent 264fa33917
commit c0aafee9a9
15 changed files with 457 additions and 293 deletions
+7
View File
@@ -295,6 +295,10 @@
"svelte": "./package/components/recording/ScriptRecordingReplay.svelte",
"default": "./package/components/recording/ScriptRecordingReplay.svelte"
},
"./components/recording/types": {
"types": "./package/components/recording/types.d.ts",
"default": "./package/components/recording/types.js"
},
"./components/FlowWrapper.svelte": {
"types": "./package/components/FlowWrapper.svelte.d.ts",
"svelte": "./package/components/FlowWrapper.svelte",
@@ -500,6 +504,9 @@
"components/ScriptRecordingReplay.svelte": [
"./package/components/recording/ScriptRecordingReplay.svelte.d.ts"
],
"components/recording/types": [
"./package/components/recording/types.d.ts"
],
"components/FlowBuilder.svelte": [
"./package/components/FlowBuilder.svelte.d.ts"
],
@@ -26,6 +26,7 @@
workspace?: string | undefined
minHeight?: number
noBorder?: boolean
hideDefaultInputs?: boolean
}
let {
@@ -38,7 +39,8 @@
stepDetail = $bindable(undefined),
workspace = $workspaceStore,
minHeight = 400,
noBorder = false
noBorder = false,
hideDefaultInputs = false
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -47,7 +49,9 @@
<div class="grid grid-cols-3 w-full h-full">
{#if !noGraph}
<div
class="{noSide ? 'col-span-3' : 'sm:col-span-2 col-span-3'} w-full max-h-full"
class="{noSide || (hideDefaultInputs && stepDetail == undefined)
? 'col-span-3'
: 'sm:col-span-2 col-span-3'} w-full max-h-full"
class:overflow-auto={overflowAuto}
class:border={!noBorder}
>
@@ -81,14 +85,14 @@
/>
</div>
{/if}
{#if !noSide}
{#if !noSide && !(hideDefaultInputs && stepDetail == undefined)}
<div
class={twMerge(
'relative w-full h-full min-h-[150px] max-h-[90vh] border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4',
noGraph ? 'border-0 w-max' : ''
)}
>
<FlowGraphViewerStep schema={flow.schema} {stepDetail} />
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {hideDefaultInputs} />
</div>
{/if}
</div>
@@ -23,9 +23,10 @@
schema?: any | undefined
stepDetail?: FlowModule | string | undefined
jobScriptHash?: string | undefined
hideDefaultInputs?: boolean
}
let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined }: Props = $props()
let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined, hideDefaultInputs = false }: Props = $props()
let codeViewer: Drawer | undefined = $state()
</script>
@@ -92,10 +93,10 @@
<div class={twMerge('p-2 overflow-y-scroll')}>
{#if stepDetail == undefined}
<div>
<p class="font-medium text-secondary text-center pt-4 pb-8">
<p class="text-secondary text-xs italic px-2 pt-2">
Click on a step to see its details
</p>
{#if schema}
{#if schema && !hideDefaultInputs}
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
<SchemaViewer {schema} />
{/if}
@@ -44,6 +44,7 @@
workspaceId = undefined,
flowState = $bindable({}),
selectedJobStep = $bindable(undefined),
hideFlowResult = false,
hideTimeline = false,
hideDownloadInGraph = false,
hideNodeDefinition = false,
@@ -175,6 +176,7 @@
}
}}
{showLogsWithResult}
{hideFlowResult}
notes={notesProp}
groups={groupsProp}
/>
@@ -136,6 +136,7 @@
}
showLogsWithResult?: boolean
showJobDetailHeader?: boolean
hideFlowResult?: boolean
notes?: FlowNote[]
groups?: FlowValue['groups']
}
@@ -178,6 +179,7 @@
toolCallStore,
showLogsWithResult = false,
showJobDetailHeader = false,
hideFlowResult = false,
notes: notesProp = undefined,
groups: groupsProp = undefined
}: Props = $props()
@@ -1356,7 +1358,7 @@
/>
</div>
{/if}
{:else if render}
{:else if render && !hideFlowResult}
<div class={'flex flex-col w-full'}>
{#if showLogsWithResult && job}
<!-- Side-by-side result and logs for simple jobs -->
@@ -2141,7 +2143,7 @@
likely did not run yet</p
>
{/if}
{:else}<p class="p-2 text-primary italic"
{:else}<p class="text-secondary text-xs italic"
>Select a node to see its details here</p
>{/if}
</div>
@@ -2157,7 +2159,7 @@
{#if node?.job_id}
<JobOtelTraces jobId={node.job_id} />
{:else}
<div class="p-4 text-secondary"
<div class="p-4 text-secondary text-xs italic"
>Select a node with a job to see HTTP request traces</div
>
{/if}
+76 -84
View File
@@ -81,36 +81,31 @@
}}
/>
{#if items}
<div class="divide-y border-b">
<div class="px-2 py-2 grid grid-cols-12 w-full"
><div></div>
<div class="col-span-11 pt-1 px-2 flex text-2xs text-secondary justify-between"
><div>{min ? displayDate(new Date(min), true) : ''}</div>{#if max && min}<div
class="hidden lg:block">{msToSec(max - min, 1)}s</div
>
{/if}<div class="flex gap-1 items-center font-mono"
>{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now}
{msToSec(now - min, 1)}s
{/if}<Loader2 size={14} class="animate-spin" />{/if}</div
></div
>
</div>
<div class="flex flex-row-reverse items-center text-sm text-secondary p-2">
<div class="flex gap-4 items-center text-2xs">
<div class="flex gap-2 items-center">
<div>Waiting for executor/Suspend</div>
<div class="h-4 w-4 bg-gray-500"></div>
<div class="divide-y">
<div class="px-3 py-1.5 flex items-center justify-between text-2xs text-secondary">
<div class="flex gap-1 items-center font-mono">
{min ? displayDate(new Date(min), true) : ''}
</div>
<div class="flex gap-3 items-center">
<div class="flex gap-1.5 items-center">
<div class="h-2.5 w-2.5 rounded-sm bg-gray-400 dark:bg-gray-500"></div>
<span>Wait</span>
</div>
<div class="flex gap-2 items-center">
<div>Execution</div>
<div class="h-4 w-4 bg-blue-500/90"></div>
<div class="flex gap-1.5 items-center">
<div class="h-2.5 w-2.5 rounded-sm bg-blue-500/90"></div>
<span>Execution</span>
</div>
{#if max && min}
<span class="font-mono">{msToSec(max - min, 1)}s</span>
{/if}
{#if !max && min}{#if now}
<span class="font-mono">{msToSec(now - min, 1)}s</span>
{/if}<Loader2 size={14} class="animate-spin" />{/if}
</div>
</div>
{#if selfWaitTime}
<div class="px-2 py-2 grid grid-cols-6 w-full">
root:
<div class="px-3 py-1.5 flex items-center gap-2">
<span class="text-xs text-secondary">root:</span>
<WaitTimeWarning
self_wait_time_ms={selfWaitTime}
aggregate_wait_time_ms={aggregateWaitTime}
@@ -120,9 +115,9 @@
{/if}
{#each flowModules as { id: k, type: typ } (k)}
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
<div class="shadow-inner dark:shadow-gray-700 relative">
<div class="px-2 py-2 grid grid-cols-6 w-full">
<div class="truncate"
<div class="relative px-3 py-1.5">
<div class="flex items-center justify-between mb-0.5">
<div class="text-xs font-medium"
>{k.startsWith('subflow:') ? k.substring(8) : k}
{#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')}
<span class="text-xs font-mono font-medium inline-flex items-center -my-2">
@@ -141,70 +136,67 @@
</span>
{/if}
</div>
<div class="col-span-5 flex">
{#if subItems?.length > 1}
<div class="text-xs text-secondary absolute top-1 right-2">
{subItems?.length} jobs
</div>
{/if}
{#if min && total}
<VirtualList
width="100%"
height={Math.min(400, (subItems?.length ?? 0) * barHeight)}
itemCount={subItems?.length ?? 0}
itemSize={barHeight}
getKey={(index) => subItems?.[index]?.id}
>
{#snippet item({ index, style })}
{@const b = subItems?.[index]}
{#if b?.created_at}
<!-- <div class="text-xs text-secondary">{JSON.stringify(b)}</div> -->
{@const waitingLen = b?.created_at
? b.started_at
? b.started_at - b?.created_at
: b.duration_ms
? 0
: now - b?.created_at
: 0}
<div class="flex w-full p-1 pb-2 pl-12" {style}>
{#if subItems?.length > 1}
<span class="text-2xs text-secondary bg-surface-hover px-1.5 py-0.5 rounded-full">
{subItems?.length} jobs
</span>
{/if}
</div>
<div class="w-full">
{#if min && total}
<VirtualList
width="100%"
height={Math.min(400, (subItems?.length ?? 0) * barHeight)}
itemCount={subItems?.length ?? 0}
itemSize={barHeight}
getKey={(index) => subItems?.[index]?.id}
>
{#snippet item({ index, style })}
{@const b = subItems?.[index]}
{#if b?.created_at}
{@const waitingLen = b?.created_at
? b.started_at
? b.started_at - b?.created_at
: b.duration_ms
? 0
: now - b?.created_at
: 0}
<div class="flex w-full py-0.5 items-center" {style}>
<TimelineBar
position="left"
id={b?.id}
{total}
{min}
gray
spacerClass="bg-gray-100 dark:bg-gray-800/50 rounded-l-md"
started_at={b.created_at}
len={waitingLen < 100 ? 0 : waitingLen - 100}
running={b?.started_at == undefined}
/>
{#if b.started_at}
<TimelineBar
position="left"
position={waitingLen < 100 ? 'center' : 'right'}
id={b?.id}
{total}
{min}
gray
started_at={b.created_at}
len={waitingLen < 100 ? 0 : waitingLen - 100}
running={b?.started_at == undefined}
concat
started_at={b.started_at}
len={b.started_at ? (b?.duration_ms ?? now - b?.started_at) : 0}
running={b?.duration_ms == undefined}
/>
{#if b.started_at}
<TimelineBar
position={waitingLen < 100 ? 'center' : 'right'}
id={b?.id}
{total}
{min}
concat
started_at={b.started_at}
len={b.started_at ? (b?.duration_ms ?? now - b?.started_at) : 0}
running={b?.duration_ms == undefined}
/>
{/if}
</div>
{:else}
<div class="flex w-full p-1 pb-2 pl-12">
<div class="text-xs text-secondary">
<!-- Waiting for executor/Suspend {JSON.stringify(b)} -->
</div>
</div>
{/if}
{/snippet}
</VirtualList>
{/if}</div
></div
>
{/if}
</div>
{:else}
<div class="flex w-full py-0.5"></div>
{/if}
{/snippet}
</VirtualList>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<Loader2 class="animate-spin" />
{/if}
+53 -23
View File
@@ -20,7 +20,7 @@
schema?: any
}
type TabValue = 'ui' | 'raw' | 'schema' | 'diff'
export type TabValue = 'ui' | 'raw' | 'schema' | 'diff'
interface Props {
flow: {
@@ -33,10 +33,16 @@
noSide?: boolean
noGraph?: boolean
initTab?: TabValue
selectedTab?: TabValue
hideTabs?: boolean
noSummary?: boolean
noInput?: boolean
hideDefaultInputs?: boolean
showStepHint?: boolean
noGraphDownload?: boolean
availableVersions?: Array<{ id: number; deployment_msg?: string }>
selectedVersionId?: number
graphContent?: import('svelte').Snippet
}
let {
@@ -46,9 +52,15 @@
noGraph = false,
availableVersions = undefined,
initTab = undefined,
selectedTab = $bindable(),
hideTabs = false,
noSummary = false,
noInput = false,
hideDefaultInputs = false,
showStepHint = false,
noGraphDownload = false,
selectedVersionId = undefined
selectedVersionId = undefined,
graphContent = undefined
}: Props = $props()
let open: { [id: number]: boolean } = {}
@@ -59,7 +71,10 @@
let previousVersionId: number | undefined = $state(undefined)
let previousFlow: PreviousFlow | undefined = $state(undefined)
let tab: TabValue = $state(untrack(() => initTab) ?? 'diff')
const tabControlledExternally = selectedTab !== undefined
if (!tabControlledExternally) {
selectedTab = initTab ?? 'diff'
}
let previousFlowCache: Record<number, PreviousFlow> = {}
@@ -90,16 +105,16 @@
})
$effect.pre(() => {
if (initTab) {
if (initTab || tabControlledExternally) {
return
}
if (availableVersions && availableVersions.length > 0) {
tab = 'diff'
selectedTab = 'diff'
} else {
if (noGraph) {
tab = 'schema'
selectedTab = 'schema'
} else {
tab = 'ui'
selectedTab = 'ui'
}
}
})
@@ -127,7 +142,7 @@
<HighlightTheme />
<Tabs bind:selected={tab}>
<Tabs bind:selected={selectedTab as string} {hideTabs}>
{#if availableVersions && availableVersions.length > 0}
<Tab value="diff" label="Diff" />
{/if}
@@ -167,23 +182,38 @@
</TabContent>
{/if}
<TabContent value="ui">
<div class="flow-root w-full pb-4">
{#if !noSummary}
<h2 class="my-4">{flow.summary}</h2>
<div>{flow.description ?? ''}</div>
{/if}
{#if graphContent}
{@render graphContent()}
{:else}
<div class="flow-root w-full pb-4">
{#if showStepHint}
<p class="text-2xs text-tertiary py-1">Click on a step to see its details</p>
{/if}
{#if !noSummary}
<h2 class="my-4">{flow.summary}</h2>
<div>{flow.description ?? ''}</div>
{/if}
<p class="font-black text-lg w-full my-4">
<span>Flow Input</span>
</p>
{#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema}
<FlowInputViewer schema={flow.schema} />
{:else}
<div class="text-secondary text-xs italic mb-4">No inputs</div>
{/if}
{#if !noInput}
<p class="font-black text-lg w-full my-4">
<span>Flow Input</span>
</p>
{#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema}
<FlowInputViewer schema={flow.schema} />
{:else}
<div class="text-secondary text-xs italic mb-4">No inputs</div>
{/if}
{/if}
<FlowGraphViewer download={!noGraphDownload} {noSide} {flow} overflowAuto />
</div>
<FlowGraphViewer
download={!noGraphDownload}
{noSide}
{hideDefaultInputs}
{flow}
overflowAuto
/>
</div>
{/if}
</TabContent>
<TabContent value="raw">
<FlowViewerInner {flow} />
+2 -2
View File
@@ -107,7 +107,7 @@
$effect(() => {
if (noLogs != lastNoLogs) {
lastNoLogs = noLogs
if (!noLogs) {
if (!noLogs && !getActiveReplay()) {
currentEventSource?.onerror?.(new Event(noLogsChangeRestartEvent))
const lastJobId = lastCompletedJobId
if (lastJobId && (job || lastCallbacks?.loadExtraLogs)) {
@@ -255,7 +255,7 @@
}
}
export async function getLogs() {
if (job) {
if (job && !getActiveReplay()) {
refreshLogOffset()
const getUpdate = await JobService.getJobUpdates({
workspace: workspace!,
@@ -632,6 +632,10 @@
}
export async function runTest() {
// Discard any previous recording when running a normal test
if (!scriptRecording.active) {
lastRecording = undefined
}
// Not defined if JobProgressBar not loaded
jobProgressBar?.reset()
// Flush module edits back to modules map before running preview
@@ -1530,16 +1534,7 @@
displayName: 'Test & record',
icon: Disc,
action: () => recordAndTest()
},
...(lastRecording
? [
{
displayName: 'Download recording',
icon: Download,
action: () => downloadRecording()
}
]
: [])
}
]}
/>
</div>
+16 -8
View File
@@ -14,6 +14,7 @@
running: boolean
concat?: boolean
gray?: boolean
spacerClass?: string
}
let {
@@ -25,25 +26,26 @@
id,
running,
concat = false,
gray = false
gray = false,
spacerClass = ''
}: Props = $props()
</script>
{#if min && started_at != undefined}
{#if !concat}
<div style="width: {((started_at - min) / total) * 100}%" class="h-4"></div>
<div style="width: {((started_at - min) / total) * 100}%" class="h-5 {spacerClass}"></div>
{/if}
<Popover
style="width: {(len / total) * 100}%"
class="h-4 relative {gray
class="h-5 relative {gray
? 'bg-gray-300 dark:bg-gray-600'
: running
? 'bg-blue-400/90'
: 'bg-blue-500/90'} {position == 'left'
? 'rounded-l-sm'
? 'rounded-l-md'
: position == 'right'
? 'rounded-r-sm'
: 'rounded-sm'} center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
? 'rounded-r-md'
: 'rounded-md'} center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
>
{#snippet text()}
<a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank"
@@ -52,9 +54,15 @@
{/snippet}
{#if len > 0}
{@const narrow = len / total < 0.09}
{@const endPos = started_at != undefined && min != undefined ? (started_at - min + len) / total : 1}
{@const endPos =
started_at != undefined && min != undefined ? (started_at - min + len) / total : 1}
{@const nearStart = endPos < 0.15}
<span class={narrow ? (nearStart ? 'absolute left-full ml-2 text-primary font-mono' : 'absolute right-full mr-1 text-primary font-mono') : 'font-mono'}
<span
class={narrow
? nearStart
? 'absolute left-full ml-2 text-primary font-mono'
: 'absolute right-full mr-1 text-primary font-mono'
: 'font-mono'}
>{#if len}{msToSec(len, 1)}s{/if}</span
>
{/if}
@@ -921,6 +921,7 @@
document.addEventListener('keydown', globalKeyDownHandler)
return () => {
document.removeEventListener('keydown', globalKeyDownHandler)
}
@@ -2,7 +2,8 @@
import type { Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte'
import FlowViewer from '$lib/components/FlowViewer.svelte'
import FlowViewer, { type TabValue } from '$lib/components/FlowViewer.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte'
import { setActiveReplay } from './flowRecording.svelte'
@@ -13,21 +14,37 @@
import { InfoIcon, LogOut, Play, Square } from 'lucide-svelte'
import { onDestroy } from 'svelte'
interface Props {
recording: FlowRecording
}
let { recording }: Props = $props()
type ReplayState = 'loaded' | 'playing'
let replayState: ReplayState = $state('loaded')
interface Props {
recording: FlowRecording
selectedTab?: TabValue
replayState?: ReplayState
hideControls?: boolean
hideTabs?: boolean
}
let {
recording,
selectedTab = $bindable(),
replayState = $bindable(),
hideControls = false,
hideTabs = false
}: Props = $props()
if (selectedTab === undefined) {
selectedTab = 'ui'
}
if (replayState === undefined) {
replayState = 'loaded'
}
let rootJobId: string | undefined = $state(undefined)
let rootInitialJob: Job | undefined = $state(undefined)
let job: Job | undefined = $state(undefined)
let done = $derived((job as any)?.type === 'CompletedJob')
function stop() {
export function stop() {
setActiveReplay(undefined)
job = undefined
initRecording()
@@ -36,10 +53,7 @@
function findRootJobId(data: FlowRecording): string | undefined {
for (const [id, recorded] of Object.entries(data.jobs)) {
const j = recorded.initial_job
if (
(j.job_kind === 'flow' || j.job_kind === 'flowpreview') &&
!j.parent_job
) {
if ((j.job_kind === 'flow' || j.job_kind === 'flowpreview') && !j.parent_job) {
return id
}
}
@@ -81,17 +95,19 @@
for (const mod of fs.modules) {
const durations = mod.flow_jobs_duration
if (durations?.started_at) {
durations.started_at = durations.started_at.map(
(d: string) => offsetDate(d) ?? d
)
durations.started_at = durations.started_at.map((d: string) => offsetDate(d) ?? d)
}
}
}
for (const recorded of Object.values(data.jobs)) {
offsetJobTimestamps(recorded.initial_job)
if (recorded.initial_job?.flow_status) offsetFlowStatus(recorded.initial_job.flow_status)
for (const event of recorded.events) {
if (event.data?.job) offsetJobTimestamps(event.data.job)
if (event.data?.job) {
offsetJobTimestamps(event.data.job)
if (event.data.job.flow_status) offsetFlowStatus(event.data.job.flow_status)
}
if (event.data?.flow_status) offsetFlowStatus(event.data.flow_status)
}
}
@@ -141,22 +157,27 @@
// Push the root's completed event to fire after all sub-job events
let completedIdx = -1
for (let i = rootEvents.length - 1; i >= 0; i--) {
if (rootEvents[i].data.completed) { completedIdx = i; break }
if (rootEvents[i].data.completed) {
completedIdx = i
break
}
}
if (completedIdx >= 0 && rootEvents[completedIdx].t < maxSubJobT) {
rootEvents[completedIdx].t = maxSubJobT + 50
}
}
function startReplay() {
export function startReplay() {
if (!rootJobId) return
// JSON round-trip to unwrap reactive proxies and strip non-cloneable properties
const snapshot = JSON.parse(JSON.stringify(recording)) as FlowRecording
fixEventOrdering(snapshot, rootJobId!)
rebaseTimestamps(snapshot, rootJobId!)
fixEventOrdering(snapshot, rootJobId)
rebaseTimestamps(snapshot, rootJobId)
setActiveReplay(snapshot)
rootInitialJob = buildInitialJob(snapshot, rootJobId!)
rootInitialJob = buildInitialJob(snapshot, rootJobId)
job = undefined
replayState = 'playing'
selectedTab = 'ui'
}
onDestroy(() => {
@@ -173,52 +194,81 @@
</p>
</div>
</div>
{:else if replayState === 'loaded'}
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-emphasis">{recording.flow_path}</h2>
<Tooltip placement="bottom">
<InfoIcon size={16} class="text-tertiary" />
{#snippet text()}
<span class="text-2xs" >
Recorded {new Date(recording.recorded_at).toLocaleString()} &mdash;
{(recording.total_duration_ms / 1000).toFixed(1)}s
</span>
{/snippet}
</Tooltip>
{#if !hideControls}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-emphasis">
{replayState === 'playing' ? 'Replaying: ' : ''}{recording.flow_path}
</h2>
<Tooltip placement="bottom">
<InfoIcon size={16} class="text-tertiary" />
{#snippet text()}
<span class="text-2xs">
Recorded {new Date(recording.recorded_at).toLocaleString()} &mdash;
{(recording.total_duration_ms / 1000).toFixed(1)}s
</span>
{/snippet}
</Tooltip>
</div>
{#if replayState === 'loaded'}
<Button variant="contained" color="blue" onclick={startReplay} startIcon={{ icon: Play }}>
Play
</Button>
{:else}
<Button
variant="border"
size="xs"
onclick={stop}
startIcon={{ icon: done ? LogOut : Square }}
>
{done ? 'Exit' : 'Stop'}
</Button>
{/if}
</div>
<Button variant="contained" color="blue" on:click={startReplay} startIcon={{ icon: Play }}>
Play
</Button>
</div>
<FlowViewer flow={recording.flow} noSummary />
</div>
{:else if replayState === 'playing' && rootJobId}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-emphasis">Replaying: {recording.flow_path}</h2>
<Button variant="border" size="xs" on:click={stop} startIcon={{ icon: done ? LogOut : Square }}>
{done ? 'Exit' : 'Stop'}
</Button>
</div>
<FlowProgressBar {job} slim textPosition="bottom" showStepId />
{#if job}
<FlowExecutionStatus
{job}
workspaceId={$workspaceStore}
isOwner={false}
innerModules={job?.flow_status?.modules}
suspendStatus={{ val: {} }}
/>
{/if}
<FlowStatusViewer
jobId={rootJobId}
initialJob={rootInitialJob}
bind:job
workspaceId={$workspaceStore}
wideResults
showLogsWithResult
/>
<FlowViewer
flow={recording.flow}
noSummary
noInput
hideDefaultInputs
showStepHint={replayState === 'loaded'}
bind:selectedTab
{hideTabs}
initTab="ui"
>
{#snippet graphContent()}
{#if replayState === 'playing' && rootJobId}
<div class="flex flex-col gap-4">
<FlowProgressBar {job} slim textPosition="bottom" showStepId />
{#if job}
<FlowExecutionStatus
{job}
workspaceId={$workspaceStore}
isOwner={false}
innerModules={job?.flow_status?.modules}
suspendStatus={{ val: {} }}
/>
{/if}
<FlowStatusViewer
jobId={rootJobId}
initialJob={rootInitialJob}
bind:job
workspaceId={$workspaceStore}
wideResults
showLogsWithResult
hideFlowResult={!done}
/>
</div>
{:else}
<div class="flow-root w-full pb-4">
<p class="text-2xs text-tertiary py-1">Click on a step to see its details</p>
<FlowGraphViewer hideDefaultInputs flow={recording.flow!} overflowAuto />
</div>
{/if}
{/snippet}
</FlowViewer>
</div>
{/if}
@@ -12,6 +12,7 @@
import { json as jsonLang } from 'svelte-highlight/languages'
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import { ClipboardCopy, InfoIcon, LogOut, Play, Square } from 'lucide-svelte'
@@ -19,15 +20,26 @@
import { onDestroy, tick } from 'svelte'
import JobLoader from '$lib/components/JobLoader.svelte'
interface Props {
recording: ScriptRecording
}
let { recording }: Props = $props()
export type ScriptTabValue = 'parameters' | 'code' | 'args' | 'schema' | 'result'
type ReplayState = 'loaded' | 'playing'
let replayState: ReplayState = $state('loaded')
interface Props {
recording: ScriptRecording
selectedTab?: ScriptTabValue
replayState?: ReplayState
hideControls?: boolean
hideTabs?: boolean
}
let {
recording,
selectedTab = $bindable(),
replayState = $bindable(),
hideControls = false,
hideTabs = false
}: Props = $props()
let jobId: string | undefined = $state(undefined)
let job: Job | undefined = $state(undefined)
let jobLoader: JobLoader | undefined = $state(undefined)
@@ -35,7 +47,18 @@
let scriptRecordingStore = createScriptRecording()
function stop() {
let schema = $derived(recording.schema)
if (selectedTab === undefined) {
if (schema && recording.args) selectedTab = 'parameters'
else if (recording.args && Object.keys(recording.args).length > 0) selectedTab = 'args'
else selectedTab = 'code'
}
if (replayState === undefined) {
replayState = 'loaded'
}
export function stop() {
setActiveReplay(undefined)
job = undefined
replayState = 'loaded'
@@ -85,13 +108,14 @@
initRecording()
async function startReplay() {
export async function startReplay() {
const snapshot = JSON.parse(JSON.stringify(recording)) as ScriptRecording
rebaseTimestamps(snapshot)
const replayData = scriptRecordingStore.toReplayData(snapshot)
setActiveReplay(replayData)
job = undefined
replayState = 'playing'
selectedTab = 'result'
await tick()
if (jobLoader && jobId) {
jobLoader.watchJob(jobId)
@@ -101,8 +125,6 @@
onDestroy(() => {
setActiveReplay(undefined)
})
let schema = $derived(recording.schema)
</script>
<HighlightTheme />
@@ -115,48 +137,141 @@
</p>
</div>
</div>
{:else if replayState === 'loaded'}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-emphasis"
>{recording.script_path || 'Untitled script'}</h2
>
<span class="text-xs text-secondary px-2 py-0.5 bg-surface-secondary rounded"
>{recording.language}</span
>
<Tooltip placement="bottom">
<InfoIcon size={16} class="text-tertiary" />
{#snippet text()}
<span class="text-2xs">
Recorded {new Date(recording.recorded_at).toLocaleString()} &mdash;
{(recording.total_duration_ms / 1000).toFixed(1)}s
</span>
{/snippet}
</Tooltip>
{:else}
<div class="flex flex-col gap-4 h-full">
{#if !hideControls}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-emphasis">
{replayState === 'playing' ? 'Replaying: ' : ''}{recording.script_path ||
'Untitled script'}
</h2>
<span class="text-xs text-secondary px-2 py-0.5 bg-surface-secondary rounded">
{recording.language}
</span>
<Tooltip placement="bottom">
<InfoIcon size={16} class="text-tertiary" />
{#snippet text()}
<span class="text-2xs">
Recorded {new Date(recording.recorded_at).toLocaleString()} &mdash;
{(recording.total_duration_ms / 1000).toFixed(1)}s
</span>
{/snippet}
</Tooltip>
</div>
{#if replayState === 'loaded'}
<Button variant="contained" color="blue" onclick={startReplay} startIcon={{ icon: Play }}>
Play
</Button>
{:else}
<Button
variant="border"
size="xs"
onclick={stop}
startIcon={{ icon: done ? LogOut : Square }}
>
{done ? 'Exit' : 'Stop'}
</Button>
{/if}
</div>
<Button variant="contained" color="blue" on:click={startReplay} startIcon={{ icon: Play }}>
Play
</Button>
</div>
{#if recording.args && Object.keys(recording.args).length > 0}
<JobArgs args={recording.args} />
{/if}
<Tabs selected="code">
<Tab value="code" label="Code" />
{#if replayState === 'playing'}
<JobLoader noCode={true} bind:this={jobLoader} bind:job />
{/if}
<Tabs bind:selected={selectedTab as string} {hideTabs}>
{#if replayState === 'playing'}
<Tab value="result" label="Result" />
{/if}
{#if schema && recording.args}
<Tab value="parameters" label="Code" />
{/if}
{#if recording.args && Object.keys(recording.args).length > 0}
<Tab value="args" label="Args" />
{/if}
{#if !schema || !recording.args}
<Tab value="code" label="Code" />
{/if}
{#if schema}
<Tab value="schema" label="Schema" />
{/if}
{#snippet content()}
<TabContent value="result" class="flex-1 min-h-0">
{#if replayState === 'playing' && jobId}
<div class="grid grid-cols-2 gap-4 w-full h-full">
<div class="flex flex-col min-h-0">
<h3 class="shrink-0 text-xs font-semibold text-emphasis mb-1">Result</h3>
<div class="flex-1 min-h-0 overflow-auto rounded-md border bg-surface-tertiary p-4">
{#if job !== undefined && job.type === 'CompletedJob' && job.result !== undefined}
<DisplayResult result={job.result} language={job.language} />
{:else if done}
<div
class="w-full h-full flex items-center justify-center text-secondary text-sm"
>
No output available
</div>
{:else}
<div
class="w-full h-full flex items-center justify-center text-secondary text-sm"
>
Waiting for result...
</div>
{/if}
</div>
</div>
<div class="flex flex-col min-h-0">
<h3 class="shrink-0 text-xs font-semibold text-emphasis mb-1">Logs</h3>
<div class="flex-1 min-h-0 overflow-auto rounded-md border bg-surface-tertiary">
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={!done}
content={job?.logs}
tag={job?.tag}
download={false}
/>
</div>
</div>
</div>
{/if}
</TabContent>
<TabContent value="parameters" class="flex-1 min-h-0">
{#if schema && recording.args}
<div class="flex gap-4 p-2 h-full">
<div class="w-1/2 overflow-auto text-2xs">
<HighlightCode
language={recording.language as Script['language']}
code={recording.code}
className="text-2xs"
/>
</div>
<div class="w-1/2 overflow-auto">
<SchemaForm
{schema}
args={recording.args}
disabled={true}
noVariablePicker={true}
/>
</div>
</div>
{/if}
</TabContent>
<TabContent value="args">
{#if recording.args && Object.keys(recording.args).length > 0}
<div class="p-2">
<JobArgs args={recording.args} />
</div>
{/if}
</TabContent>
<TabContent value="code">
<div class="p-2 w-full overflow-auto">
<div class="p-2 w-full overflow-auto text-2xs">
<HighlightCode
language={recording.language as Script['language']}
code={recording.code}
lines
className="text-xs"
className="text-2xs"
/>
</div>
</TabContent>
@@ -180,46 +295,4 @@
{/snippet}
</Tabs>
</div>
{:else if replayState === 'playing' && jobId}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-emphasis"
>Replaying: {recording.script_path || 'Untitled script'}</h2
>
<Button
variant="border"
size="xs"
on:click={stop}
startIcon={{ icon: done ? LogOut : Square }}
>
{done ? 'Exit' : 'Stop'}
</Button>
</div>
<JobLoader noCode={true} bind:this={jobLoader} bind:job />
{#if done && job}
<div>
<h3 class="text-xs font-semibold text-emphasis mb-1">Result</h3>
<div class="border rounded-md bg-surface-tertiary p-4 overflow-auto max-h-screen">
{#if job.type === 'CompletedJob' && job.result !== undefined}
<DisplayResult result={job.result} language={job.language} />
{:else}
<div class="text-secondary text-sm">No result available</div>
{/if}
</div>
</div>
{/if}
<div class="border rounded-md p-2 bg-surface-secondary overflow-auto min-h-[300px]">
<LogViewer
jobId={job?.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={!done}
content={job?.logs}
tag={job?.tag}
download={false}
/>
</div>
</div>
{/if}
+4
View File
@@ -1,6 +1,7 @@
import { get } from 'svelte/store'
import { dbClockDrift } from './stores'
import { JobService } from './gen'
import { getActiveReplay } from './components/recording/flowRecording.svelte'
import pLimit from 'p-limit'
function subtractSeconds(date: Date, seconds: number): Date {
@@ -26,6 +27,9 @@ export function forLater(scheduledString: string): boolean {
const limit = pLimit(1)
export function getDbClockNow() {
if (getActiveReplay()) {
return new Date()
}
let drift = get(dbClockDrift)
if (drift == undefined) {
limit(() => computeDrift())
@@ -51,14 +51,14 @@
<div class="max-w-7xl mx-auto px-4 py-8 w-full">
{#if flowRecording}
<div class="flex justify-end mb-4">
<Button variant="border" size="xs" on:click={quit} startIcon={{ icon: Upload }}>
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
</div>
<FlowRecordingReplay recording={flowRecording} />
{:else if scriptRecording}
<div class="flex justify-end mb-4">
<Button variant="border" size="xs" on:click={quit} startIcon={{ icon: Upload }}>
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
Load another recording
</Button>
</div>
@@ -70,12 +70,7 @@
<p class="text-xs text-secondary mb-2">
Upload a recording JSON file to replay a flow or script execution offline.
</p>
<FileInput
accept=".json"
convertTo="text"
class="w-full"
on:change={handleFileChange}
>
<FileInput accept=".json" convertTo="text" class="w-full" on:change={handleFileChange}>
Drag and drop a recording file
</FileInput>
</div>