Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Petric
af4e9e6a22 fix: batch job runner empty job kind 2025-10-07 23:06:26 +00:00
Ruben Fiszel
50a6106436 fix: fix runnable inputs not being retriggered on change in some rare cases 2025-10-07 15:15:49 +00:00
Diego Imbert
6806f2193d Fix Ctrl C in app right panel Copying component (#6766) 2025-10-07 12:35:54 +00:00
Diego Imbert
7d5196170c Remove click-to-insert prop feature (#6765)
* Remove click-to-insert prop feature

* CI
2025-10-07 12:03:30 +00:00
centdix
7b9e2c2d68 internal: fix flake and cli dev usage (#6761)
* fix flake

* fix cli build

* fix deno_ffi
2025-10-07 08:07:47 +00:00
Diego Imbert
258b275f9b fix: better ducklake setup (#6763)
* stash

* Much better ducklake setup UX

* nits

* mistake

* sqlx prepare
2025-10-07 07:00:00 +00:00
36 changed files with 737 additions and 280 deletions

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value->>'ducklake_user_pg_pwd' FROM global_settings WHERE name = 'ducklake_settings';",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "5fdfc9427f455a4c1bc8f6ca41ddfd426bc0c2ac126792c926f3cf1182ded981"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value->'instance_catalog_db_status' FROM global_settings WHERE name = 'ducklake_settings'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "73c1c88bdf26ea0559b83314fed7a67d850e4e4dd60f4424ffb0b6f472acc8d5"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Name"
]
},
"nullable": [
null
]
},
"hash": "97e3a1439202e13e739ad2e3f22b3a21d0c9b0e57d7d35326753e8f6a804d4f8"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.ducklake->'ducklakes' AS ducklake_name\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ducklake_name",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b344ba5a32ec873181390e205e16356f1b79bd994a4bd1a8655dbe17bd1e4a30"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE global_settings SET value = jsonb_set(value, '{instance_catalog_db_status}', (COALESCE(value->'instance_catalog_db_status', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'ducklake_settings'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Json"
]
},
"nullable": []
},
"hash": "fd55112d55995ab08d2c275aa6430cdec1cacebdf2f2b3dd6f678b434643eb50"
}

View File

@@ -1,8 +1,8 @@
#!/bin/bash
# This script outputs all features except private. Usage :
# > cargo build --features $(./all_features_oss.sh)
#!/bin/bash
# Path to the Cargo.toml file
CARGO_TOML_PATH="./Cargo.toml"

View File

@@ -0,0 +1,6 @@
INSERT INTO global_settings (name, value) VALUES (
'ducklake_user_pg_pwd',
(SELECT g2.value->'ducklake_user_pg_pwd' FROM global_settings g2 WHERE g2.name = 'ducklake_settings')
);
DELETE FROM global_settings WHERE name = 'ducklake_settings';

View File

@@ -0,0 +1,9 @@
INSERT INTO global_settings (name, value) VALUES (
'ducklake_settings',
(SELECT json_build_object(
'ducklake_user_pg_pwd', g2.value,
'instance_catalog_db_status', '{}'::json
) FROM global_settings g2 WHERE g2.name = 'ducklake_user_pg_pwd')
);
DELETE FROM global_settings WHERE name = 'ducklake_user_pg_pwd';

View File

@@ -770,34 +770,26 @@ paths:
schema:
type: boolean
/settings/databases_exist:
/settings/get_ducklake_instance_catalog_db_status:
post:
summary: checks that all given databases exist or else return the ones that don't
operationId: databasesExist
summary: Returns the set-up statuses of ducklake instance catalog dbs
operationId: getDucklakeInstanceCatalogDbStatus
tags:
- setting
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: string
responses:
"200":
description: databases that do not exist
description: Statuses of all ducklake instance catalog dbs
content:
application/json:
schema:
type: array
items:
type: string
type: object
additionalProperties:
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatus"
/settings/create_ducklake_database/{name}:
/settings/setup_ducklake_catalog_db/{name}:
post:
summary: Runs CREATE DATABASE on the Windmill Postgres and grants access to the ducklake_user
operationId: createDucklakeDatabase
operationId: setupDucklakeCatalogDb
tags:
- setting
parameters:
@@ -812,7 +804,8 @@ paths:
description: status
content:
application/json:
schema: {}
schema:
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatus"
/settings/global/{key}:
get:
@@ -17333,6 +17326,49 @@ components:
- enabled
- aws_auth_resource_type
LoggedWizardStatus:
type: string
enum:
- OK
- SKIP
- FAIL
DucklakeInstanceCatalogDbStatusLogs:
type: object
properties:
super_admin:
$ref: "#/components/schemas/LoggedWizardStatus"
database_credentials:
$ref: "#/components/schemas/LoggedWizardStatus"
valid_dbname:
$ref: "#/components/schemas/LoggedWizardStatus"
created_database:
$ref: "#/components/schemas/LoggedWizardStatus"
description: Created database status log
db_connect:
$ref: "#/components/schemas/LoggedWizardStatus"
grant_permissions:
$ref: "#/components/schemas/LoggedWizardStatus"
DucklakeInstanceCatalogDbStatus:
type: object
required:
- logs
- success
properties:
logs:
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatusLogs"
success:
type: boolean
description: Whether the operation completed successfully
example: true
error:
type: string
nullable: true
description: Error message if the operation failed
example: "Connection timeout"
NewSqsTrigger:
type: object
properties:

View File

