mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
all
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user