mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()} —
|
||||
{(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()} —
|
||||
{(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()} —
|
||||
{(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()} —
|
||||
{(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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user