@@ -6,7 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::time::Duration;
use std::{collections::HashMap, time::Duration};
use crate::{
db::{ApiAuthed, DB},
@@ -23,6 +23,7 @@ use axum::{
#[cfg(feature = "enterprise")]
use axum::extract::Query;
use serde_json::json;
#[cfg(feature = "enterprise")]
use crate::utils::require_devops_role;
@@ -68,10 +69,13 @@ pub fn global_service() -> Router {
"/critical_alerts/:id/acknowledge",
post(acknowledge_critical_alert),
)
.route("/databases_exist", post(databases_exist))
.route(
"/create_ducklake_database/:name",
post(create_ducklake_database),
"/get_ducklake_instance_catalog_db_status",
post(get_ducklake_instance_catalog_db_status),
)
.route(
"/setup_ducklake_catalog_db/:name",
post(setup_ducklake_catalog_db),
)
.route(
"/critical_alerts/acknowledge_all",
@@ -568,54 +572,112 @@ pub async fn acknowledge_all_critical_alerts() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
async fn databases_exist(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Json(database_names): Json<Vec<String>>,
) -> JsonResult<Vec<String>> {
let result = sqlx::query_scalar!(
r#"SELECT elem FROM (SELECT unnest($1::TEXT[]) AS elem) AS e
WHERE elem NOT IN (SELECT datname FROM pg_catalog.pg_database);"#,
database_names.as_slice()
)
.fetch_all(&db)
.await?
.into_iter()
.filter_map(|x| x)
.collect();
Ok(Json(result))
#[derive(Deserialize, Debug, Serialize)]
struct DucklakeInstanceCatalogDbStatus {
logs: DucklakeInstanceCatalogDbStatusLogs, // (Step, Message)[]
success: bool,
error: Option<String>,
}
async fn create_ducklake_database(
#[derive(Deserialize, Debug, Serialize, Default)]
#[serde(default)]
struct DucklakeInstanceCatalogDbStatusLogs {
super_admin: String,
#[serde(skip_serializing_if = "String::is_empty")]
database_credentials: String,
#[serde(skip_serializing_if = "String::is_empty")]
valid_dbname: String,
#[serde(skip_serializing_if = "String::is_empty")]
created_database: String,
#[serde(skip_serializing_if = "String::is_empty")]
db_connect: String,
#[serde(skip_serializing_if = "String::is_empty")]
grant_permissions: String,
}
async fn get_ducklake_instance_catalog_db_status(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<HashMap<String, DucklakeInstanceCatalogDbStatus>> {
let result = sqlx::query_scalar!(
r#"SELECT value->'instance_catalog_db_status' FROM global_settings WHERE name = 'ducklake_settings'"#,
)
.fetch_one(&db)
.await?
.ok_or_else(|| error::Error::ExecutionErr("Couldn't find ducklake_settings".to_string()))?;
let result = serde_json::from_value(result).map_err(|e| {
error::Error::ExecutionErr(format!(
"couldn't parse instance_catalog_db_status : {}",
e.to_string()
))
})?;
return Ok(Json(result));
}
async fn setup_ducklake_catalog_db(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(dbname): Path<String>,
) -> JsonResult<DucklakeInstanceCatalogDbStatus> {
let mut logs = DucklakeInstanceCatalogDbStatusLogs::default();
let result = setup_ducklake_catalog_db_inner(authed, &db, &dbname, &mut logs).await;
let success = result.is_ok();
let error = result.err().map(|e| e.to_string());
let status = DucklakeInstanceCatalogDbStatus { logs, success, error };
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
// Save that the database was setup successfully
sqlx::query!(
r#"UPDATE global_settings SET value = jsonb_set(value, '{instance_catalog_db_status}', (COALESCE(value->'instance_catalog_db_status', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'ducklake_settings'"#,
json!({ dbname: status_json })
).execute(&db).await?;
Ok(Json(status))
}
async fn setup_ducklake_catalog_db_inner(
authed: ApiAuthed,
db: &DB,
dbname: &str,
logs: &mut DucklakeInstanceCatalogDbStatusLogs,
) -> Result<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(db, &authed.email).await?;
logs.super_admin = "OK".to_string();
let pg_creds = &get_database_url().await?;
let pg_creds = parse_postgres_url(pg_creds)?;
logs.database_credentials = "OK".to_string();
// Validate name to ensure it only contains alphanumeric characters
// Prevents SQL injection on the instance database
let valid_name = regex::Regex::new(r"^[a-zA-Z0-9_]+$")
.map_err(|_| error::Error::internal_err("Failed to compile regex".to_string()))?;
if !valid_name.is_match(&dbname) {
lazy_static::lazy_static! {
static ref VALID_NAME: regex::Regex = regex::Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
}
if !VALID_NAME.is_match(dbname) {
return Err(error::Error::BadRequest(
"Invalid database name".to_string(),
"Catalog name must be alphanumeric, underscores allowed".to_string(),
));
}
if pg_creds.database.trim().eq_ignore_ascii_case(dbname.trim()) {
return Err(error::Error::BadRequest(
"Database name cannot be the same as the main database".to_string(),
));
}
logs.valid_dbname = "OK".to_string();
sqlx::query(&format!("CREATE DATABASE \"{dbname}\""))
.execute(&db)
.await?;
let db_exists = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
dbname
)
.fetch_one(db)
.await?
.unwrap_or(false);
sqlx::query(&format!(
"GRANT CONNECT ON DATABASE \"{dbname}\" TO ducklake_user"
))
.execute(&db)
.await?;
// We have to connect to the newly created database as admin to grant permissions
let pg_creds = parse_postgres_url(&get_database_url().await?)?;
logs.created_database = "SKIP".to_string();
if !db_exists {
sqlx::query(&format!("CREATE DATABASE \"{dbname}\""))
.execute(db)
.await?;
logs.created_database = "OK".to_string();
}
let ssl_mode = match pg_creds.ssl_mode.as_deref() {
Some("allow") => "prefer".to_string(),
@@ -623,6 +685,7 @@ async fn create_ducklake_database(
Some(s) => s.to_string(),
None => "prefer".to_string(),
};
// We have to connect to the newly created database as admin to grant permissions
let conn_str = format!(
"postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}",
user = urlencoding::encode(&pg_creds.username.unwrap_or_else(|| "postgres".to_string())),
@@ -632,29 +695,41 @@ async fn create_ducklake_database(
dbname = dbname,
sslmode = ssl_mode
);
let (client, connection) = tokio::time::timeout(
std::time::Duration::from_secs(20),
tokio_postgres::connect(&conn_str, tokio_postgres::NoTls),
)
.await
.map_err(to_anyhow)?
.map_err(to_anyhow)?;
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {}", e);
}
});
.map_err(|e| error::Error::ExecutionErr(format!("timeout: {}", e.to_string())))?
.map_err(|e| error::Error::ExecutionErr(format!("error: {}", e.to_string())))?;
let join_handle = tokio::spawn(async move { connection.await });
logs.db_connect = "OK".to_string();
client
.batch_execute(&format!(
"GRANT USAGE ON SCHEMA public TO ducklake_user;
"GRANT CONNECT ON DATABASE \"{dbname}\" TO ducklake_user;
GRANT USAGE ON SCHEMA public TO ducklake_user;
GRANT CREATE ON SCHEMA public TO ducklake_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ducklake_user;"
))
.await
.map_err(to_anyhow)?;
.map_err(|e| {
error::Error::ExecutionErr(format!(
"Failed to grant permissions to ducklake_user: {}",
e.to_string(),
))
})?;
logs.grant_permissions = "OK".to_string();
drop(client); // /!\ Drop before joining to avoid deadlock
join_handle
.await
.map_err(|e| error::Error::ExecutionErr(format!("join error: {}", e.to_string())))?
.map_err(|e| {
error::Error::ExecutionErr(format!("tokio_postgres error: {}", e.to_string()))
})?;
Ok(())
}

View File

@@ -1023,10 +1023,11 @@ async fn edit_ducklake_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
Json(new_config): Json<EditDucklakeConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let is_superadmin = require_super_admin(&db, &email).await.is_ok();
let mut tx = db.begin().await?;
@@ -1065,6 +1066,38 @@ async fn edit_ducklake_config(
}
}
// Check that non-superadmins are not abusing Instance catalogs
if !is_superadmin {
let old_ducklakes = sqlx::query_scalar!(
r#"
SELECT ws.ducklake->'ducklakes' AS ducklake_name
FROM workspace_settings ws
WHERE ws.workspace_id = $1
"#,
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(serde_json::Value::Null);
let old_ducklakes: HashMap<String, Ducklake> =
serde_json::from_value(old_ducklakes).unwrap_or_default();
for (name, dl) in new_config.settings.ducklakes.iter() {
if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance {
let old_dl = old_ducklakes.get(name);
if old_dl.is_none()
|| old_dl.unwrap().catalog.resource_type
!= DucklakeCatalogResourceType::Instance
|| old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path
{
return Err(Error::BadRequest(
"Only superadmins can create or modify ducklakes with Instance catalogs"
.to_string(),
));
}
}
}
}
let config: serde_json::Value = serde_json::to_value(new_config.settings)
.map_err(|err| Error::internal_err(err.to_string()))?;

View File

@@ -212,7 +212,7 @@ pub async fn get_ducklake_from_db_unchecked(
pub async fn get_ducklake_instance_pg_catalog_password(db: &DB) -> Result<String> {
sqlx::query_scalar!(
"SELECT trim(both '\"' from value::text) FROM global_settings WHERE name = 'ducklake_user_pg_pwd';"
"SELECT value->>'ducklake_user_pg_pwd' FROM global_settings WHERE name = 'ducklake_settings';"
)
.fetch_optional(db)
.await?
@@ -269,11 +269,8 @@ async fn transform_json_unchecked(
.map_err(to_anyhow)?;
let mc = build_crypt(&db, &w_id).await?;
let variable = decrypt(&mc, variable).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
&s, e
))
})?;
Error::internal_err(format!("Error decrypting variable {}: {}", &s, e))
})?;
serde_json::Value::String(variable)
}
s @ serde_json::Value::String(_) => s.clone(),

