mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
664 lines
23 KiB
Svelte
664 lines
23 KiB
Svelte
<script lang="ts" module>
|
|
import { randomUUID } from '$lib/utils/uuid'
|
|
|
|
export type DataTableSettingsType = {
|
|
dataTables: {
|
|
// Stable client-side id so the UI can track renames (A -> B) across a
|
|
// save rather than seeing them as a delete + add. Never sent to the
|
|
// backend config.
|
|
id: string
|
|
name: string
|
|
database: {
|
|
resource_type: 'postgresql' | 'instance'
|
|
resource_path?: string | undefined
|
|
}
|
|
/** Set on a fork's entry: it names the workspace whose data table governs this one, and
|
|
* owns no database of its own. Read-only here — only forking writes it, and the server
|
|
* carries it across a save rather than taking it from this form. */
|
|
reference?: { workspace_id: string; datatable: string }
|
|
}[]
|
|
}
|
|
|
|
export function convertDataTableSettingsFromBackend(
|
|
settings: GetSettingsResponse['datatable']
|
|
): DataTableSettingsType {
|
|
const s: DataTableSettingsType = { dataTables: [] }
|
|
if (settings?.datatables) {
|
|
for (const [name, rest] of Object.entries(settings.datatables)) {
|
|
s.dataTables.push({
|
|
id: randomUUID(),
|
|
name,
|
|
...rest,
|
|
// A pointer entry owns no database. The row renders read-only in that case, so this
|
|
// placeholder is never shown or sent.
|
|
database: rest.database ?? { resource_type: 'instance' }
|
|
})
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
export function convertDataTableSettingsToBackend(
|
|
settings: DataTableSettingsType
|
|
): NonNullable<GetSettingsResponse['datatable']> {
|
|
const s: GetSettingsResponse['datatable'] = { datatables: {} }
|
|
for (const dataTable of settings.dataTables) {
|
|
const database = dataTable.database
|
|
if (dataTable.name in s.datatables)
|
|
throw 'Settings contain duplicate dataTable name: ' + dataTable.name
|
|
// A pointer owns no database, so it has nothing to validate and nothing to send: the
|
|
// server keeps the stored reference whatever this payload says.
|
|
if (dataTable.reference) {
|
|
s.datatables[dataTable.name] = {}
|
|
continue
|
|
}
|
|
if (!database.resource_path) throw 'No resource selected for ' + dataTable.name
|
|
if (database.resource_type === 'instance' && database.resource_path === 'windmill')
|
|
throw dataTable.name + ' database cannot be called "windmill"'
|
|
|
|
s.datatables[dataTable.name] = {
|
|
database: dataTable.database
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { Plus, PlugZap } from 'lucide-svelte'
|
|
|
|
import Button from '../common/button/Button.svelte'
|
|
|
|
import CloseButton from '../common/CloseButton.svelte'
|
|
|
|
import ResourcePicker from '../ResourcePicker.svelte'
|
|
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
|
|
import Select from '../select/Select.svelte'
|
|
import Cell from '../table/Cell.svelte'
|
|
import DataTable from '../table/DataTable.svelte'
|
|
import Head from '../table/Head.svelte'
|
|
import Row from '../table/Row.svelte'
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import Tooltip from '../Tooltip.svelte'
|
|
import {
|
|
isCustomInstanceDbEnabled,
|
|
getUnusedInstanceDbName,
|
|
isDataTableWizardEnabled
|
|
} from './utils.svelte'
|
|
import { random_adj } from '../random_positive_adjetive'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import {
|
|
SettingService,
|
|
WorkspaceService,
|
|
type GetSettingsResponse,
|
|
type TestDataTableConnectionResponse
|
|
} from '$lib/gen'
|
|
// `superadmin` gates the commented-out roles section at the bottom; restore it there.
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
|
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
|
import { resource } from 'runed'
|
|
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
|
|
import { Popover } from '../meltComponents'
|
|
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
|
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
|
|
// Both components are complete and reviewed; their call sites in this file are commented
|
|
// out until the ACL editor lands. Uncomment these with them.
|
|
// import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
|
|
// import DataTableRolesSection from './DataTableRolesSection.svelte'
|
|
import { deepEqual } from 'fast-equals'
|
|
import { clone } from '$lib/utils'
|
|
import SettingsFooter from './SettingsFooter.svelte'
|
|
import Alert from '../common/alert/Alert.svelte'
|
|
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import AddDataTableWizard from './AddDataTableWizard.svelte'
|
|
import { takeParkedWizard, type WizardResume } from './wizardParking'
|
|
import { Database } from 'lucide-svelte'
|
|
import { onMount } from 'svelte'
|
|
|
|
type Props = {
|
|
dataTableSettings: DataTableSettingsType
|
|
}
|
|
|
|
let { dataTableSettings = $bindable() }: Props = $props()
|
|
|
|
// Result of the last "Test connection", shown under the table: the grant
|
|
// statements have to stay selectable, which rules out a toast.
|
|
let connectionCheck = $state<
|
|
| {
|
|
name: string
|
|
loading: boolean
|
|
report?: TestDataTableConnectionResponse
|
|
error?: string
|
|
}
|
|
| undefined
|
|
>(undefined)
|
|
|
|
// Identifies the request the single result slot is waiting on. The data table
|
|
// name is not enough: A -> B -> A leaves two A requests in flight, and the
|
|
// first to be issued can be the last to land.
|
|
let latestCheck = 0
|
|
|
|
async function testConnection(name: string) {
|
|
const check = ++latestCheck
|
|
connectionCheck = { name, loading: true }
|
|
try {
|
|
const report = await WorkspaceService.testDataTableConnection({
|
|
workspace: $workspaceStore ?? '',
|
|
datatableName: name
|
|
})
|
|
if (check !== latestCheck) return
|
|
connectionCheck = { name, loading: false, report }
|
|
} catch (err) {
|
|
if (check !== latestCheck) return
|
|
connectionCheck = { name, loading: false, error: err?.body ?? err?.message ?? String(err) }
|
|
}
|
|
}
|
|
|
|
let tableHeadNames = ['Name', 'Database', '', ''] as const
|
|
let tableHeadTooltips: Partial<Record<(typeof tableHeadNames)[number], string | undefined>> = {
|
|
Name: 'Data tables are referenced by their name. main is a special name that can be used as the default data table.',
|
|
Database: 'The database where the data is stored.'
|
|
}
|
|
|
|
let tempSettings: DataTableSettingsType = $derived.by(() => {
|
|
let s = $state($state.snapshot(dataTableSettings))
|
|
return s
|
|
})
|
|
|
|
function removeDataTable(index: number) {
|
|
tempSettings.dataTables.splice(index, 1)
|
|
}
|
|
|
|
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
|
|
|
|
function defaultInstanceDbName(): string {
|
|
const usedNames = [
|
|
...Object.keys(customInstanceDbs.current ?? {}),
|
|
...tempSettings.dataTables
|
|
.filter((d) => d.database.resource_type === 'instance' && d.database.resource_path)
|
|
.map((d) => d.database.resource_path!)
|
|
]
|
|
return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames)
|
|
}
|
|
|
|
// Kept for the flag-off path: adding a data table is a row in this table that the user
|
|
// fills in and saves, rather than a wizard.
|
|
function onNewDataTable() {
|
|
const name = tempSettings.dataTables.some((d) => d.name === 'main')
|
|
? `${random_adj()}_datatable`
|
|
: 'main'
|
|
tempSettings.dataTables.push({
|
|
id: randomUUID(),
|
|
name,
|
|
database: {
|
|
resource_type: $isCustomInstanceDbEnabled ? 'instance' : 'postgresql',
|
|
resource_path: $isCustomInstanceDbEnabled ? defaultInstanceDbName() : undefined
|
|
}
|
|
})
|
|
}
|
|
|
|
async function onSave() {
|
|
try {
|
|
if (
|
|
$isCustomInstanceDbEnabled &&
|
|
tempSettings.dataTables.some(
|
|
(d) =>
|
|
d.database.resource_type === 'instance' &&
|
|
!customInstanceDbs.current?.[d.database.resource_path ?? '']?.success
|
|
)
|
|
) {
|
|
let confirm = await confirmationModal.ask({
|
|
title: 'Some databases are not setup',
|
|
children: 'Are you sure you want to save without setting them up ?',
|
|
confirmationText: 'Save anyway'
|
|
})
|
|
if (!confirm) return
|
|
}
|
|
const settings = convertDataTableSettingsToBackend(tempSettings)
|
|
// Track renames/deletions by stable id (against the saved baseline) so
|
|
// the backend can cascade or delete each data table's migrations.
|
|
const savedById = new Map(dataTableSettings.dataTables.map((d) => [d.id, d.name]))
|
|
const tempIds = new Set(tempSettings.dataTables.map((d) => d.id))
|
|
const renames = tempSettings.dataTables
|
|
.filter((d) => savedById.has(d.id) && savedById.get(d.id) !== d.name)
|
|
.map((d) => ({ from: savedById.get(d.id)!, to: d.name }))
|
|
const deleted_datatables = dataTableSettings.dataTables
|
|
.filter((d) => !tempIds.has(d.id))
|
|
.map((d) => d.name)
|
|
const result = await WorkspaceService.editDataTableConfig({
|
|
workspace: $workspaceStore!,
|
|
requestBody: { settings, renames, deleted_datatables }
|
|
})
|
|
dataTableSettings = clone(tempSettings)
|
|
// A delete can leave another workspace's data table governed by nothing. Swallowing
|
|
// that is what made it silent for the person who caused it.
|
|
const stranded = result?.stranded_references ?? []
|
|
if (stranded.length > 0) {
|
|
sendUserToast(
|
|
`These data tables were governed by one you deleted and no longer resolve: ${stranded
|
|
.map((s) => `${s.workspace_id}/${s.datatable}`)
|
|
.join(', ')}. Their databases still exist; a superadmin can point them at another ` +
|
|
`workspace's data table.`,
|
|
'warning',
|
|
[],
|
|
undefined,
|
|
20000
|
|
)
|
|
} else {
|
|
sendUserToast('Data table settings saved successfully')
|
|
}
|
|
} catch (e) {
|
|
sendUserToast(e, true)
|
|
console.error('Error saving data table settings', e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
const wizardEnabled = isDataTableWizardEnabled()
|
|
let wizardOpen = $state(false)
|
|
/** Opened through the wizard's own `open()`, which is what sets a fresh run up. */
|
|
let wizard: { open: (parked?: WizardResume) => void } | undefined = $state(undefined)
|
|
let wizardResume: WizardResume | undefined = $state(undefined)
|
|
|
|
// Supabase sends the user back here after authorizing; pick the wizard back up where it
|
|
// was rather than making them start again.
|
|
onMount(() => {
|
|
if (!wizardEnabled) return
|
|
const parked = takeParkedWizard()
|
|
if (parked) {
|
|
wizardResume = parked
|
|
// Handed in, not left to the `resume` prop: the wizard rebuilds the run synchronously
|
|
// inside this call, and a parked run that arrived late would come back as a fresh one.
|
|
wizard?.open(parked)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* The wizard persists what it creates, so the server is authoritative afterwards and the
|
|
* whole baseline comes from it. `tempSettings` derives from that baseline, so this discards
|
|
* uncommitted edits in the table -- which is why the wizard cannot be opened while there
|
|
* are any (see the disabled entry points below).
|
|
*/
|
|
async function reloadAfterWizard() {
|
|
const s = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
|
dataTableSettings = convertDataTableSettingsFromBackend(s.datatable)
|
|
wizardResume = undefined
|
|
}
|
|
|
|
let confirmationModal = createAsyncConfirmationModal()
|
|
let dirtyMap = $derived.by(() => {
|
|
const map: Record<string, boolean> = {}
|
|
for (let i = 0; i < tempSettings.dataTables.length; i++) {
|
|
let temp = tempSettings.dataTables[i]
|
|
let dt = dataTableSettings.dataTables.find((d) => d.id === temp.id)
|
|
map[temp.name] = !deepEqual(dt, temp)
|
|
}
|
|
return map
|
|
})
|
|
|
|
function onDiscard() {
|
|
tempSettings.dataTables = $state.snapshot(dataTableSettings.dataTables)
|
|
}
|
|
|
|
export function discard() {
|
|
onDiscard()
|
|
}
|
|
|
|
export function unsavedChanges(): { savedValue: any; modifiedValue: any } {
|
|
return { savedValue: dataTableSettings, modifiedValue: tempSettings }
|
|
}
|
|
|
|
let hasUnsavedChanges = $derived.by(() => {
|
|
return !deepEqual(dataTableSettings, tempSettings)
|
|
})
|
|
</script>
|
|
|
|
<SettingsPageHeader
|
|
title="Data tables"
|
|
description="Relational storage the whole workspace shares under one name. Scripts, flows and apps address it as <span class='font-mono'>datatable://main</span> instead of picking a PostgreSQL resource, so nobody needs access to the credentials to query it, and you can point that name at another database without touching a line of code. Browse and edit tables, and version schema changes as migrations, from here."
|
|
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
|
|
/>
|
|
|
|
{#if isCloudHosted()}
|
|
<Alert type="info" title="Instance database not available on cloud" class="mb-4" size="xs">
|
|
On Windmill Cloud, data tables cannot use the Windmill instance database. Select
|
|
<span class="font-semibold">PostgreSQL</span> and provide an external PostgreSQL resource (e.g. Supabase
|
|
or Neon) instead.
|
|
</Alert>
|
|
{/if}
|
|
|
|
<MissingWorkerTagAlert tag="postgresql" subject="Browsing and querying data tables" class="mb-4" />
|
|
|
|
<DataTable>
|
|
<Head>
|
|
<tr>
|
|
{#each tableHeadNames as name, i}
|
|
<Cell head first={i == 0} last={i == tableHeadNames.length - 1}>
|
|
{name}
|
|
{#if tableHeadTooltips[name]}
|
|
<Tooltip>
|
|
{@html tableHeadTooltips[name]}
|
|
</Tooltip>
|
|
{/if}
|
|
</Cell>
|
|
{/each}
|
|
</tr>
|
|
</Head>
|
|
<tbody class="divide-y bg-surface-tertiary">
|
|
{#if tempSettings.dataTables.length == 0}
|
|
<Row>
|
|
{#if wizardEnabled}
|
|
<Cell colspan={tableHeadNames.length} class="py-8">
|
|
<div class="flex flex-col items-center gap-3 text-center">
|
|
<Database size={24} class="text-secondary" />
|
|
<div class="flex flex-col gap-1 items-center">
|
|
<span class="font-semibold text-sm">No data table yet</span>
|
|
<p class="text-xs text-secondary max-w-sm">
|
|
Give your scripts a database to store and query data.
|
|
{#if isCloudHosted()}
|
|
Set one up free in about a minute.
|
|
{:else}
|
|
Use the Windmill database, or bring your own.
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="accent"
|
|
disabled={hasUnsavedChanges}
|
|
title={hasUnsavedChanges ? 'Save or discard your changes first' : undefined}
|
|
on:click={() => wizard?.open()}
|
|
>
|
|
Add a data table
|
|
</Button>
|
|
</div>
|
|
</Cell>
|
|
{:else}
|
|
<Cell colspan={tableHeadNames.length} class="text-center py-6">
|
|
No data table in this workspace yet
|
|
</Cell>
|
|
{/if}
|
|
</Row>
|
|
{/if}
|
|
{#each tempSettings.dataTables as dataTable, dataTableIndex (dataTable.id)}
|
|
<Row>
|
|
<Cell first class="w-48 relative">
|
|
{#if dataTable.reference}
|
|
<span class="font-mono text-sm">{dataTable.name}</span>
|
|
{:else}
|
|
<TextInput
|
|
bind:value={dataTable.name}
|
|
inputProps={{ placeholder: 'Name', id: 'name' }}
|
|
/>
|
|
{/if}
|
|
</Cell>
|
|
<Cell>
|
|
{#if dataTable.reference}
|
|
<div class="flex items-center gap-1 text-sm text-secondary">
|
|
<span>Governed by</span>
|
|
<span class="font-mono">{dataTable.reference.workspace_id}</span>
|
|
<span>/</span>
|
|
<span class="font-mono">{dataTable.reference.datatable}</span>
|
|
<Tooltip>
|
|
This fork uses its parent's data table rather than a copy of it, so the database and
|
|
its roles are decided in that workspace.
|
|
</Tooltip>
|
|
</div>
|
|
{:else}
|
|
<div class="flex gap-2">
|
|
<div class="relative">
|
|
{#if dataTable.database.resource_type === 'instance'}
|
|
<Tooltip
|
|
wrapperClass="absolute mt-[0.6rem] right-2 z-20"
|
|
placement="bottom-start"
|
|
>
|
|
Use Windmill's PostgreSQL instance
|
|
</Tooltip>
|
|
{/if}
|
|
<Select
|
|
items={[
|
|
{ value: 'postgresql', label: 'PostgreSQL' },
|
|
{
|
|
value: 'instance',
|
|
label: 'Instance',
|
|
disabled: isCloudHosted(),
|
|
subtitle: $isCustomInstanceDbEnabled
|
|
? undefined
|
|
: isCloudHosted()
|
|
? 'Not available on cloud'
|
|
: 'Superadmin only'
|
|
}
|
|
]}
|
|
bind:value={
|
|
() => dataTable.database.resource_type,
|
|
(resource_type) => {
|
|
dataTable.database = {
|
|
resource_type,
|
|
resource_path:
|
|
resource_type === 'instance' ? defaultInstanceDbName() : undefined
|
|
}
|
|
}
|
|
}
|
|
id="database-type-select"
|
|
class="w-28"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-1 w-80 relative">
|
|
{#if dataTable.database.resource_type !== 'instance'}
|
|
<ResourcePicker
|
|
class="flex-1"
|
|
bind:value={dataTable.database.resource_path}
|
|
resourceType={dataTable.database.resource_type}
|
|
/>
|
|
{:else}
|
|
<CustomInstanceDbSelect
|
|
class="flex-1"
|
|
{confirmationModal}
|
|
{customInstanceDbs}
|
|
bind:value={dataTable.database.resource_path}
|
|
tag="datatable"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</Cell>
|
|
|
|
<Cell class="whitespace-nowrap">
|
|
<div class="flex gap-2">
|
|
<DataTableMigrationsButton
|
|
workspace={$workspaceStore ?? ''}
|
|
datatable={dataTable.name}
|
|
disabled={!!dirtyMap[dataTable.name]}
|
|
/>
|
|
<!-- Data table roles: not mounted yet. The enforcement ships first and this
|
|
drawer is what turns it on, so leaving it reachable would expose a half of the
|
|
feature whose other half (the ACL editor, which grants the privileges a role
|
|
actually needs) does not exist yet.
|
|
|
|
DataTablePermissionsButton.svelte is complete and reviewed — reuse it rather
|
|
than rewriting it, and uncomment this together with the roles section at the
|
|
bottom of this file and the two imports at the top.
|
|
|
|
<DataTablePermissionsButton
|
|
workspace={$workspaceStore ?? ''}
|
|
datatable={dataTable.name}
|
|
disabled={!!dirtyMap[dataTable.name]}
|
|
/>
|
|
-->
|
|
<Button
|
|
size="xs"
|
|
color="light"
|
|
variant="border"
|
|
startIcon={{ icon: PlugZap }}
|
|
iconOnly
|
|
disabled={!!dirtyMap[dataTable.name]}
|
|
loading={connectionCheck?.name === dataTable.name && connectionCheck.loading}
|
|
title="Test connection: check the database is reachable and its user can create tables"
|
|
on:click={() => testConnection(dataTable.name)}
|
|
/>
|
|
{#if dirtyMap[dataTable.name]}
|
|
<Popover
|
|
openOnHover
|
|
contentClasses="p-2 text-sm text-secondary italic"
|
|
class="cursor-not-allowed"
|
|
>
|
|
{#snippet trigger()}
|
|
<ExploreAssetButton
|
|
asset={{ kind: 'datatable', path: dataTable.name }}
|
|
disabled
|
|
/>
|
|
{/snippet}
|
|
{#snippet content()}
|
|
Please save settings first
|
|
{/snippet}
|
|
</Popover>
|
|
{:else}
|
|
<ExploreAssetButton asset={{ kind: 'datatable', path: dataTable.name }} />
|
|
{/if}
|
|
</div>
|
|
</Cell>
|
|
<Cell class="w-12">
|
|
{#if !dataTable.reference}
|
|
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
|
|
{/if}
|
|
</Cell>
|
|
</Row>
|
|
{/each}
|
|
{#if !wizardEnabled || tempSettings.dataTables.length > 0}
|
|
<Row class="!border-0">
|
|
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
|
|
<div class="flex justify-center">
|
|
<Button
|
|
size="sm"
|
|
btnClasses="max-w-fit"
|
|
variant="default"
|
|
disabled={wizardEnabled && hasUnsavedChanges}
|
|
title={wizardEnabled && hasUnsavedChanges
|
|
? 'Save or discard your changes first'
|
|
: undefined}
|
|
on:click={() => (wizardEnabled ? wizard?.open() : onNewDataTable())}
|
|
>
|
|
<Plus />
|
|
{wizardEnabled ? 'Add a data table' : 'New Data Table'}
|
|
</Button>
|
|
</div>
|
|
</Cell>
|
|
</Row>
|
|
{/if}
|
|
</tbody>
|
|
</DataTable>
|
|
|
|
{#if connectionCheck && !connectionCheck.loading}
|
|
{@const report = connectionCheck.report}
|
|
{#if connectionCheck.error}
|
|
<Alert type="error" title="Could not connect to {connectionCheck.name}" class="mt-4" size="xs">
|
|
{connectionCheck.error}
|
|
</Alert>
|
|
{:else if report}
|
|
{@const fullyPrivileged = report.can_create_table && report.can_create_schema}
|
|
<Alert
|
|
type={fullyPrivileged ? 'success' : 'warning'}
|
|
title={fullyPrivileged
|
|
? `${connectionCheck.name} is reachable and its user can create tables and schemas`
|
|
: `${connectionCheck.name} is reachable but its user is missing privileges`}
|
|
class="mt-4"
|
|
size="xs"
|
|
>
|
|
<div class="flex flex-col gap-2">
|
|
<div>
|
|
Connects as <span class="font-mono">{report.user}</span>{#if report.schema}, resolving
|
|
unqualified statements to schema <span class="font-mono">{report.schema}</span>{/if}.
|
|
</div>
|
|
{#if report.suggested_search_path}
|
|
<div>
|
|
Its search_path resolves to no schema, so unqualified statements fail with
|
|
<span class="font-mono">no schema has been selected to create in</span> whatever
|
|
privileges the role holds. Point it at one, e.g.
|
|
<span class="font-mono select-all">{report.suggested_search_path}</span>.
|
|
</div>
|
|
{/if}
|
|
<ul class="list-disc list-inside">
|
|
<li>
|
|
Create tables{report.schema ? ` in ${report.schema}` : ''}:
|
|
<span class="font-semibold">{report.can_create_table ? 'yes' : 'no'}</span>
|
|
</li>
|
|
<li>
|
|
Create schemas:
|
|
<span class="font-semibold">{report.can_create_schema ? 'yes' : 'no'}</span>
|
|
</li>
|
|
<li>
|
|
Migration bookkeeping table exists:
|
|
<span class="font-semibold">{report.migrations_table_exists ? 'yes' : 'no'}</span>
|
|
</li>
|
|
</ul>
|
|
{#if report.suggested_grants.length > 0}
|
|
<div>
|
|
Windmill connects as the role that lacks these privileges, so it cannot grant them
|
|
itself. Run as a schema owner or superuser on that database:
|
|
</div>
|
|
<pre class="whitespace-pre-wrap select-all text-xs"
|
|
>{report.suggested_grants.map((g) => `${g};`).join('\n')}</pre
|
|
>
|
|
{#if report.schema && !report.can_create_table && !report.migrations_table_exists}
|
|
<div>
|
|
Alternatively, create the <span class="font-mono">_wm_migrations</span> bookkeeping table
|
|
yourself and grant only SELECT, INSERT, UPDATE, DELETE on it.
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</Alert>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- The instance role catalog, superadmin-only. Not mounted for the same reason as the
|
|
permissions drawer above: creating roles is only useful once there is a way to grant them
|
|
privileges, which arrives with the ACL editor.
|
|
|
|
DataTableRolesSection.svelte is complete and reviewed — reuse it rather than rewriting it,
|
|
and uncomment this together with the permissions button above and the two imports at the top.
|
|
|
|
{#if $superadmin && !isCloudHosted()}
|
|
<div class="mt-8">
|
|
<DataTableRolesSection />
|
|
</div>
|
|
{/if}
|
|
-->
|
|
|
|
<SettingsFooter
|
|
class="mt-8"
|
|
{hasUnsavedChanges}
|
|
{onSave}
|
|
{onDiscard}
|
|
saveLabel="Save data table settings"
|
|
/>
|
|
|
|
<ConfirmationModal {...confirmationModal.props} />
|
|
|
|
{#if wizardEnabled}
|
|
<AddDataTableWizard
|
|
bind:this={wizard}
|
|
bind:opened={
|
|
() => wizardOpen,
|
|
(v) => {
|
|
wizardOpen = v
|
|
// Drop the parked run once the wizard closes: leaving it set would force the next
|
|
// open straight back to the Supabase setup step.
|
|
if (!v) wizardResume = undefined
|
|
}
|
|
}
|
|
existingNames={tempSettings.dataTables.map((d) => d.name)}
|
|
existingDataTables={tempSettings.dataTables.map((d) => ({
|
|
name: d.name,
|
|
resourcePath: d.database.resource_path
|
|
}))}
|
|
resume={wizardResume}
|
|
onDone={reloadAfterWizard}
|
|
{customInstanceDbs}
|
|
{confirmationModal}
|
|
{defaultInstanceDbName}
|
|
/>
|
|
{/if}
|