This commit is contained in:
Ruben Fiszel
2026-05-05 07:27:48 +00:00
parent be9c8c5d92
commit 67c2ce3936
4 changed files with 228 additions and 54 deletions
@@ -292,6 +292,7 @@
>
<div class="flex items-center gap-2 min-w-0">
{#if isDraft && script}
{@const draftScriptPath = script.path}
<Code2 size={16} class="shrink-0 text-emerald-700 dark:text-emerald-400" />
{#if onDraftPathChange}
<!-- Inline rename popover for drafts. The persisted-script
@@ -318,7 +319,9 @@
Draft pipeline script
<Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" />
</span>
<span class="text-xs font-mono truncate" title={script.path}>{script.path}</span>
<span class="text-xs font-mono truncate" title={draftScriptPath}
>{draftScriptPath}</span
>
</button>
{/snippet}
{#snippet content({ close })}
@@ -1,87 +1,212 @@
<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'
// Single-table preview for a datatable asset. Shows the rows for the
// specific table referenced by the asset path; if the table — or the
// underlying datatable — doesn't exist yet, surfaces the relevant
// empty state with a path forward (configure datatable / wait for the
// upstream script to run). The full DBManager (with sidebar) is
// available from the explore-asset button; in this pane the user
// already picked the table they want to look at, so the sidebar would
// just be noise.
import DBTable from '$lib/components/DBTable.svelte'
import { resource } from 'runed'
import { workspaceStore } from '$lib/stores'
import { loadAllTablesMetaData } from '$lib/components/apps/components/display/dbtable/metadata'
import { dbTableOpsWithPreviewScripts } from '$lib/components/dbOps'
import { WorkspaceService } from '$lib/gen'
import type { DbInput } from '$lib/components/dbTypes'
import { parseDbInputFromAssetSyntax } from '$lib/utils'
import { AlertTriangle, Database, Loader2, Settings } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { base } from '$lib/base'
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.
// table) or `<datatable>/<table>`.
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.
// Bump this to force a re-fetch of the row data and metadata
// (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.
// Default PG schema when the asset URI omits one. The asset URI shape
// is `datatable://<db>/<table>` for the default schema and
// `datatable://<db>/<schema>.<table>` for an explicit schema —
// `parseDbInputFromAssetSyntax` returns `specificSchema: undefined` in
// the first case, which we resolve to `public` here.
const DEFAULT_SCHEMA = 'public'
// Reuse the shared parser so explicit-schema URIs (like
// `main/analytics.events`) are handled the same way the rest of the
// codebase handles them, instead of treating the dot as part of the
// table name.
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) }
if (slash < 0)
return { datatable: p, schema: undefined, table: undefined as string | undefined }
const datatable = p.slice(0, slash)
const tail = p.slice(slash + 1)
// Delegate to the canonical parser so we pick up the same
// `<schema>.<table>` convention as the rest of the asset stack.
const dbInput = parseDbInputFromAssetSyntax(`datatable://${datatable}/${tail}`)
const specificSchema =
dbInput && 'specificSchema' in dbInput ? dbInput.specificSchema : undefined
const specificTable = dbInput?.specificTable
return {
datatable,
schema: specificSchema as string | undefined,
table: specificTable as string | undefined
}
})
// Workspace-configured datatables (the names in workspace_settings →
// Data tables). If the parsed datatable isn't in this list, the
// underlying connection / DB doesn't exist yet — there's nothing to
// query, and the user needs to configure it before pipeline scripts
// can write to it.
let datatables = resource(
() => $workspaceStore,
async (ws) => {
if (!ws) return [] as string[]
try {
return (await WorkspaceService.listDataTables({ workspace: ws })).map((d) => d.name)
} catch {
return [] as string[]
}
}
)
let datatableExists = $derived((datatables.current ?? []).includes(parsed.datatable))
// Resolve to the parsed schema when the URI was explicit, otherwise
// fall back to the workspace default. This is what gets keyed into
// loadAllTablesMetaData, so an explicit-schema URI like
// `main/analytics.events` lands on the right entries.
let effectiveSchema = $derived(parsed.schema ?? DEFAULT_SCHEMA)
let input = $derived<DbInput | undefined>(
parsed.datatable
parsed.datatable && datatableExists
? {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${parsed.datatable}`,
specificSchema: DEFAULT_SCHEMA,
specificSchema: effectiveSchema,
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)
// Metadata for every table in the datatable. We query the lot rather
// than just the one we care about because `loadAllTablesMetaData` is
// what `dbTableOpsWithPreviewScripts` and the rest of the DB manager
// expect — and the cost is one round-trip vs paying it later anyway.
// `refreshKey` is included as a dep so a re-render after a producer
// run picks up newly-created tables without the user reselecting.
let colDefs = resource(
() => [input, refreshKey],
async ([_input]) => {
if (!_input || !$workspaceStore) return undefined
try {
return await loadAllTablesMetaData($workspaceStore, _input)
} catch {
// Connection/permission errors look identical to "table
// missing" from the user's POV inside the preview pane;
// the full error is surfaced via the existing sendUserToast
// path inside loadAllTablesMetaData.
return undefined
}
}
)
$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
// loadAllTablesMetaData keys columns by either `schema.table` (when
// the DB has multiple schemas) or just `table`. We look up the
// schema-qualified key first using the parsed/default schema, then
// fall back to the bare key so the preview survives both shapes the
// metadata loader returns.
let tableColDefs = $derived.by(() => {
if (!parsed.table || !colDefs.current) return undefined
return colDefs.current[`${effectiveSchema}.${parsed.table}`] ?? colDefs.current[parsed.table]
})
let dbTableOps = $derived(
input && tableColDefs && parsed.table && $workspaceStore
? dbTableOpsWithPreviewScripts({
input,
tableKey: parsed.table,
colDefs: tableColDefs,
workspace: $workspaceStore
})
: undefined
)
</script>
<div class={twMerge('flex flex-col h-full min-h-0', className)}>
{#if !input}
<div class={twMerge('flex flex-col h-full min-h-0 relative', className)}>
{#if !parsed.datatable}
<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. -->
{:else if datatables.loading && !datatables.current}
<div class="absolute inset-0 flex items-center justify-center text-tertiary">
<Loader2 class="animate-spin" size={20} />
</div>
{:else if !datatableExists}
<!-- The datatable name isn't in workspace settings yet — nothing
to query against. Send the user to the settings page rather
than creating one in-place because the choice of underlying
PG resource is non-trivial (custom instance DB vs external
resource) and the settings UI is the right place for it. -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<Database size={28} class="text-tertiary" />
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-emphasis">Datatable not configured</span>
<span class="text-2xs text-tertiary">
The datatable
<span class="font-mono">{parsed.datatable}</span>
isn't set up in this workspace yet.
</span>
</div>
<Button
href="{base}/workspace_settings?tab=windmill_data_tables"
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Settings }}
>
Configure datatable
</Button>
</div>
{:else if colDefs.loading && !colDefs.current}
<div class="absolute inset-0 flex items-center justify-center text-tertiary">
<Loader2 class="animate-spin" size={20} />
</div>
{:else if !tableColDefs}
<!-- Datatable exists but the specific table doesn't — most often
because no upstream pipeline script has run yet to create it. -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center">
<AlertTriangle size={20} class="text-amber-500" />
<span class="text-sm font-medium text-emphasis">Table doesn't exist yet</span>
<span class="text-2xs text-tertiary max-w-xs">
Nothing has been created at
<span class="font-mono"
>{parsed.datatable}/{parsed.schema ? `${parsed.schema}.` : ''}{parsed.table}</span
>
yet. It'll appear here once an upstream pipeline script writes to it.
</span>
</div>
{:else if dbTableOps}
<!-- Re-mount on refreshKey so DBTable's internal grid re-fetches
the row pages without us having to thread refresh hooks
through the table component. -->
{#key refreshKey}
<DBManagerContent {input} showRepl={false} bind:selectedSchemaKey bind:selectedTableKey />
<DBTable {dbTableOps} />
{/key}
{/if}
</div>
@@ -139,6 +139,34 @@ export function assetUri(asset: { kind: AssetKind; path: string }): string {
return `${ASSET_URI_PREFIX[asset.kind]}${asset.path}`
}
// Splits a datatable asset path (`<db>/<table>` or `<db>/<schema>.<table>`)
// into its constituent parts. Mirrors `parseDbInputFromAssetSyntax` in
// $lib/utils.ts: schema is omitted when the table lives in the default
// (`public`) schema, prefixed with `<schema>.` only when explicit.
function parseDatatablePath(p: string): {
db: string
schema: string | undefined
table: string
} {
const slash = p.indexOf('/')
if (slash < 0) return { db: p, schema: undefined, table: '' }
const db = p.slice(0, slash)
const tail = p.slice(slash + 1)
const dot = tail.indexOf('.')
if (dot < 0) return { db, schema: undefined, table: tail }
return { db, schema: tail.slice(0, dot), table: tail.slice(dot + 1) }
}
// Schema-qualified table reference for SQL emission. We always emit the
// schema (defaulting to `public`) so the generated statement doesn't rely
// on whatever search-path / default-schema the engine picks at attach
// time — important for DuckDB attaching to Postgres as `pg`, where bare
// `pg.<table>` is fragile.
function qualifiedDatatableRef(p: string, defaultSchema = 'public'): string {
const parsed = parseDatatablePath(p)
return `${parsed.schema ?? defaultSchema}.${parsed.table || 'table_name'}`
}
// Comment prefix per language; mirrors what the parser accepts.
function commentPrefix(lang: ScriptLang): string {
switch (lang) {
@@ -378,6 +406,11 @@ function bodyDuckdb(ctx: TemplateContext): string {
switch (input.kind) {
case 's3object':
return `read_parquet('s3://${input.path}')`
case 'datatable':
// `pg` is the attached Postgres catalog; we schema-qualify
// the table reference so the SELECT doesn't rely on the
// catalog's default schema setting.
return `pg.${qualifiedDatatableRef(input.path)}`
case 'ducklake': {
return null // attach + read shown below
}
@@ -428,10 +461,14 @@ function bodyDuckdb(ctx: TemplateContext): string {
break
case 'datatable':
if (output) {
const tableName = output.path.split('/').slice(1).join('_') || 'table_name'
// Schema-qualified `pg.<schema>.<table>` (rather than
// `pg.<table>`) so the CREATE TABLE doesn't silently
// land in whatever default schema the attached catalog
// resolves to.
const ref = `pg.${qualifiedDatatableRef(output.path)}`
lines.push(
`-- DataTable is exposed as the 'pg' attached database in duckdb scripts.`,
`CREATE TABLE IF NOT EXISTS pg.${tableName} AS`,
`CREATE TABLE IF NOT EXISTS ${ref} AS`,
`SELECT * FROM ${inSql ?? '(SELECT 1 AS placeholder)'};`
)
}
@@ -452,18 +489,22 @@ function bodyPostgres(ctx: TemplateContext): string {
lines.push('')
if (outputKind === 'datatable' && output) {
const tableName = output.path.split('/').slice(1).join('_') || 'table_name'
// Schema-qualified — Postgres respects search_path so a bare
// `<table>` would land in `public` by default, but emitting
// `<schema>.<table>` makes the target explicit and survives any
// non-default search_path the connection might carry.
const ref = qualifiedDatatableRef(output.path)
lines.push(
`-- The pipeline-script harness runs this query against the`,
`-- datatable backing the configured workspace storage.`,
`CREATE TABLE IF NOT EXISTS ${tableName} (`,
`CREATE TABLE IF NOT EXISTS ${ref} (`,
` id serial primary key,`,
` payload jsonb,`,
` computed_at timestamptz default now()`,
`);`,
``,
`-- Replace with your real INSERT.`,
`INSERT INTO ${tableName} (payload) VALUES ('{}'::jsonb);`
`INSERT INTO ${ref} (payload) VALUES ('{}'::jsonb);`
)
} else {
lines.push(`SELECT 1;`)
@@ -260,15 +260,20 @@
})
// Native browser tab close / reload — we can't show a custom modal
// here, only the browser's generic confirm. Setting returnValue to a
// non-empty string is the cross-browser idiom that triggers the
// "Leave site?" prompt. The text shown is browser-controlled.
// here, only the browser's generic confirm. Modern browsers require
// BOTH preventDefault() *and* a non-empty `returnValue` to actually
// pop the prompt; the assigned text is then ignored in favour of a
// browser-controlled string ("Leave site? Changes you made may not be
// saved."). Empty string used to work but Chromium 119+ silently
// suppresses the dialog in that case. The handler also requires that
// the user has interacted with the page (Chrome's user-activation
// requirement) — a fresh tab with no clicks/keystrokes won't get the
// prompt. This is enforced by the browser, not us.
$effect(() => {
if (typeof window === 'undefined') return
function onBeforeUnload(e: BeforeUnloadEvent) {
if (drafts.size === 0) return
e.preventDefault()
e.returnValue = ''
e.returnValue = 'You have unsaved drafts.'
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)