View File

@@ -877,9 +877,6 @@ pub async fn run_agent(
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
);
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
// Clone everything needed for the spawned task
let tool_job_spawn = tool_job.clone();

View File

@@ -16,8 +16,5 @@ set -e
echo "Running dnt..."
deno run -A dnt.ts
# Remove .ts extensions after building to go back to the original state
./windmill-utils-internal/remove-ts-ext.sh
echo "Build complete!"

View File

@@ -1 +1 @@
export * from "./config";
export * from "./config.ts";

View File

@@ -8,8 +8,8 @@
* - Cross-platform path constants
*/
export * from "./inline-scripts";
export * from "./path-utils";
export * from "./parse";
export * from "./config";
export { SEP, DELIMITER } from "./constants";
export * from "./inline-scripts.ts";
export * from "./path-utils.ts";
export * from "./parse.ts";
export * from "./config.ts";
export { SEP, DELIMITER } from "./constants.ts";

View File

@@ -1,5 +1,5 @@
import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner";
import { FlowModule } from "../gen/types.gen";
import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner.ts";
import { FlowModule } from "../gen/types.gen.ts";
/**
* Represents an inline script extracted from a flow module

View File

@@ -1,2 +1,2 @@
export * from "./replacer";
export * from "./extractor";
export * from "./replacer.ts";
export * from "./extractor.ts";

View File

@@ -1,4 +1,4 @@
import { FlowModule } from "../gen/types.gen";
import { FlowModule } from "../gen/types.gen.ts";
/**
* Replaces inline script references with actual file content from the filesystem.

View File

@@ -1 +1 @@
export * from "./parse-schema";
export * from "./parse-schema.ts";

View File

@@ -1 +1 @@
export * from "./path-assigner";
export * from "./path-assigner.ts";

View File

@@ -1,4 +1,4 @@
import { RawScript } from "../gen/types.gen";
import { RawScript } from "../gen/types.gen.ts";
const INLINE_SCRIPT_PREFIX = "inline_script";

View File

@@ -50,11 +50,11 @@
xmlsec.dev
libxslt.dev
libclang.dev
libffi # For deno_ffi
libtool
nodejs
postgresql
pkg-config
glibc.dev
clang
cmake
];
@@ -298,9 +298,10 @@
# included we need to look in a few places.
# See https://web.archive.org/web/20220523141208/https://hoverbear.org/blog/rust-bindgen-in-nix/
BINDGEN_EXTRA_CLANG_ARGS =
"${builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"} ${
# Prevent clang from using system headers - only use Nix headers
"-nostdinc ${builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"} ${
builtins.readFile "${stdenv.cc}/nix-support/libc-cflags"
}${builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"}${
} ${builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"} ${
builtins.readFile "${stdenv.cc}/nix-support/libcxx-cxxflags"
} -idirafter ${pkgs.libiconv}/include ${
lib.optionalString stdenv.cc.isClang
@@ -313,9 +314,10 @@
lib.getVersion stdenv.cc.cc
} -isystem ${stdenv.cc.cc}/include/c++/${
lib.getVersion stdenv.cc.cc
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/14.2.1/include"
}"; # NOTE: It is hardcoded to 14.2.1 -------------------------------------------------------------^^^^^^
# Please update the version here as well if you want to update flake.
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/${
lib.getVersion stdenv.cc.cc
}/include"
}";
};
packages.default = self.packages.${system}.windmill;
packages.windmill-client = pkgs.buildNpmPackage {

View File

@@ -90,7 +90,6 @@
let monaco: SimpleEditor | undefined = $state(undefined)
let monacoTemplate: TemplateEditor | undefined = $state(undefined)
let argInput: ArgInput | undefined = $state(undefined)
let focusedPrev = false
let hidden = $state(false)
@@ -355,32 +354,6 @@
function onFocus() {
focused = true
if (isStaticTemplate(inputCat)) {
focusProp?.(argName, 'append', (path) => {
// Empty field + variable = use $var:/$res: syntax instead of ${...}
const isEmpty = !arg.value || arg.value.trim() === ''
if (isEmpty && variableMatch(path)) {
connectProperty(path)
return true
} else {
const toAppend = `\$\{${path}}`
arg.value = `${arg.value ?? ''}${toAppend}`
monacoTemplate?.setCode(arg.value)
setPropertyType(arg.value)
argInput?.focus()
return false
}
})
} else {
focusProp?.(argName, 'insert', (path) => {
arg.expr = path
arg.type = 'javascript'
propertyType = 'javascript'
monaco?.setCode(arg.expr)
return true
})
}
}
let prevArg: any = undefined
@@ -711,7 +684,6 @@
{resourceTypes}
noMargin
compact
bind:this={argInput}
on:focus={onFocus}
on:blur={() => {
focused = false
@@ -777,19 +749,11 @@
renderLineHighlight="none"
hideLineNumbers
fakeMonacoPlaceholderClass="mt-2"
on:focus={() => {
focused = true
focusProp?.(argName, 'insert', (path) => {
monaco?.insertAtCursor(path)
return false
})
}}
on:focus={() => (focused = true)}
on:blur={() => (focused = false)}
on:change={() => {
dispatch('change', { argName, arg })
}}
on:blur={() => {
focused = false
}}
autoHeight
loadAsync
/>

View File

@@ -51,7 +51,6 @@
}: Props = $props()
if (initialValue && value == undefined) {
console.log('initialValue', initialValue)
value = initialValue
}
@@ -76,15 +75,12 @@
$effect(() => {
if (value === undefined) {
if (initialValue) {
console.log('initialValue', initialValue)
if (initialValue != value) {
value = initialValue
}
} else {
console.log('no value')
}
} else {
console.log('value', value)
}
})
@@ -125,7 +121,6 @@
}
collection = nc
if (collection.length == 1 && selectFirst && (value == undefined || value == '')) {
console.log('selectFirst', collection[0].value)
value = collection[0].value
valueType = collection[0].type
}

View File

@@ -491,6 +491,7 @@
if (ctxMatch) {
nonStaticRunnableInputs[k] = '$ctx:' + ctxMatch[1]
} else {
// console.log('k', k)
nonStaticRunnableInputs[k] = await inputValues[k]?.computeExpr()
}
if (isEditor && field?.type == 'evalv2' && field.allowUserResources) {
@@ -506,6 +507,7 @@
const oneOfRunnableInputs = isEditor ? collectOneOfFields(fields, $app) : {}
// console.log(JSON.stringify({ id, nonStaticRunnableInputs, inputValues }))
const requestBody: ExecuteComponentData['requestBody'] = {
args: nonStaticRunnableInputs,
component: id,
@@ -749,7 +751,7 @@
let lastJobId: string | undefined = $state(undefined)
let inputValues: Record<string, InputValue> = $state({})
let inputValues: Record<string, InputValue> = {}
function updateBgRuns(loading: boolean) {
if (loading) {

View File

@@ -91,6 +91,7 @@
} else {
outputs?.result.set(undefined)
}
console.log('outputs?.result', outputs?.result.peak())
untrack(() => fireOnChange())
})
</script>

View File

@@ -1158,7 +1158,12 @@
<div class="relative flex flex-col h-full"></div>
{:else}
<Pane bind:size={rightPanelSize} minSize={15} maxSize={33}>
<div bind:clientWidth={$runnableJob.width} class="relative flex flex-col h-full">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:clientWidth={$runnableJob.width}
class="relative flex flex-col h-full"
onkeydown={(e) => e.stopPropagation()}
>
<Tabs bind:selected={selectedTab} wrapperClass="!min-h-[42px]" class="!h-full">
<Popover disappearTimeout={0} notClickable placement="bottom">
{#snippet text()}

View File

@@ -191,8 +191,8 @@
{#if job?.args}
<div class="p-2">
<JobArgs
id={job.id}
workspace={job.workspace_id ?? $workspaceStore ?? 'no_w'}
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={job?.args}
/>
</div>
@@ -216,12 +216,12 @@
/>
</Pane>
<Pane size={50} minSize={10} class="text-sm text-secondary">
{#if job != undefined && 'result' in job && job.result != undefined}<div
{#if job != undefined && 'result' in job && job?.result != undefined}<div
class="relative h-full px-2"
><DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={job.result}
result={job?.result}
/></div
>
{:else if testIsLoading}
@@ -237,7 +237,7 @@
{#if jobResult?.transformer}
<Pane size={50} minSize={10} class="text-sm text-secondary p-2">
<div class="font-bold">Transformer results</div>
{#if job != undefined && 'result' in job && job.result != undefined}
{#if job != undefined && 'result' in job && job?.result != undefined}
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}

View File

@@ -56,6 +56,29 @@
{#if componentInput.fieldType !== 'any'}
<div class="w-full">
<div class="flex gap-2 justify-end" bind:clientWidth>
<div class="flex">
<ConnectionButton
closeConnection={() => {
$connectingInput = {
opened: false,
hoveredComponent: undefined,
input: undefined,
onConnect: () => {}
}
dispatch('select', true)
}}
openConnection={() => {
$connectingInput = {
opened: true,
input: undefined,
hoveredComponent: undefined,
onConnect: applyConnection
}
}}
isOpen={!!$connectingInput.opened}
/>
</div>
<ToggleButtonGroup
on:selected={() => {
onchange?.()
@@ -121,29 +144,6 @@
/>
{/snippet}
</ToggleButtonGroup>
<div class="flex">
<ConnectionButton
closeConnection={() => {
$connectingInput = {
opened: false,
hoveredComponent: undefined,
input: undefined,
onConnect: () => {}
}
dispatch('select', true)
}}
openConnection={() => {
$connectingInput = {
opened: true,
input: undefined,
hoveredComponent: undefined,
onConnect: applyConnection
}
}}
isOpen={!!$connectingInput.opened}
/>
</div>
</div>
</div>
{/if}

View File

@@ -189,6 +189,7 @@
<div class={classNames('flex gap-x-2 gap-y-1 justify-end items-center')}>
{#if componentInput?.type && allowTypeChange !== false}
<ConnectionButton
small
{closeConnection}
{openConnection}
isOpen={!!$connectingInput.opened}

View File

@@ -7,13 +7,25 @@
import type { AppViewerContext } from '$lib/components/apps/types'
import { twMerge } from 'tailwind-merge'
export let isOpen = false
export let openConnection: () => void
export let closeConnection: () => void
export let btnWrapperClasses = ''
export let id: string | undefined = undefined
interface Props {
isOpen?: boolean
openConnection: () => void
closeConnection: () => void
btnWrapperClasses?: string
id?: string | undefined
small?: boolean
}
let selected = false
let {
isOpen = false,
openConnection,
closeConnection,
btnWrapperClasses = '',
id = undefined,
small
}: Props = $props()
let selected = $state(false)
const { panzoomActive } = getContext<AppViewerContext>('AppViewerContext')
@@ -51,25 +63,27 @@
}
}
function handlePointerDownOutside(e: CustomEvent) {
function handlePointerDownOutside() {
if (!$panzoomActive) {
deactivateConnection()
}
}
$: !isOpen && (selected = false)
$effect(() => {
!isOpen && (selected = false)
})
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
use:pointerDownOutside={{
capture: true,
stopPropagation: isOpen,
exclude: getConnectionButtonElements,
customEventName: 'pointerdown_connecting'
customEventName: 'pointerdown_connecting',
onClickOutside: () => handlePointerDownOutside()
}}
on:keydown={handleKeyDown}
on:pointerdown_outside={handlePointerDownOutside}
onkeydown={handleKeyDown}
data-connection-button
>
<AnimatedButton
@@ -79,14 +93,14 @@
marginWidth="2px"
>
<Button
size="xs"
size={small ? 'xs' : 'md'}
variant="border"
color="light"
title="Connect"
on:click={() => handleConnect(true)}
{id}
wrapperClasses={twMerge(btnWrapperClasses, selected ? 'opacity-100' : '')}
btnClasses="p-0"
btnClasses={small ? 'p-0' : ''}
>
<Plug size={14} />
</Button>

View File

@@ -32,7 +32,7 @@
jobKindsCat?: string | undefined
minTs?: string | undefined
maxTs?: string | undefined
jobKinds?: string
jobKinds?: string | undefined
queue_count?: Tweened<number> | undefined
suspended_count?: Tweened<number> | undefined
autoRefresh?: boolean
@@ -98,12 +98,12 @@
loadJobsIntern(true)
}
function computeJobKinds(jobKindsCat: string | undefined): string {
function computeJobKinds(jobKindsCat: string | undefined): string | undefined {
if (jobKindsCat == undefined && jobKinds != undefined) {
return jobKinds
}
if (jobKindsCat == 'all') {
return ''
return undefined
} else if (jobKindsCat == 'dependencies') {
let kinds: CompletedJob['job_kind'][] = [
'dependencies',

View File

@@ -0,0 +1,108 @@
<script module lang="ts">
export function firstEmptyStepIsError<Step extends { status?: LoggedWizardStatus }>(
steps: Step[],
error: string | undefined
): (Step & { status: LoggedWizardStatus })[] {
let convertedSteps = [...steps]
let alreadyFoundEmpty = false
for (let step of convertedSteps) {
if (!step.status) {
if (!alreadyFoundEmpty) {
alreadyFoundEmpty = true
step.status = error !== undefined ? 'FAIL' : 'SKIP'
} else {
step.status = 'SKIP'
}
}
}
return convertedSteps as any
}
</script>
<script lang="ts">
import type { LoggedWizardStatus } from '$lib/gen'
import { CircleCheck, Circle, CircleX, ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
type Props = {
steps: { title?: string; status: LoggedWizardStatus; description?: string }[]
class?: string
}
let { steps, class: className = '' }: Props = $props()
let openedDescriptions: Record<number, true> = $state({})
$effect(() => {
for (let i = 0; i < steps.length; i++) {
let step = steps[i]
if (step.status == 'FAIL') {
openedDescriptions[i] = true
}
}
})
</script>
<div class={twMerge('flex flex-col gap-2', className)}>
{#each steps as step, i}
{@const descriptionOpened = openedDescriptions[i] ?? false}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col bg-surface rounded-md py-1.5 px-3 cursor-pointer"
role=""
onclick={() => {
if (step.description) {
if (descriptionOpened) delete openedDescriptions[i]
else openedDescriptions[i] = true
}
}}
>
<div class="flex gap-3">
<span class="inline-flex w-10 h-10 shrink-0 justify-center items-center">
{#if step.status == 'SKIP'}
<Circle size={20} class="inline text-hint/50" />
{:else if step.status == 'FAIL'}
<CircleX size={20} class="inline text-red-500" />
{:else if step.status == 'OK'}
<CircleCheck size={20} class="inline text-green-500" />
{/if}
</span>
<div class="flex-1 my-2">
<span
class={twMerge(
'font-medium flex justify-between items-center',
{
SKIP: 'text-hint/75',
FAIL: 'text-red-400',
OK: 'text-green-600 dark:text-green-400'
}[step.status]
)}
>
{i + 1}. {step.title}
{#if step.description}
<ChevronDown
class={twMerge(
'text-hint transition-transform',
descriptionOpened ? 'rotate-180' : ''
)}
size={16}
/>
{/if}
</span>
<ResizeTransitionWrapper vertical class="relative text-xs text-secondary">
{#if descriptionOpened}
<div
class="whitespace-pre-wrap cursor-default mt-1.5"
onclick={(e) => e.stopPropagation()}
>
{step.description}
</div>
{/if}
</ResizeTransitionWrapper>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -50,7 +50,7 @@
</script>
<script>
import { Plus } from 'lucide-svelte'
import { ArrowRight, TriangleAlert, Plus } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
@@ -62,7 +62,7 @@
import Select from '../select/Select.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { SettingService, WorkspaceService } from '$lib/gen'
import { SettingService, WorkspaceService, type DucklakeInstanceCatalogDbStatus } from '$lib/gen'
import { type GetSettingsResponse } from '$lib/gen'
import { superadmin, workspaceStore } from '$lib/stores'
@@ -73,12 +73,14 @@
import { isCloudHosted } from '$lib/cloud'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import { clone, pluralize } from '$lib/utils'
import { clone } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import { deepEqual } from 'fast-equals'
import Popover from '../meltComponents/Popover.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Section from '../Section.svelte'
import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import { slide } from 'svelte/transition'
const DEFAULT_DUCKLAKE_CATALOG_NAME = 'ducklake_catalog'
@@ -88,7 +90,7 @@
}
let { ducklakeSettings = $bindable(), ducklakeSavedSettings = $bindable() }: Props = $props()
let isWmDbEnabled = $derived($superadmin && !isCloudHosted())
let isInstanceCatalogEnabled = $derived($superadmin && !isCloudHosted())
function onNewDucklake() {
const name = ducklakeSettings.ducklakes.some((d) => d.name === 'main')
@@ -97,8 +99,8 @@
ducklakeSettings.ducklakes.push({
name,
catalog: {
resource_type: isWmDbEnabled ? 'instance' : 'postgresql',
resource_path: isWmDbEnabled ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
resource_type: isInstanceCatalogEnabled ? 'instance' : 'postgresql',
resource_path: isInstanceCatalogEnabled ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
},
storage: {
storage: undefined,
@@ -111,12 +113,6 @@
ducklakeSettings.ducklakes.splice(index, 1)
}
const windmillDbNames = $derived(
ducklakeSettings.ducklakes
.filter((d) => d.catalog.resource_type === 'instance')
.map((d) => d.catalog.resource_path ?? '')
)
const ducklakeIsDirty: Record<string, boolean> = $derived(
Object.fromEntries(
ducklakeSettings.ducklakes.map((d) => {
@@ -126,25 +122,27 @@
)
)
let instanceCatalogSetupIsRunning = $state(false)
const instanceCatalogStatuses = usePromise(SettingService.getDucklakeInstanceCatalogDbStatus, {
clearValueOnRefresh: false
})
async function onSave() {
try {
if (windmillDbNames.length) {
// Ensure that all instance dbs exist
const nonExistentDbs = await SettingService.databasesExist({ requestBody: windmillDbNames })
if (nonExistentDbs.length) {
let confirmed = await confirmationModal.ask({
title: "The following databases do not exist in Windmill's Postgres instance",
confirmationText: `Create ${pluralize(nonExistentDbs.length, 'database')}`,
children: `<span>
Confirm running the following in the instance's Postgres :<br />
${nonExistentDbs.map((db) => `<pre class='border mt-1 p-2 rounded-md'>CREATE DATABASE "${db}";</pre>`).join('\n')}
</span>`
})
if (!confirmed) return
await Promise.all(
nonExistentDbs.map((name) => SettingService.createDucklakeDatabase({ name }))
)
}
if (
isInstanceCatalogEnabled &&
ducklakeSettings.ducklakes.some(
(d) =>
d.catalog.resource_type === 'instance' &&
!instanceCatalogStatuses.value?.[d.catalog.resource_path ?? '']?.success
)
) {
let confirm = await confirmationModal.ask({
title: 'Some instance catalogs are not setup',
children: 'Are you sure you want to save without setting them up ?',
confirmationText: 'Save anyway'
})
if (!confirm) return
}
const settings = convertDucklakeSettingsToBackend(ducklakeSettings)
await WorkspaceService.editDucklakeConfig({
@@ -192,56 +190,12 @@
</div>
{#if ducklakeSettings.ducklakes.some((d) => d.catalog.resource_type === 'instance')}
<Alert title="Instance catalogs use the Windmill database" class="mb-4" type="info">
Using an instance catalog is the fastest way to get started with Ducklake. They are public to
the instance and can be re-used in other workspaces' Ducklake settings.
<div>
<Section
label="Manual setup instructions"
collapsable
headerClass="mt-6 border bg-surface px-3 py-1 rounded-md text-xs text-secondary"
class="text-secondary"
animate
>
This is what happens when you create a new Instance catalog with the name
<code>ducklake_catalog</code>. This may be useful to debug issues in case the automatic
setup fails in the middle, but in most cases Windmill will handle it for you.
<br /><br />
If the database <code>ducklake_catalog</code> already exists, Windmill assumes that the
setup was successful and does not do anything. However, it is possible that it failed in the
middle (in which case you should have seen an error pop up during setup). There is no
rollback as the following operations do not work in a transaction.
<br />
This is what the setup does :
<br /><br />
Connect to the Windmill PostgreSQL as the default user (the one in your DATABASE_URL, usually
'postgres') and run :
<br />
<code class="block p-2 border rounded-md bg-surface mt-2">
CREATE DATABASE <code>ducklake_catalog</code>;<br />
GRANT CONNECT ON DATABASE <code>ducklake_catalog</code> TO ducklake_user;
</code>
<br />
Then, connect to the <code>ducklake_catalog</code> database with the same user as above (NOT
ducklake_user) and run :
<code class="block p-2 border rounded-md bg-surface mt-2">
GRANT USAGE ON SCHEMA public TO ducklake_user;<br />
GRANT CREATE ON SCHEMA public TO ducklake_user;<br />
ALTER DEFAULT PRIVILEGES IN SCHEMA public<br />
&nbsp;&nbsp;GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ducklake_user;
</code>
<br />
After doing that, creating a new Ducklake with an Instance catalog named
<code>ducklake_catalog</code> should not prompt you to run the automatic setup, and
everything should work fine.
<br /><br />
Note : the ducklake_user is automatically created by Windmill in a migration. Its password is
auto-generated and stored in the database table <code>global_settings</code> with the key
<code>ducklake_user_pg_pwd</code>.
</Section>
</div>
</Alert>
<div transition:slide={{ duration: 200 }} class="mb-4">
<Alert title="Instance catalogs use the Windmill database" type="info">
Using an instance catalog is the fastest way to get started with Ducklake. They are public to
the instance and can be re-used in other workspaces' Ducklake settings.
</Alert>
</div>
{/if}
<DataTable>
@@ -271,7 +225,7 @@
<Row>
<Cell first class="w-48 relative">
{#if ducklake.name === 'main'}
<Tooltip wrapperClass="absolute mt-2.5 right-4" placement="bottom-start">
<Tooltip wrapperClass="absolute mt-3 right-4" placement="bottom-start">
The <i>main</i> ducklake can be accessed with the
<br />
<code class="px-1 py-0.5 border rounded-md">ATTACH 'ducklake' AS dl;</code> shorthand
@@ -280,10 +234,10 @@
<TextInput bind:value={ducklake.name} inputProps={{ placeholder: 'Name' }} />
</Cell>
<Cell>
<div class="flex gap-4">
<div class="flex gap-2">
<div class="relative">
{#if ducklake.catalog.resource_type === 'instance'}
<Tooltip wrapperClass="absolute mt-2.5 right-2 z-20" placement="bottom-start">
<Tooltip wrapperClass="absolute mt-3 right-2 z-20" placement="bottom-start">
Use Windmill's PostgreSQL instance as a catalog
</Tooltip>
{/if}
@@ -291,7 +245,11 @@
items={[
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'mysql', label: 'MySQL' },
...(isWmDbEnabled ? [{ value: 'instance', label: 'Instance' }] : [])
{
value: 'instance',
label: 'Instance',
subtitle: isInstanceCatalogEnabled ? undefined : 'Superadmin only'
}
]}
bind:value={
() => ducklake.catalog.resource_type,
@@ -306,7 +264,7 @@
class="w-28"
/>
</div>
<div class="flex items-center gap-1 w-80">
<div class="flex items-center gap-1 w-80 relative">
{#if ducklake.catalog.resource_type !== 'instance'}
<ResourcePicker
bind:value={ducklake.catalog.resource_path}
@@ -315,16 +273,51 @@
class="min-h-9"
/>
{:else}
<TextInput
{@const status =
instanceCatalogStatuses.value?.[ducklake.catalog.resource_path ?? '']}
<Select
class="flex-1"
inputClass="pr-20"
bind:value={ducklake.catalog.resource_path}
inputProps={{ placeholder: 'PostgreSQL database name' }}
onCreateItem={(i) => (ducklake.catalog.resource_path = i)}
placeholder="PostgreSQL database name"
items={safeSelectItems(Object.keys(instanceCatalogStatuses.value ?? {}))}
disabled={!isInstanceCatalogEnabled}
/>
<Popover
class="absolute right-1.5"
enableFlyTransition
contentClasses="py-5 px-6 w-[34rem] bg-surface-secondary -translate-y-2"
closeOnOtherPopoverOpen
closeOnOutsideClick
{...confirmationModal.props.open ? { isOpen: false } : {}}
>
<svelte:fragment slot="trigger">
<Button spacingSize="xs2" variant="border" color="light" btnClasses="h-6">
{#if !status}
<span class="text-yellow-600 dark:text-yellow-400">
Setup <ArrowRight class="inline" size={14} />
</span>
{:else if !status.success}
<span class="text-red-400 flex gap-1">
Error <TriangleAlert class="inline" size={16} />
</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
{/if}
</Button>
</svelte:fragment>
<svelte:fragment slot="content">
{@render instanceCatalogWizard(status, ducklake.catalog.resource_path ?? '')}
</svelte:fragment>
</Popover>
{/if}
</div>
</div>
</Cell>
<Cell>
<div class="flex gap-4">
<div class="flex gap-2">
<Select
placeholder="Default storage"
items={[
@@ -389,7 +382,7 @@
</tbody>
</DataTable>
<Button
wrapperClasses="mt-4 mb-44 max-w-fit"
wrapperClasses="mt-4 mb-16 max-w-fit"
on:click={onSave}
disabled={ducklakeSavedSettings.ducklakes.length === ducklakeSettings.ducklakes.length &&
Object.values(ducklakeIsDirty).every((v) => v === false)}
@@ -399,3 +392,120 @@
<DbManagerDrawer bind:this={dbManagerDrawer} />
<ConfirmationModal {...confirmationModal.props} />
{#snippet instanceCatalogWizard(
status: DucklakeInstanceCatalogDbStatus | undefined,
dbname: string
)}
{#if !status}
<div class="mb-4 text-secondary text-sm">
{dbname} needs to be configured in the Windmill postgres instance
</div>
{/if}
{#if status?.error}
<div transition:slide={{ duration: 200 }} class="mb-4">
<Alert title="Error setting up ducklake instance catalog" type="error">
{status.error}
</Alert>
</div>
{/if}
<LoggedWizardResult
class="max-h-[24rem] overflow-y-auto"
steps={firstEmptyStepIsError(
[
{
title: 'Super admin required',
status: status?.logs.super_admin,
description:
'You need to be a super admin to setup an instance catalog, as it requires creating a new database in the Windmill PostgreSQL instance'
},
{
title: 'Retrieve and parse database credentials',
status: status?.logs.database_credentials,
description:
'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set'
},
{
title: 'Catalog name is valid',
status: status?.logs.valid_dbname,
description:
'The catalog name must be alphanumeric (underscores allowed) and cannot be named the same as the Windmill database (usually "windmill")'
},
{
title:
'Create database' +
(status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''),
status: status?.logs.created_database,
description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".`
},
{
title: `Connect to the ${dbname} database`,
status: status?.logs.db_connect,
description:
"Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands"
},
{
title: 'Grant permissions to ducklake_user',
status: status?.logs.grant_permissions,
description:
'Gives ducklake_user the required permissions to use the database as a Ducklake catalog. ducklake_user is already created during a migration and has an auto-generated password stored in global_settings.ducklake_settings.ducklake_user_pg_pwd. These are the commands : \n\n' +
`GRANT CONNECT ON DATABASE "${dbname}" TO ducklake_user;\n` +
'GRANT USAGE ON SCHEMA public TO ducklake_user;\n' +
'GRANT CREATE ON SCHEMA public TO ducklake_user;\n' +
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO ducklake_user;'
}
],
status?.error ?? undefined
)}
/>
<Button
wrapperClasses="mt-6"
size="sm"
disabled={!isInstanceCatalogEnabled}
onClick={async () => {
if (instanceCatalogSetupIsRunning) return
let wasAlreadySuccessful = status?.success ?? false
if (status?.logs.created_database != 'OK' && status?.logs.created_database != 'SKIP') {
let confirm = await confirmationModal.ask({
title: 'Confirm setup',
children: `This will create a new database ${dbname} in the Windmill PostgreSQL instance`,
confirmationText: 'Setup catalog'
})
if (!confirm) return
}
try {
instanceCatalogSetupIsRunning = true
let result = await SettingService.setupDucklakeCatalogDb({ name: dbname })
await instanceCatalogStatuses.refresh()
if (result.success) {
if (!wasAlreadySuccessful) sendUserToast('Setup successful')
else sendUserToast('Everything OK')
} else {
sendUserToast(result.error ?? 'An error occured', true)
}
} catch (e) {
sendUserToast('Unexpected error, check console for details', true)
console.error('Error setting up ducklake instance catalog', e)
} finally {
instanceCatalogSetupIsRunning = false
}
}}
loading={instanceCatalogSetupIsRunning}
>
{#if !isInstanceCatalogEnabled}
Only superadmins can setup instance catalogs
{:else if status?.success}
Check again
{:else if status?.error}
Try again
{:else}
Setup {dbname}
{/if}
</Button>
{/snippet}

View File

@@ -30,6 +30,7 @@ export type UsePromiseResult<T> = (
| { status: 'ok'; value: T; error?: undefined }
) & {
refresh: () => void
clear: () => void
}
export type UsePromiseOptions = {
@@ -64,6 +65,12 @@ export function usePromise<T>(
ret.status = 'error'
})
})
},
clear: () => {
ret.status = 'loading'
ret.value = undefined
ret.error = undefined
ret.__promise = undefined
}
})
if (loadInit) ret.refresh()