mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat(datatables): configure the external instance cluster and pick its databases from the UI
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b62d8b246a
commit
aebae79397
@@ -27,6 +27,7 @@
|
||||
import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte'
|
||||
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
|
||||
import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte'
|
||||
import ExternalInstancePgSettings from './instanceSettings/ExternalInstancePgSettings.svelte'
|
||||
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
|
||||
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
|
||||
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
|
||||
@@ -781,10 +782,10 @@
|
||||
/>
|
||||
<p class="text-xs text-tertiary">
|
||||
Comma-separated host/IP patterns the proxy still traces but for which it skips
|
||||
upstream TLS certificate verification. Use for internal endpoints with
|
||||
self-signed or otherwise untrusted certificates — unlike NO_PROXY above, these
|
||||
requests stay traced. Same matching as NO_PROXY (<code>example.com</code> matches
|
||||
subdomains; <code>.example.com</code> matches subdomains only).
|
||||
upstream TLS certificate verification. Use for internal endpoints with self-signed
|
||||
or otherwise untrusted certificates — unlike NO_PROXY above, these requests stay
|
||||
traced. Same matching as NO_PROXY (<code>example.com</code> matches subdomains;
|
||||
<code>.example.com</code> matches subdomains only).
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -872,6 +873,8 @@
|
||||
<WsConnectivityTest {values} />
|
||||
{:else if setting.fieldType == 'instance_banner'}
|
||||
<InstanceBannerSetting {values} disabled={loading} />
|
||||
{:else if setting.fieldType == 'external_instance_pg'}
|
||||
<ExternalInstancePgSettings {values} disabled={loading} />
|
||||
{/if}
|
||||
{#if hasError}
|
||||
<span class="text-red-600 dark:text-red-400 text-xs">
|
||||
|
||||
@@ -730,6 +730,7 @@
|
||||
secret_backend: ['token'],
|
||||
object_store_cache_config: ['secret_key', 'serviceAccountKey'],
|
||||
custom_instance_pg_databases: ['user_pwd'],
|
||||
external_instance_pg: ['password'],
|
||||
rsa_keys: ['private_key'],
|
||||
github_enterprise_app: ['private_key']
|
||||
}
|
||||
@@ -1234,6 +1235,11 @@
|
||||
description="Configure a self-managed GitHub App for git sync on GitHub.com, GHE Cloud or GitHub Enterprise Server."
|
||||
link="https://www.windmill.dev/docs/integrations/git_repository#self-managed-github-app"
|
||||
/>
|
||||
{:else if category == 'External Postgres'}
|
||||
<SettingsPageHeader
|
||||
title="External Postgres"
|
||||
description="Store data tables and Ducklake catalogs on a PostgreSQL cluster outside Windmill's own database. Save the connection, set the cluster up, then create databases for workspaces to use."
|
||||
/>
|
||||
{:else if category == 'DB Health'}
|
||||
<SettingsPageHeader
|
||||
title="DB Health"
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface Setting {
|
||||
| 'ws_connectivity'
|
||||
| 'retention_overrides'
|
||||
| 'instance_banner'
|
||||
| 'external_instance_pg'
|
||||
storage: SettingStorage
|
||||
advancedToggle?: {
|
||||
label: string
|
||||
@@ -718,6 +719,17 @@ export const settings: Record<string, Setting[]> = {
|
||||
}
|
||||
],
|
||||
'DB Health': [],
|
||||
'External Postgres': [
|
||||
{
|
||||
label: 'External instance cluster',
|
||||
description:
|
||||
'A PostgreSQL cluster Windmill administers for data tables and Ducklake catalogs of the External instance type. The admin user needs CREATEDB and CREATEROLE, plus REPLICATION for Postgres triggers. Windmill creates its own roles and databases there and leaves everything else on the cluster alone.',
|
||||
key: 'external_instance_pg',
|
||||
fieldType: 'external_instance_pg',
|
||||
storage: 'setting',
|
||||
ee_only: 'External instance databases are an Enterprise Edition feature'
|
||||
}
|
||||
],
|
||||
Registries: [
|
||||
{
|
||||
label: 'Instance Python Version',
|
||||
@@ -1241,6 +1253,14 @@ export const instanceSettingsNavigationGroups = [
|
||||
aiId: 'instance-settings-object-storage',
|
||||
aiDescription: 'Instance object storage settings',
|
||||
isEE: true
|
||||
},
|
||||
{
|
||||
id: 'external_postgres',
|
||||
label: 'External Postgres',
|
||||
aiId: 'instance-settings-external-postgres',
|
||||
aiDescription:
|
||||
'External PostgreSQL cluster Windmill manages for external instance data tables and Ducklake catalogs',
|
||||
isEE: true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1365,6 +1385,7 @@ export const tabToCategoryMap: Record<string, string> = {
|
||||
github_enterprise_app: 'GitHub App',
|
||||
websocket: 'WebSocket',
|
||||
db_health: 'DB Health',
|
||||
external_postgres: 'External Postgres',
|
||||
lsp: 'LSP'
|
||||
}
|
||||
|
||||
@@ -1401,6 +1422,7 @@ export const categoryToTabMap: Record<string, string> = {
|
||||
'GitHub App': 'github_enterprise_app',
|
||||
WebSocket: 'websocket',
|
||||
'DB Health': 'db_health',
|
||||
'External Postgres': 'external_postgres',
|
||||
LSP: 'lsp'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Password from '../Password.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import DataTable from '../table/DataTable.svelte'
|
||||
import Head from '../table/Head.svelte'
|
||||
import Row from '../table/Row.svelte'
|
||||
import Cell from '../table/Cell.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import {
|
||||
SettingService,
|
||||
type CustomInstanceDbTag,
|
||||
type ExternalInstancePgSetupReport
|
||||
} from '$lib/gen'
|
||||
import { instanceSettingsSaved } from '../instanceSettings'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { resource } from 'runed'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Wrench
|
||||
} from 'lucide-svelte'
|
||||
import type { Writable } from 'svelte/store'
|
||||
|
||||
interface Props {
|
||||
values: Writable<Record<string, any>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let { values, disabled = false }: Props = $props()
|
||||
|
||||
const KEY = 'external_instance_pg'
|
||||
const SSLMODES = ['verify-full', 'verify-ca', 'require', 'prefer', 'disable']
|
||||
|
||||
let isDisabled = $derived(disabled || !$enterpriseLicense)
|
||||
|
||||
function field(key: string): any {
|
||||
return $values[KEY]?.[key]
|
||||
}
|
||||
|
||||
// Empty inputs are removed rather than sent: the backend reads every field as optional, and an
|
||||
// empty string would be a real (and invalid) host, port or sslmode.
|
||||
function setField(key: string, value: any) {
|
||||
const next = { ...($values[KEY] ?? {}) }
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = key === 'port' ? Number(value) : value
|
||||
}
|
||||
$values[KEY] = Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
||||
// Setup reads the saved settings, never the form, so it waits until the form is saved.
|
||||
const saved = resource(
|
||||
() => $instanceSettingsSaved,
|
||||
async () => {
|
||||
try {
|
||||
return (await SettingService.getGlobal({ key: KEY })) ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
let unsaved = $derived(!saved.loading && !deepEqual(saved.current ?? undefined, $values[KEY]))
|
||||
|
||||
let refreshKey = $state(0)
|
||||
const status = resource(
|
||||
() => [$instanceSettingsSaved, refreshKey],
|
||||
async () => {
|
||||
try {
|
||||
return await SettingService.getExternalInstancePgStatus()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
const databases = resource(
|
||||
() => [$instanceSettingsSaved, refreshKey],
|
||||
async () => {
|
||||
try {
|
||||
return await SettingService.listExternalInstancePgDatabases()
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let freshReport: ExternalInstancePgSetupReport | undefined = $state(undefined)
|
||||
let report = $derived(freshReport ?? status.current?.last_setup)
|
||||
let runningSetup: 'setup' | 'rotate' | undefined = $state(undefined)
|
||||
|
||||
const confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
async function runSetup(rotate: boolean) {
|
||||
if (rotate) {
|
||||
const ok = await confirmationModal.ask({
|
||||
title: 'Rotate passwords',
|
||||
children:
|
||||
'Windmill generates new passwords for its two roles on the external cluster. Jobs that connect afterwards use the new ones.',
|
||||
confirmationText: 'Rotate'
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
runningSetup = rotate ? 'rotate' : 'setup'
|
||||
try {
|
||||
freshReport = await SettingService.setupExternalInstancePg({
|
||||
requestBody: { rotate_passwords: rotate }
|
||||
})
|
||||
sendUserToast(
|
||||
freshReport.success ? 'External cluster is set up' : 'Setup failed, see the report below',
|
||||
!freshReport.success
|
||||
)
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
runningSetup = undefined
|
||||
refreshKey++
|
||||
}
|
||||
}
|
||||
|
||||
let newDbName = $state('')
|
||||
let newDbTag: CustomInstanceDbTag = $state('datatable')
|
||||
let creating = $state(false)
|
||||
|
||||
async function createDatabase() {
|
||||
creating = true
|
||||
try {
|
||||
await SettingService.createExternalInstancePgDatabase({
|
||||
name: newDbName.trim(),
|
||||
requestBody: { tag: newDbTag }
|
||||
})
|
||||
sendUserToast(`Created database ${newDbName.trim()}`)
|
||||
newDbName = ''
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
creating = false
|
||||
refreshKey++
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(name: string) {
|
||||
const ok = await confirmationModal.ask({
|
||||
title: `Drop database ${name}`,
|
||||
children:
|
||||
'The database and everything in it is deleted from the external cluster. This cannot be undone.',
|
||||
confirmationText: 'Drop database'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await SettingService.dropExternalInstancePgDatabase({ name })
|
||||
sendUserToast(`Dropped database ${name}`)
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
refreshKey++
|
||||
}
|
||||
}
|
||||
|
||||
let databaseEntries = $derived(Object.entries(databases.current ?? {}))
|
||||
let setUp = $derived(!!status.current?.last_setup?.success)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if !$enterpriseLicense}
|
||||
<Alert
|
||||
type="info"
|
||||
title="External instance databases are an Enterprise Edition feature"
|
||||
size="xs"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_host" class="text-xs font-semibold text-emphasis">Host</label>
|
||||
<TextInput
|
||||
inputProps={{ id: 'external_pg_host', placeholder: 'db.example.com', disabled: isDisabled }}
|
||||
bind:value={() => field('host'), (v) => setField('host', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_port" class="text-xs font-semibold text-emphasis">Port</label>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
id: 'external_pg_port',
|
||||
type: 'number',
|
||||
placeholder: '5432',
|
||||
disabled: isDisabled
|
||||
}}
|
||||
bind:value={() => field('port'), (v) => setField('port', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_user" class="text-xs font-semibold text-emphasis">Admin user</label>
|
||||
<TextInput
|
||||
inputProps={{ id: 'external_pg_user', placeholder: 'windmill_admin', disabled: isDisabled }}
|
||||
bind:value={() => field('user'), (v) => setField('user', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_password" class="text-xs font-semibold text-emphasis">Password</label>
|
||||
<Password
|
||||
id="external_pg_password"
|
||||
small
|
||||
disabled={isDisabled}
|
||||
bind:password={() => field('password'), (v) => setField('password', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_dbname" class="text-xs font-semibold text-emphasis">
|
||||
Maintenance database
|
||||
</label>
|
||||
<TextInput
|
||||
inputProps={{ id: 'external_pg_dbname', placeholder: 'postgres', disabled: isDisabled }}
|
||||
bind:value={() => field('dbname'), (v) => setField('dbname', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="external_pg_sslmode" class="text-xs font-semibold text-emphasis">SSL mode</label>
|
||||
<Select
|
||||
id="external_pg_sslmode"
|
||||
items={SSLMODES.map((m) => ({ value: m, label: m }))}
|
||||
placeholder="verify-full (default)"
|
||||
clearable
|
||||
disabled={isDisabled}
|
||||
bind:value={() => field('sslmode'), (v) => setField('sslmode', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col gap-1">
|
||||
<label for="external_pg_root_cert" class="text-xs font-semibold text-emphasis">
|
||||
Root certificate (PEM)
|
||||
</label>
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
inputProps={{
|
||||
id: 'external_pg_root_cert',
|
||||
placeholder: '-----BEGIN CERTIFICATE-----',
|
||||
rows: 3,
|
||||
disabled: isDisabled
|
||||
}}
|
||||
bind:value={() => field('root_certificate_pem'), (v) => setField('root_certificate_pem', v)}
|
||||
/>
|
||||
<span class="text-2xs text-secondary">
|
||||
Leave empty to verify against the system trust store.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Wrench }}
|
||||
disabled={isDisabled || unsaved || !saved.current || !!runningSetup}
|
||||
loading={runningSetup === 'setup'}
|
||||
onclick={() => runSetup(false)}
|
||||
>
|
||||
Set up cluster
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
disabled={isDisabled || unsaved || !saved.current || !setUp || !!runningSetup}
|
||||
loading={runningSetup === 'rotate'}
|
||||
onclick={() => runSetup(true)}
|
||||
>
|
||||
Rotate passwords
|
||||
</Button>
|
||||
{#if unsaved}
|
||||
<span class="text-xs text-secondary">Save the settings before setting the cluster up.</span>
|
||||
{:else if !saved.current}
|
||||
<span class="text-xs text-secondary">Fill in the connection and save to set it up.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if report}
|
||||
<div class="flex flex-col gap-1 rounded-md border p-3 bg-surface-secondary">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-semibold text-emphasis">
|
||||
{report.success ? 'Last setup succeeded' : 'Last setup failed'}
|
||||
</span>
|
||||
<span class="text-secondary">{new Date(report.finished_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-1.5 mt-1">
|
||||
{#each report.steps as step, i (i)}
|
||||
<li class="flex gap-2 text-xs">
|
||||
{#if step.status === 'ok'}
|
||||
<CircleCheck size={14} class="text-green-600 dark:text-green-400 shrink-0 mt-0.5" />
|
||||
{:else if step.status === 'warning'}
|
||||
<TriangleAlert
|
||||
size={14}
|
||||
class="text-yellow-600 dark:text-yellow-400 shrink-0 mt-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<CircleX size={14} class="text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
{/if}
|
||||
<span class="font-mono text-secondary shrink-0">{step.name}</span>
|
||||
<span class="text-primary break-words">{step.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis">Databases</span>
|
||||
<span class="text-xs text-secondary">
|
||||
Databases Windmill created on this cluster. Workspaces use them by picking the
|
||||
<span class="font-semibold">External instance</span> type in their data table or Ducklake settings.
|
||||
</span>
|
||||
</div>
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Used for</Cell>
|
||||
<Cell head>Used by</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface-tertiary">
|
||||
{#if databaseEntries.length === 0}
|
||||
<Row>
|
||||
<Cell colspan={4} class="text-center text-xs text-secondary py-4">No database yet</Cell>
|
||||
</Row>
|
||||
{/if}
|
||||
{#each databaseEntries as [name, db] (name)}
|
||||
<Row>
|
||||
<Cell first class="font-mono text-xs">{name}</Cell>
|
||||
<Cell class="text-xs">{db.tag === 'ducklake' ? 'Ducklake' : 'Data table'}</Cell>
|
||||
<Cell class="text-xs">
|
||||
{(db.used_by_workspaces ?? []).join(', ') || '—'}
|
||||
</Cell>
|
||||
<Cell last class="text-right">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
disabled={isDisabled || (db.used_by_workspaces ?? []).length > 0}
|
||||
title={(db.used_by_workspaces ?? []).length > 0
|
||||
? 'Still used by a workspace'
|
||||
: `Drop ${name}`}
|
||||
onclick={() => dropDatabase(name)}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
class="flex-1"
|
||||
inputProps={{
|
||||
id: 'external_pg_new_db',
|
||||
placeholder: 'New database name',
|
||||
disabled: isDisabled || !setUp
|
||||
}}
|
||||
bind:value={newDbName}
|
||||
/>
|
||||
<Select
|
||||
id="external_pg_new_db_tag"
|
||||
class="w-36"
|
||||
items={[
|
||||
{ value: 'datatable', label: 'Data table' },
|
||||
{ value: 'ducklake', label: 'Ducklake' }
|
||||
]}
|
||||
disabled={isDisabled || !setUp}
|
||||
bind:value={newDbTag}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={isDisabled || !setUp || !newDbName.trim() || creating}
|
||||
loading={creating}
|
||||
onclick={createDatabase}
|
||||
>
|
||||
Create database
|
||||
</Button>
|
||||
</div>
|
||||
{#if !setUp}
|
||||
<span class="text-xs text-secondary">Set the cluster up before creating databases.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
@@ -9,7 +9,7 @@
|
||||
id: string
|
||||
name: string
|
||||
database: {
|
||||
resource_type: 'postgresql' | 'instance'
|
||||
resource_type: 'postgresql' | 'instance' | 'external_instance'
|
||||
resource_path?: string | undefined
|
||||
}
|
||||
/** Set on a fork's entry: it names the workspace whose data table governs this one, and
|
||||
@@ -81,8 +81,10 @@
|
||||
import {
|
||||
isCustomInstanceDbEnabled,
|
||||
getUnusedInstanceDbName,
|
||||
isDataTableWizardEnabled
|
||||
isDataTableWizardEnabled,
|
||||
externalInstanceDbUnavailableReason
|
||||
} from './utils.svelte'
|
||||
import ExternalInstanceDbSelect from './ExternalInstanceDbSelect.svelte'
|
||||
import { random_adj } from '../random_positive_adjetive'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
@@ -420,6 +422,13 @@
|
||||
>
|
||||
Use Windmill's PostgreSQL instance
|
||||
</Tooltip>
|
||||
{:else if dataTable.database.resource_type === 'external_instance'}
|
||||
<Tooltip
|
||||
wrapperClass="absolute mt-[0.6rem] right-2 z-20"
|
||||
placement="bottom-start"
|
||||
>
|
||||
Use a database on the external PostgreSQL cluster set in instance settings
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<Select
|
||||
items={[
|
||||
@@ -433,6 +442,12 @@
|
||||
: isCloudHosted()
|
||||
? 'Not available on cloud'
|
||||
: 'Superadmin only'
|
||||
},
|
||||
{
|
||||
value: 'external_instance',
|
||||
label: 'External instance',
|
||||
disabled: !!$externalInstanceDbUnavailableReason,
|
||||
subtitle: $externalInstanceDbUnavailableReason
|
||||
}
|
||||
]}
|
||||
bind:value={
|
||||
@@ -446,16 +461,22 @@
|
||||
}
|
||||
}
|
||||
id="database-type-select"
|
||||
class="w-28"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 w-80 relative">
|
||||
{#if dataTable.database.resource_type !== 'instance'}
|
||||
{#if dataTable.database.resource_type === 'postgresql'}
|
||||
<ResourcePicker
|
||||
class="flex-1"
|
||||
bind:value={dataTable.database.resource_path}
|
||||
resourceType={dataTable.database.resource_type}
|
||||
/>
|
||||
{:else if dataTable.database.resource_type === 'external_instance'}
|
||||
<ExternalInstanceDbSelect
|
||||
class="flex-1"
|
||||
bind:value={dataTable.database.resource_path}
|
||||
tag="datatable"
|
||||
/>
|
||||
{:else}
|
||||
<CustomInstanceDbSelect
|
||||
class="flex-1"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
ducklakes: {
|
||||
name: string
|
||||
catalog: {
|
||||
resource_type: 'postgresql' | 'mysql' | 'instance'
|
||||
resource_type: 'postgresql' | 'mysql' | 'instance' | 'external_instance'
|
||||
resource_path?: string // Name of the database when resource_type is instance
|
||||
}
|
||||
storage: {
|
||||
@@ -105,7 +105,12 @@
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
|
||||
import {
|
||||
isCustomInstanceDbEnabled,
|
||||
getUnusedInstanceDbName,
|
||||
externalInstanceDbUnavailableReason
|
||||
} from './utils.svelte'
|
||||
import ExternalInstanceDbSelect from './ExternalInstanceDbSelect.svelte'
|
||||
import { resource } from 'runed'
|
||||
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
@@ -283,10 +288,9 @@
|
||||
This workspace is a fork, and these settings are its own copy. Lakes marked
|
||||
<span class="font-semibold">isolated</span> read the parent's tables through defer views and
|
||||
write to a fork-scoped namespace that is cleaned up when the fork is deleted. Lakes marked
|
||||
<span class="font-semibold">shared with parent</span> read and write the parent's physical
|
||||
lake directly — editing their catalog or storage here repoints the shared lake for this
|
||||
fork's jobs. The choice is made per lake when the fork is created and cannot be changed
|
||||
here.
|
||||
<span class="font-semibold">shared with parent</span> read and write the parent's physical lake
|
||||
directly — editing their catalog or storage here repoints the shared lake for this fork's jobs.
|
||||
The choice is made per lake when the fork is created and cannot be changed here.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -359,8 +363,8 @@
|
||||
isolated
|
||||
</span>
|
||||
<Tooltip>
|
||||
Writes go to a fork-scoped namespace; reads of tables not yet materialized in
|
||||
this fork defer to the parent. Deleting the fork cleans the namespace up.
|
||||
Writes go to a fork-scoped namespace; reads of tables not yet materialized in this
|
||||
fork defer to the parent. Deleting the fork cleans the namespace up.
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -373,6 +377,11 @@
|
||||
<Tooltip wrapperClass="absolute mt-[0.6rem] right-2 z-20" placement="bottom-start">
|
||||
Use Windmill's PostgreSQL instance as a catalog
|
||||
</Tooltip>
|
||||
{:else if ducklake.catalog.resource_type === 'external_instance'}
|
||||
<Tooltip wrapperClass="absolute mt-[0.6rem] right-2 z-20" placement="bottom-start">
|
||||
Use a database on the external PostgreSQL cluster set in instance settings as a
|
||||
catalog
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<Select
|
||||
items={[
|
||||
@@ -382,6 +391,12 @@
|
||||
value: 'instance',
|
||||
label: 'Instance',
|
||||
subtitle: $isCustomInstanceDbEnabled ? undefined : 'Superadmin only'
|
||||
},
|
||||
{
|
||||
value: 'external_instance',
|
||||
label: 'External instance',
|
||||
disabled: !!$externalInstanceDbUnavailableReason,
|
||||
subtitle: $externalInstanceDbUnavailableReason
|
||||
}
|
||||
]}
|
||||
bind:value={
|
||||
@@ -394,16 +409,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
class="w-24"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1">
|
||||
{#if ducklake.catalog.resource_type !== 'instance'}
|
||||
{#if ducklake.catalog.resource_type === 'postgresql' || ducklake.catalog.resource_type === 'mysql'}
|
||||
<ResourcePicker
|
||||
class="flex-1 min-w-32"
|
||||
bind:value={ducklake.catalog.resource_path}
|
||||
resourceType={ducklake.catalog.resource_type}
|
||||
/>
|
||||
{:else if ducklake.catalog.resource_type === 'external_instance'}
|
||||
<ExternalInstanceDbSelect
|
||||
class="flex-1 min-w-32"
|
||||
bind:value={ducklake.catalog.resource_path}
|
||||
tag="ducklake"
|
||||
/>
|
||||
{:else}
|
||||
<CustomInstanceDbSelect
|
||||
class="flex-1 min-w-32"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { SettingService, type CustomInstanceDbTag } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { safeSelectItems } from '../select/utils.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isExternalInstanceDbEnabled } from './utils.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
|
||||
type Props = {
|
||||
value: string | undefined
|
||||
tag: CustomInstanceDbTag
|
||||
class?: string
|
||||
}
|
||||
let { value = $bindable(), tag, class: className }: Props = $props()
|
||||
|
||||
let refreshKey = $state(0)
|
||||
const databases = resource(
|
||||
() => refreshKey,
|
||||
async () => {
|
||||
try {
|
||||
return await SettingService.listExternalInstancePgDatabases()
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Every database Windmill created is offered, whatever it was created for: the tag only
|
||||
// sorts the ones made for this kind of storage first.
|
||||
let items = $derived(
|
||||
safeSelectItems(
|
||||
Object.entries(databases.current ?? {})
|
||||
.sort(([, a], [, b]) => Number(b.tag === tag) - Number(a.tag === tag))
|
||||
.map(([name]) => name)
|
||||
)
|
||||
)
|
||||
let exists = $derived(!!value && !!databases.current?.[value])
|
||||
let creating = $state(false)
|
||||
|
||||
async function create() {
|
||||
if (!value) return
|
||||
creating = true
|
||||
try {
|
||||
await SettingService.createExternalInstancePgDatabase({
|
||||
name: value,
|
||||
requestBody: { tag }
|
||||
})
|
||||
sendUserToast(`Created database ${value} on the external cluster`)
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
creating = false
|
||||
refreshKey++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<Select
|
||||
class="flex-1"
|
||||
bind:value
|
||||
onCreateItem={(i) => (value = i)}
|
||||
placeholder="Search or create..."
|
||||
showPlaceholderOnOpen
|
||||
{items}
|
||||
id="external-instance-db-select"
|
||||
disabled={!$isExternalInstanceDbEnabled}
|
||||
/>
|
||||
{#if value && !databases.loading && !exists}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
loading={creating}
|
||||
disabled={!$isExternalInstanceDbEnabled}
|
||||
title="Create this database on the external cluster"
|
||||
onclick={create}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { enterpriseLicense, superadmin } from '$lib/stores'
|
||||
import { getLocalSetting } from '$lib/utils'
|
||||
import { derived } from 'svelte/store'
|
||||
|
||||
@@ -20,6 +20,24 @@ export let isCustomInstanceDbEnabled = derived(
|
||||
([superadmin_]) => superadmin_ && !isCloudHosted()
|
||||
)
|
||||
|
||||
export let isExternalInstanceDbEnabled = derived(
|
||||
[superadmin, enterpriseLicense],
|
||||
([superadmin_, enterpriseLicense_]) => superadmin_ && !!enterpriseLicense_ && !isCloudHosted()
|
||||
)
|
||||
|
||||
/** Why the External instance option cannot be picked, or undefined when it can. */
|
||||
export let externalInstanceDbUnavailableReason = derived(
|
||||
[superadmin, enterpriseLicense],
|
||||
([superadmin_, enterpriseLicense_]) =>
|
||||
isCloudHosted()
|
||||
? 'Not available on cloud'
|
||||
: !enterpriseLicense_
|
||||
? 'Enterprise Edition only'
|
||||
: !superadmin_
|
||||
? 'Superadmin only'
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Postgres caps identifiers at 63 bytes; the backend rejects longer db names.
|
||||
const MAX_INSTANCE_DB_NAME_LEN = 63
|
||||
|
||||
|
||||
Reference in New Issue
Block a user