Files
windmill/frontend/src/lib/components/DBManagerDrawer.svelte
T

404 lines
13 KiB
Svelte

<script lang="ts">
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService, type DataTableTables } from '$lib/gen'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Select from './select/Select.svelte'
import { ArrowLeft, Copy, Expand, Minimize, RefreshCcw } from 'lucide-svelte'
import DBManagerContent from './DBManagerContent.svelte'
import type { PendingRowAction } from './DBManager.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
import { resource } from 'runed'
import { tick, untrack } from 'svelte'
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
import type { DatatableRowAction } from './dbTypes'
import ResourcePicker from './ResourcePicker.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import { useDbManagerTag } from './dbManagerTag.svelte'
import DbWorkerTagButton from './DbWorkerTagButton.svelte'
interface Props {
uriState: DbManagerUriState
/** Z-index offset for the drawer, useful when opening from within modals */
offset?: number
}
let { uriState, offset = 0 }: Props = $props()
let open = $derived(uriState.open)
// The workspace the drawer's DB operations run against — the acting workspace of
// the editor that opened it (set via openDrawer), else the nav workspace.
let ws = $derived(uriState.workspace ?? $workspaceStore)
// A create started on a data table other than the current one: survives the
// re-mount the switch causes.
let pendingAction = $state<PendingRowAction | undefined>(undefined)
// Every data table with its schemas and tables, in one call: this is what the
// left pane's tree navigates, so it has to cover the data tables the user is
// not currently on, not just the selected one.
const datatables = resource<DataTableTables[]>([], async () => {
if (!ws) return []
try {
return await WorkspaceService.listDataTableTables({ workspace: ws })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
// Roles the *caller* may use, so the picker never offers one that would be
// refused. Absent/disabled permissions yield no roles and hide the picker.
const usableRoles = resource(
() => [ws, uriState.selectedDatatable] as const,
async ([workspace, datatable]) => {
if (!workspace || !datatable) return undefined
try {
return {
datatable,
...(await WorkspaceService.listUsableDatatableRoles({
workspace,
datatableName: datatable
}))
}
} catch (e) {
// Never leave the drawer waiting on this: fall back to the
// unpermissioned shape so it opens and the server picks the role.
console.error('Failed to load datatable roles:', e)
return { datatable, enabled: false, roles: [], default_role: 'admin' }
}
}
)
// A resource keeps its previous value while refetching, and roles are per data
// table: settling from the last one's answer would connect to the new data
// table as a role it may not even have.
const rolesOfCurrent = $derived(
usableRoles.current?.datatable === uriState.selectedDatatable ? usableRoles.current : undefined
)
// The content must not mount until the role is settled: mounting is what fires
// the schema and metadata queries, and a first round sent without a role would
// run — and cache — as whatever the server defaults to.
const roleSettled = $derived(
!uriState.isDatatableInput ||
(rolesOfCurrent !== undefined &&
(!rolesOfCurrent.enabled ||
rolesOfCurrent.roles.length === 0 ||
uriState.selectedRole !== undefined))
)
// Settle the role before anything queries the data table: the schema and
// metadata fetches run as whatever role the input carries, so leaving it unset
// until the user touches the picker would send the first — and cached — round
// of queries as a role they may not be allowed to use.
$effect(() => {
const roles = rolesOfCurrent
if (!roles?.enabled || uriState.selectedRole !== undefined) return
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
if (effective) untrack(() => (uriState.selectedRole = effective))
})
// Refetch datatables when switching to a datatable input
$effect(() => {
if (uriState.isDatatableInput) {
untrack(() => datatables.refetch())
}
})
function handleClose() {
uriState.closeDrawer()
dbManagerContent?.clearReplResult()
}
let windowWidth = $state(window.innerWidth)
let expand = $state(false)
$effect(() => {
if (!open) {
expand = false
uriState.closeDrawer()
}
})
let dbManagerContent: DBManagerContent | undefined = $state()
// Per-database worker tag override, remembered across drawer opens.
const workerTag = useDbManagerTag(
() => ws,
() => uriState.effectiveInput
)
let hasReplResult = $state(false)
// Export/Import state
let exportDrawerOpen = $state(false)
let exportResult = $state('')
let importDrawerOpen = $state(false)
let importLoading = $state(false)
let importSource = $state<string | undefined>(undefined)
/** Which database an import writes into; set when driven from a tree row. */
let importTarget = $state<string | undefined>(undefined)
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
let isPostgresqlInput = $derived(
uriState.isDatatableInput ||
(uriState.input?.type === 'database' && uriState.input.resourceType === 'postgresql')
)
let enableImportExport = $derived(isPostgresqlInput)
function toSourceIdentifier(raw: string): string {
if (raw.startsWith('datatable://') || raw.startsWith('$res:')) return raw
return `$res:${raw}`
}
function currentSourceIdentifier(): string | undefined {
const input = uriState.effectiveInput
if (!input || input.type !== 'database') return undefined
return toSourceIdentifier(input.resourcePath)
}
// The tree's row menus act on the data table of the row that was clicked, which
// is not necessarily the one currently open — so the target is set first and the
// headless modals are keyed on it.
let actionDatatable = $state<string | undefined>(undefined)
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
actionDatatable = datatable
// Let the keyed block above mount against the new target before driving it.
await tick()
switch (action) {
case 'migrations':
migrationsModal?.open()
break
case 'roles':
permissionsDrawer?.openPermissions()
break
case 'export':
await handleExportSchema(`datatable://${datatable}`)
break
case 'import':
importTarget = `datatable://${datatable}`
importDrawerOpen = true
break
}
}
function refreshManager() {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
}
async function handleExportSchema(explicitSource?: string) {
const source = explicitSource ?? currentSourceIdentifier()
if (!source || !ws) return
try {
exportResult = await WorkspaceService.exportPgSchema({
workspace: ws,
requestBody: { source }
})
exportDrawerOpen = true
} catch (e) {
sendUserToast(`Failed to export schema: ${e}`, true)
}
}
async function handleImportDatabase() {
if (!importSource || !ws) return
const target = importTarget ?? currentSourceIdentifier()
if (!target) return
importLoading = true
try {
await WorkspaceService.importPgDatabase({
workspace: ws,
requestBody: {
source: toSourceIdentifier(importSource),
target,
fork_behavior: importBehavior
}
})
sendUserToast('Database import completed successfully')
importDrawerOpen = false
importSource = undefined
dbManagerContent?.refresh()
} catch (e) {
sendUserToast(`Failed to import database: ${e}`, true)
} finally {
importLoading = false
}
}
</script>
<svelte:window bind:innerWidth={windowWidth} />
<Drawer
bind:open
size={expand ? `${windowWidth}px` : '1200px'}
preventEscape
{offset}
on:close={handleClose}
>
<DrawerContent
title={hasReplResult ? 'Query Result' : 'Database Manager'}
on:close={() => {
if (hasReplResult) {
dbManagerContent?.clearReplResult()
} else {
handleClose()
}
}}
CloseIcon={hasReplResult ? ArrowLeft : undefined}
noPadding
id="db-manager-drawer"
>
{#if uriState.effectiveInput && ws && roleSettled}
{#key `${uriState.selectedDatatable}~${uriState.selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={uriState.effectiveInput}
workspace={uriState.workspace}
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
onSelectRole={(dt, role) => {
// Setting the data table clears the role, so the order matters.
uriState.selectedDatatable = dt
uriState.selectedRole = role
}}
bind:pendingAction
canManageDatatable={!!($superadmin || $userStore?.is_admin)}
onDatatableAction={runDatatableAction}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey={uriState.selectedSchema}
bind:selectedTableKey={uriState.selectedTable}
onImport={enableImportExport
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
: undefined}
></DBManagerContent>
{/key}
{/if}
{#snippet actions()}
{#if uriState.effectiveInput && ws}
<DbWorkerTagButton
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
input={uriState.effectiveInput}
workspace={ws}
/>
{/if}
<Button
loading={dbManagerContent?.isLoading() ?? false}
on:click={refreshManager}
startIcon={{ icon: RefreshCcw }}
iconOnly
title="Refresh"
size="xs"
color="light"
/>
<Button
on:click={() => (expand = !expand)}
startIcon={{ icon: expand ? Minimize : Expand }}
size="xs"
color="light"
/>
{/snippet}
</DrawerContent>
</Drawer>
{#if actionDatatable && ws}
<DataTableMigrationsButton
bind:this={migrationsModal}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSchemaChanged={refreshManager}
/>
<DataTablePermissionsButton
bind:this={permissionsDrawer}
hideTrigger
workspace={ws}
datatable={actionDatatable}
/>
{/if}
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
{#if exportResult}
<div class="flex flex-col gap-2 h-full relative">
<pre class="overflow-auto text-xs bg-surface-secondary p-4 rounded flex-1"
>{exportResult}</pre
>
<Button
size="xs"
color="light"
startIcon={{ icon: Copy }}
wrapperClasses="absolute top-2 right-2"
btnClasses="bg-surface-tertiary"
on:click={() => {
navigator.clipboard.writeText(exportResult)
sendUserToast('Copied to clipboard')
}}
>
Copy
</Button>
</div>
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:open={importDrawerOpen} size="600px" offset={offset + 1}>
<DrawerContent title="Import Database" on:close={() => (importDrawerOpen = false)}>
<div class="flex flex-col gap-4">
<Alert type="warning" title="Warning">
This will import the schemas from the selected source into the current database. Existing
tables with the same names may be affected.
</Alert>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">Source database</span>
<ResourcePicker
datatableAsPgResource
bind:value={importSource}
resourceType="postgresql"
workspace={ws}
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">Import mode</span>
<Select
items={[
{ value: 'schema_only', label: 'Schema only' },
...(isCloudHosted() || (!$superadmin && !$userStore?.is_admin)
? []
: [{ value: 'schema_and_data', label: 'Schema and data' }])
]}
bind:value={importBehavior}
/>
</div>
{#if importBehavior === 'schema_and_data'}
<Alert type="warning" title="Heavy operation">
Importing schema and data will copy all rows from every table in the source database. This
may take a long time and use significant storage space depending on the size of the
source.
</Alert>
{/if}
<Button
disabled={!importSource}
loading={importLoading}
color="red"
on:click={handleImportDatabase}
>
Import {importBehavior === 'schema_and_data' ? 'schemas and data' : 'schemas'} into current database
</Button>
</div>
</DrawerContent>
</Drawer>