This commit is contained in:
Ruben Fiszel
2026-05-01 11:57:57 +00:00
parent 19d50ca5ec
commit 1710b8a77b
6 changed files with 212 additions and 52 deletions
@@ -37,6 +37,11 @@
// metadata out themselves (the picker already has its own).
showMetadata?: boolean
class?: string
// Bump this to force a re-fetch (metadata + preview). Used by the
// asset detail pane after an upstream producer run completes —
// without it, the "Asset not yet materialized" empty state stays
// pinned until the user re-selects the asset.
refreshKey?: any
}
let {
@@ -45,7 +50,8 @@
loadFilePreviewRequest = HelpersService.loadFilePreview,
loadFileMetadataRequest = HelpersService.loadFileMetadata,
showMetadata = false,
class: className = ''
class: className = '',
refreshKey
}: Props = $props()
let csvSeparatorChar: string = $state(',')
@@ -91,12 +97,15 @@
return body.includes('not found') || body.includes('404')
}
// Reload whenever the file key changes. Tracking the workspace too —
// the asset graph spans workspaces so a re-mount with the same key but
// a different ws should refetch.
// Reload whenever the file key, workspace, or external refreshKey
// changes. The refreshKey path is what lets the asset pane re-check
// existence after an upstream run completes — moving from the
// "not yet materialized" empty state to the actual preview without
// requiring the user to re-click the asset.
$effect(() => {
const key = fileKey
const ws = $workspaceStore
void refreshKey
if (!key || !ws) {
fileMetadata = undefined
filePreview = undefined
@@ -23,6 +23,7 @@
import { parsePipelineAnnotations, type PipelineAnnotations } from './parsePipelineAnnotations'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import S3FilePreview from '$lib/components/S3FilePreview.svelte'
import DataTablePreview from './DataTablePreview.svelte'
import AssetRunsPanel from './AssetRunsPanel.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { fade } from 'svelte/transition'
@@ -69,6 +70,9 @@
// re-fetches the listing immediately (rather than waiting on its
// background poll tick).
runsRefreshKey?: any
// Job id of the most recently dispatched run. Forwarded to the
// runs panel so that clicking play auto-selects the new run.
runsPendingJobId?: string | undefined
}
let {
selection,
@@ -81,9 +85,17 @@
onScriptRenamed,
onScriptRemoved,
selectionProducers = [],
runsRefreshKey
runsRefreshKey,
runsPendingJobId
}: Props = $props()
// Bumped when the runs panel reports a watched job has reached a
// terminal state. Drives S3FilePreview's refreshKey so the preview
// re-checks existence after a producer run finishes — moves the
// "not yet materialized" empty state to the actual preview without
// requiring the user to re-click the asset.
let previewRefreshKey = $state(0)
// When `draftScript` is provided we bypass the fetch entirely and edit
// it locally; saving calls ScriptService.createScript to deploy it.
let scriptRes = resource(
@@ -334,25 +346,38 @@
<div class="flex-1 min-h-0 relative">
{#if selection?.kind === 'asset' && !isDraft}
{#if selection.asset_kind === 's3object'}
<!-- Vertical split: S3 contents on top, runs detail (with
popover-driven history) on the bottom. Both visible at
once so users can correlate "what's in the file" with
"which run produced it" without flipping tabs. The
splitter lets users grow whichever pane matters more. -->
<Splitpanes horizontal class="!h-full">
<Pane size={55} minSize={20}>
<S3FilePreview fileKey={selection.path} showMetadata class="h-full" />
</Pane>
<Pane size={45} minSize={20}>
<AssetRunsPanel producers={selectionProducers} refreshKey={runsRefreshKey} />
</Pane>
</Splitpanes>
{:else}
<div class="p-3 text-xs text-secondary">
Asset details. Use the producer/consumer arrows in the graph to navigate.
</div>
{/if}
<!-- Vertical split: top pane is kind-specific (S3 has a content
preview; other kinds fall back to a navigational hint until
they grow their own previews); bottom pane is the runs panel,
which is generic — runs are keyed by producer script path,
not by asset kind, so it's useful for every asset. -->
<Splitpanes horizontal class="!h-full">
<Pane size={55} minSize={20}>
{#if selection.asset_kind === 's3object'}
<S3FilePreview
fileKey={selection.path}
showMetadata
class="h-full"
refreshKey={previewRefreshKey}
/>
{:else if selection.asset_kind === 'datatable'}
<DataTablePreview path={selection.path} class="h-full" refreshKey={previewRefreshKey} />
{:else}
<div class="p-3 text-xs text-secondary">
No inline preview yet for {selection.asset_kind}. Use the producer/consumer arrows in
the graph to navigate. Runs of the upstream script are below.
</div>
{/if}
</Pane>
<Pane size={45} minSize={20}>
<AssetRunsPanel
producers={selectionProducers}
refreshKey={runsRefreshKey}
pendingJobId={runsPendingJobId}
onRunCompleted={() => (previewRefreshKey += 1)}
/>
</Pane>
</Splitpanes>
{:else if selection?.kind === 'runnable' && selection.runnable_kind === 'flow' && !isDraft}
<div class="p-3 text-xs text-secondary">
Flows are not editable inline. Use the open-in-editor button above.
@@ -9,7 +9,6 @@
import type { ScriptLang } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { base } from '$lib/base'
// Shape used for both the data prop and the run callback. Drafts carry
// `content` / `language` so the page-level run handler can dispatch to
@@ -74,22 +73,10 @@
// Fire every script producer in parallel — matches what would
// happen if every upstream trigger fired together. The handler
// internally dispatches to runScriptByPath / runScriptPreview
// based on producer.unsaved.
const jobs = (await Promise.all(scriptProducers.map((p) => handler(p)))).filter(
(j): j is string => !!j
)
if (jobs.length === 1) {
sendUserToast(`Running ${scriptProducers[0].path}`, false, [
{
label: 'Open run',
callback: () => {
window.open(`${base}/run/${jobs[0]}?workspace=${$workspaceStore}`, '_blank')
}
}
])
} else if (jobs.length > 1) {
sendUserToast(`Running ${jobs.length} producers of this asset`)
}
// based on producer.unsaved. No success toast — the runs panel
// auto-selects the new job and shows status/logs/output, so
// the toast was redundant.
await Promise.all(scriptProducers.map((p) => handler(p)))
} catch (err: any) {
sendUserToast(`Failed to run: ${err.body ?? err.message}`, true)
} finally {
@@ -33,8 +33,19 @@
// uses it after dispatching a run so the new job appears without
// waiting for the next poll tick.
refreshKey?: any
// Most-recently-dispatched job id. When this changes, the panel
// switches selection to it so the user lands on their just-started
// run without an extra click. Independent of refreshKey so callers
// can refresh without forcing a selection change.
pendingJobId?: string | undefined
// Fires when the watched job transitions to terminal (success or
// failure). The asset detail pane uses this to re-check the
// asset's preview — a successful run materializes the asset, so
// "not yet materialized" can move to the actual preview without
// the user re-selecting.
onRunCompleted?: () => void
}
let { producers, refreshKey }: Props = $props()
let { producers, refreshKey, pendingJobId, onRunCompleted }: Props = $props()
let runnableProducers = $derived(producers.filter((p) => p.kind === 'script'))
// Stable string key for the producer set. The parent re-derives
@@ -98,20 +109,54 @@
}
})
// Track the last pendingJobId we honored. Without this guard, manually
// selecting a *different* run from the history popover would be undone
// by a re-run of this effect (e.g. on parent re-derivation) — every
// trip would slam selectedId back to pendingJobId.
let appliedPendingJobId = $state<string | undefined>(undefined)
$effect(() => {
const id = pendingJobId
if (id && id !== appliedPendingJobId) {
appliedPendingJobId = id
selectedId = id
}
})
// Track the id we last started watching so we don't restart the
// JobLoader stream on every effect re-run. The earlier flood (~4400
// `/jobs_u/get/<id>` requests per second) came from JobLoader.watchJob
// being called on every effect tick: each call resets internal state
// and fires a fresh `getJob`, so a few extra effect runs per second
// snowballed into thousands of fetches.
let lastWatchedId = $state<string | undefined>(undefined)
$effect(() => {
// Watch the selected job as soon as one is picked.
const id = selectedId
if (id === lastWatchedId) return
lastWatchedId = id
if (!id) {
selectedJob = undefined
void jobLoader?.clearCurrentJob?.()
// untrack: clearCurrentJob reads JobLoader internals (tracked
// state in another component); without untrack, those reads
// become deps of this effect and any change to them
// (e.g. JobLoader's own bind:isLoading writes) would re-run
// it.
untrack(() => {
selectedJob = undefined
void jobLoader?.clearCurrentJob?.()
})
return
}
void jobLoader?.watchJob(id, {
done: () => {
// When a run we're watching finishes, refresh the listing so
// its row shows the terminal status.
void refresh()
}
untrack(() => {
void jobLoader?.watchJob(id, {
done: () => {
// When a run we're watching finishes, refresh the listing
// so its row shows the terminal status, and notify the
// parent so the asset preview can re-check existence —
// a successful run materializes the asset.
void refresh()
onRunCompleted?.()
}
})
})
})
@@ -0,0 +1,87 @@
<script lang="ts">
// Inline preview for a datatable asset. Reuses the existing
// DBManagerContent component (the same one the global Database Manager
// drawer uses) so users get the same rich rows/schema view here that
// they'd get clicking into a datatable from the Assets page.
//
// The asset path comes in as `<datatable>/<table>` (e.g. `main/event_summary`).
// We split that into a `datatable://<datatable>` resourcePath + a
// specificTable, mirroring what dbManagerDrawerModel.parse does for the
// equivalent URL state.
import DBManagerContent from '$lib/components/DBManagerContent.svelte'
import type { DbInput } from '$lib/components/dbTypes'
import { twMerge } from 'tailwind-merge'
interface Props {
// Asset path as parsed from `datatable://<datatable>/<table>` —
// already stripped of the prefix. May be `<datatable>` only (no
// table) or `<datatable>/<table>`. We surface a small empty-state
// hint when the table half is missing.
path: string | undefined
// Bump this to force a re-mount and re-fetch (parallel to
// S3FilePreview's refreshKey). The asset detail pane uses it after
// an upstream run completes so the table contents get re-read
// without the user re-clicking.
refreshKey?: any
class?: string
}
let { path, refreshKey, class: className = '' }: Props = $props()
// Default schema for datatable inputs is `public` — same default the
// dbManagerDrawerModel applies when the URL omits it. The DB manager
// resolves the workspace-specific Postgres schema at the connection
// level, so we don't have to look it up here.
const DEFAULT_SCHEMA = 'public'
let parsed = $derived.by(() => {
const p = path ?? ''
const slash = p.indexOf('/')
if (slash < 0) return { datatable: p, table: undefined as string | undefined }
return { datatable: p.slice(0, slash), table: p.slice(slash + 1) }
})
let input = $derived<DbInput | undefined>(
parsed.datatable
? {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${parsed.datatable}`,
specificSchema: DEFAULT_SCHEMA,
specificTable: parsed.table
}
: undefined
)
// Selected schema/table for the manager. We pin to public + the parsed
// table so the manager opens straight to the right rows; users can
// still navigate via the sidebar if they want to see siblings.
let selectedSchemaKey = $state<string | undefined>(DEFAULT_SCHEMA)
let selectedTableKey = $state<string | undefined>(parsed.table)
$effect(() => {
// Re-pin selection when the path changes (e.g. user clicks a different
// datatable asset in the graph).
selectedSchemaKey = DEFAULT_SCHEMA
selectedTableKey = parsed.table
})
</script>
<div class={twMerge('flex flex-col h-full min-h-0', className)}>
{#if !input}
<div class="p-3 text-xs text-tertiary">No datatable selected.</div>
{:else if !parsed.table}
<div class="p-3 text-xs text-tertiary">
Pick a specific table — this asset only references the datatable
<span class="font-mono">{parsed.datatable}</span>.
</div>
{:else}
<!-- Re-mount on refreshKey so DBManagerContent's internal resources
re-fetch (loadAllTablesMetaData, schemas, etc.) without us
having to thread refresh hooks through the manager. The {#key}
block is a coarse but effective way to do it. -->
{#key refreshKey}
<DBManagerContent {input} showRepl={false} bind:selectedSchemaKey bind:selectedTableKey />
{/key}
{/if}
</div>
@@ -563,6 +563,9 @@
// the listing immediately — the new (preview or script) job appears in
// the history popover without waiting on its 3 s poll tick.
let runsRefreshKey = $state(0)
// The most recently dispatched job id — surfaces to AssetRunsPanel so
// the new run auto-selects without an extra click.
let runsPendingJobId = $state<string | undefined>(undefined)
// Producers (write/rw edges) for the currently-selected asset, derived
// from `graphWithDraft.edges`. Threaded into the details pane so the
@@ -815,7 +818,10 @@
requestBody: {}
})
}
if (jobId) runsRefreshKey++
if (jobId) {
runsPendingJobId = jobId
runsRefreshKey++
}
return jobId
}}
/>
@@ -826,6 +832,7 @@
selection={activeDraft ? undefined : selection}
selectionProducers={activeDraft ? [] : selectionProducers}
{runsRefreshKey}
{runsPendingJobId}
draftScript={activeDraft?.script}
workspace={$workspaceStore}
onAnnotationsChange={(scriptPath, annotations) => {