Files
windmill/frontend/src/lib/components/DBManagerContent.svelte
T
Diego Imbert 3c66ebf4fa Merge remote-tracking branch 'origin/main' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-api-workspaces/src/datatable_permissions_oss.rs
#	frontend/src/lib/components/DBManager.svelte
#	frontend/src/lib/components/DBManagerContent.svelte
#	frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte
#	frontend/src/lib/components/datatableAcl/PgAclEditor.svelte
#	frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte
#	frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte
#	frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte
#	frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte
2026-09-20 13:14:35 +02:00

419 lines
13 KiB
Svelte

<script lang="ts">
import { dbSchemas, type DBSchema } from '$lib/stores'
import type { DataTableTables } from '$lib/gen'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
import Button from './common/button/Button.svelte'
import {
dbSupportsSchemas,
getLanguageByResourceType
} from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import DbWorkerTagPicker from './DbWorkerTagPicker.svelte'
import MissingWorkerTagAlert from './jobs/MissingWorkerTagAlert.svelte'
import {
dbSchemaOpsWithPreviewScripts,
dbTableOpsWithPreviewScripts,
getDatabaseArg,
getDbType,
getDefaultDbTag,
getDucklakeSchema
} from './dbOps'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import type { DatatableRowAction, DbInput } from './dbTypes'
import { schemaCacheKey } from './dbSchemaCache'
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
import type { PendingRowAction, SelectedTable } from './DBManager.svelte'
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
import { resource } from 'runed'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { outOfOrderRunMessage } from './workspaceSettings/datatableMigrationUtils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
input?: DbInput
showRepl?: boolean
hasReplResult?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
/** Every data table with its schemas and tables, for the left-pane tree. Undefined when
* the manager is not on a data table, which drops the tree's top level. */
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
selectedTables?: SelectedTable[]
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
/** Workspace the datatable/schema lookups run against. Defaults to the operating
* workspace (see `useOperatingWorkspace`). */
workspace?: string
/** Worker tag every job of this manager runs on, overriding the database
* language's native tag. Bound so the hints below can offer to set it. */
workerTag?: string
}
let {
input,
showRepl = true,
hasReplResult = $bindable(false),
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onSelectRole,
pendingAction = $bindable(),
onDatatableAction,
canManageDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
onImport,
workspace = undefined,
workerTag = $bindable()
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(ws, input)])
const outOfOrderModal = createAsyncConfirmationModal()
// Reported in place of the loading spinner: both queries run as jobs, so
// anything from a bad connection to a tag no worker serves surfaces here
// instead of leaving the manager spinning with no explanation. Each query
// owns its slot so neither can clear the other's error on a refetch.
let schemaError = $state<string | undefined>(undefined)
let colDefsError = $state<string | undefined>(undefined)
function emptySchemaFor(db: DbInput): DBSchema {
return {
lang: db.type === 'ducklake' ? 'ducklake' : getLanguageByResourceType(db.resourceType),
schema: {},
publicOnly: undefined,
stringified: ''
} as DBSchema
}
let loadError = $derived(
schemaError
? { title: 'Could not load the database schema', message: schemaError }
: colDefsError
? { title: 'Could not load the tables of this database', message: colDefsError }
: undefined
)
// A query already handed to the queue cannot be taken back: `resource` aborts its
// controller, but the poller behind these queries doesn't watch the signal, and a
// job left on a tag no worker serves only fails ~90s later. Stamp each run so a
// superseded one can neither report its failure nor overwrite what replaced it —
// picking a working tag from the hints below is exactly that race.
let colDefsRun = 0
let schemaRun = 0
let colDefs = resource(
() => [input, ws, workerTag],
async () => {
const run = ++colDefsRun
colDefsError = undefined
if (!input) return
try {
const metadata = await loadAllTablesMetaData(ws, input, workerTag)
return run === colDefsRun ? metadata : colDefs.current
} catch (e) {
if (run !== colDefsRun) return colDefs.current
colDefsError = 'Error loading tables metadata: ' + ((e as Error)?.message || e)
return
}
}
)
let dbSchemasPromise = resource(
() => [input, ws, workerTag],
async () => {
const run = ++schemaRun
schemaError = undefined
if (!input) return
const dbSchemasPath = schemaCacheKey(ws, input)
if (input.type == 'database') {
let connection = input.resourcePath
try {
// The role'd reference, validated: an invalid role fails here rather than
// reading the schema as the data table's default role.
if (connection.startsWith('datatable://')) connection = getDatabaseArg(input).database!
} catch (e) {
schemaError = (e as Error)?.message ?? String(e)
return
}
// Reported through a local, not `schemaError` directly, so a superseded
// run's callback can't fail a load that already succeeded.
let queryError: string | undefined
const schema = await getDbSchemas(
input.resourceType,
connection,
ws,
(message: string) => (queryError = message),
{ customTag: workerTag }
)
if (run !== schemaRun) return
if (!schema) {
schemaError = queryError ?? 'The schema query returned no schema'
return
}
$dbSchemas[dbSchemasPath] = schema
} else if (input.type == 'ducklake') {
try {
const schema = await getDucklakeSchema({
workspace: ws!,
ducklake: input.ducklake,
tag: workerTag
})
if (run === schemaRun) $dbSchemas[dbSchemasPath] = schema
} catch (e) {
if (run !== schemaRun) return
schemaError = 'Error fetching schema: ' + ((e as Error)?.message || e)
}
}
}
)
export const refresh = () => {
colDefs.refetch()
dbSchemasPromise.refetch()
}
export function isLoading() {
return colDefs.loading || dbSchemasPromise.loading
}
// Knowing the tag up front is what makes the missing-worker hint below possible
// without waiting for the poller to give up.
let defaultTag = $derived(input ? getDefaultDbTag(input) : undefined)
let jobTag = $derived(workerTag ?? defaultTag)
// A job queued behind busy workers still loads eventually, so this is a hint
// rather than an error. The no-worker-at-all case fails outright instead.
const SLOW_LOAD_MS = 10_000
let slowLoad = $state(false)
$effect(() => {
if (!isLoading()) {
slowLoad = false
return
}
const t = setTimeout(() => (slowLoad = true), SLOW_LOAD_MS)
return () => clearTimeout(t)
})
let replPanelSize = $state(36)
const REPL_MIN_SIZE = 1.5
let replResultData: undefined | Record<string, any>[] = $state(undefined)
// Sync replResultData state with bindable prop
$effect(() => {
hasReplResult = !!replResultData
})
// Export for parent components
export function clearReplResult() {
replResultData = undefined
}
let _dbManager: DbManager | undefined = $state()
export const dbManager = () => _dbManager
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
if (replResultData) {
replResultData = undefined
}
}
}}
/>
<!-- A load error replaces only the data pane: the tree, its role badge and menus, and the REPL
stay usable, so another data table or role can be picked and the connection tried by hand.
The tree then gets an empty schema: the cached one survives a failed refetch and would pass
stale content off as what this connection reaches. -->
{#snippet errorPane()}
<div class="h-full w-full flex flex-col items-center justify-center gap-3 p-8">
<div class="max-w-2xl w-full flex flex-col gap-3">
<Alert type="error" title={loadError?.title ?? ''} size="xs">
{loadError?.message}
</Alert>
<div class="self-start">
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: RefreshCcw }}
on:click={() => refresh()}
>
Retry
</Button>
</div>
<!-- A database that no default worker can reach fails here rather than in the
missing-worker path below, so the same override is offered on any load error. -->
<DbWorkerTagPicker
bind:tag={workerTag}
{defaultTag}
workspace={ws}
class="border-t pt-3 max-w-md"
/>
</div>
</div>
{/snippet}
{#if (loadError || dbSchema) && ws && input}
{@const _input = input}
{@const dbType = getDbType(_input)}
{@const shownSchema = loadError || !dbSchema ? emptySchemaFor(_input) : dbSchema}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={'absolute inset-0 z-10 p-8 ' +
(replResultData
? 'bg-surface/90'
: 'transition-colors bg-transparent pointer-events-none select-none')}
onclick={(e) => {
// Only proceed if the click is directly on this div and not on the child elements
if (e.target === e.currentTarget) {
replResultData = undefined
}
}}
>
{#if replResultData}
{#key replResultData}
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
{/key}
{/if}
</div>
<DbManager
dbSupportsSchemas={dbSupportsSchemas(dbType)}
databaseIsEmpty={!loadError &&
!Object.values(shownSchema.schema).flatMap((s) => Object.values(s)).length}
dbSchema={shownSchema}
mainPane={loadError ? errorPane : undefined}
colDefs={loadError ? undefined : colDefs.current}
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
dbTableOpsWithPreviewScripts({
colDefs,
tableKey,
whereClause,
input: _input,
workspace: ws,
tag: workerTag
})}
dbSchemaOps={dbSchemaOpsWithPreviewScripts({
input: _input,
workspace: ws,
tag: workerTag,
confirmRunOutOfOrder: (pending) =>
outOfOrderModal.ask({
title: 'Run migration out of order',
confirmationText: 'Run anyway',
children: outOfOrderRunMessage(pending)
})
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.specificSchema}
asset={_input.type == 'ducklake'
? { kind: 'ducklake', path: _input.ducklake }
: _input.resourcePath.startsWith('datatable://')
? { kind: 'datatable', path: _input.resourcePath.substring('datatable://'.length) }
: undefined}
{dbType}
refresh={() => refresh()}
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onSelectRole}
workspace={ws}
currentRole={input.type === 'database' ? input.role : undefined}
bind:pendingAction
{onDatatableAction}
{canManageDatatable}
{onImport}
bind:selectedSchemaKey
bind:selectedTableKey
{multiSelectMode}
bind:selectedTables
bind:this={_dbManager}
{disabledTables}
features={getDbFeatures(input)}
/>
</Pane>
{#if showRepl}
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
<SqlRepl
{input}
{workspace}
tag={workerTag}
onData={(data) => {
replResultData = data
}}
onSchemaChange={() => refresh()}
placeholderTableName={sortArray(
Object.keys(
shownSchema.schema[
'public' in shownSchema.schema
? 'public'
: 'dbo' in shownSchema.schema
? 'dbo'
: Object.keys(shownSchema.schema ?? {})?.[0]
] ?? {}
)
)?.[0]}
/>
</Pane>
{/if}
</Splitpanes>
{:else}
<Splitpanes>
<Pane class="relative flex flex-col justify-center items-center gap-3 p-8">
<Loader2 class="animate-spin" size={32} />
{#if slowLoad}
<span class="text-xs text-tertiary max-w-md text-center">
The schema query is taking a while. It runs as a job, so it waits for a worker serving its
tag to be free.
</span>
{#if jobTag}
<MissingWorkerTagAlert
tag={jobTag}
subject="Database queries"
workspace={ws}
class="max-w-2xl w-full"
/>
{/if}
<DbWorkerTagPicker
bind:tag={workerTag}
{defaultTag}
workspace={ws}
class="max-w-md w-full"
/>
{/if}
</Pane>
</Splitpanes>
{/if}
<Portal>
<!-- Stacks above the DB table editor's own preview confirmation (z-[9999]),
which is still open when applyDdl asks for out-of-order confirmation. -->
<ConfirmationModal {...outOfOrderModal.props} zIndexClass="z-[10000]" />
</Portal>