Compare commits
20 Commits
v1.485.1
...
alp/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb7360210 | ||
|
|
9c55040e47 | ||
|
|
41c15fc78a | ||
|
|
09169784bd | ||
|
|
4c660a8869 | ||
|
|
dfb947ff37 | ||
|
|
257040ab28 | ||
|
|
dda92b5491 | ||
|
|
18dced3c74 | ||
|
|
c641550eee | ||
|
|
fbdaf28fb5 | ||
|
|
75ee10ea65 | ||
|
|
f120f4ac4b | ||
|
|
46a3c29c9f | ||
|
|
1ab41603f4 | ||
|
|
60018aadf6 | ||
|
|
193186ae75 | ||
|
|
e298c8eea5 | ||
|
|
f662cf5d75 | ||
|
|
418c36ed62 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [1.485.3](https://github.com/windmill-labs/windmill/compare/v1.485.2...v1.485.3) (2025-04-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve performance of background cleanup monitoring operations ([18dced3](https://github.com/windmill-labs/windmill/commit/18dced3c748cd5305f0934b26e50d69899563723))
|
||||
|
||||
## [1.485.2](https://github.com/windmill-labs/windmill/compare/v1.485.1...v1.485.2) (2025-04-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve agent workers for deployed scripts ([60018aa](https://github.com/windmill-labs/windmill/commit/60018aadf62cecadf111e019d3600513a89810f1))
|
||||
* make `#(extra_)requirements:` work better with pins ([#5680](https://github.com/windmill-labs/windmill/issues/5680)) ([1ab4160](https://github.com/windmill-labs/windmill/commit/1ab41603f4fd1526d0c944396ef250b184aed1f4))
|
||||
* **python:** handle better relative imports with requirements or extra_requirements ([f662cf5](https://github.com/windmill-labs/windmill/commit/f662cf5d75beed8fd114ba171cbe0fa8e4b2773f))
|
||||
|
||||
## [1.485.1](https://github.com/windmill-labs/windmill/compare/v1.485.0...v1.485.1) (2025-04-28)
|
||||
|
||||
|
||||
|
||||
12
backend/.sqlx/query-45dde0dc2da12fe46b5975ed53b520fc4160c008c3d29fe7d99e609c0b6e3e6d.json
generated
Normal file
12
backend/.sqlx/query-45dde0dc2da12fe46b5975ed53b520fc4160c008c3d29fe7d99e609c0b6e3e6d.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM metrics WHERE created_at < NOW() - INTERVAL '180 day'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "45dde0dc2da12fe46b5975ed53b520fc4160c008c3d29fe7d99e609c0b6e3e6d"
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
79
backend/Cargo.lock
generated
79
backend/Cargo.lock
generated
@@ -1647,9 +1647,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
|
||||
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
@@ -2913,7 +2913,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sourcemap 9.1.2",
|
||||
"sourcemap 9.2.0",
|
||||
"swc_atoms",
|
||||
"swc_common",
|
||||
"swc_config",
|
||||
@@ -8960,7 +8960,7 @@ version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
|
||||
dependencies = [
|
||||
"toml_edit 0.22.25",
|
||||
"toml_edit 0.22.26",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11048,17 +11048,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sourcemap"
|
||||
version = "9.1.2"
|
||||
version = "9.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c4ea7042fd1a155ad95335b5d505ab00d5124ea0332a06c8390d200bb1a76a"
|
||||
checksum = "dd430118acc9fdd838557649b9b43fd0a78e3834d84a283b466f8e84720d6101"
|
||||
dependencies = [
|
||||
"base64-simd 0.7.0",
|
||||
"base64-simd 0.8.0",
|
||||
"bitvec",
|
||||
"data-encoding",
|
||||
"debugid",
|
||||
"if_chain",
|
||||
"rustc-hash 1.1.0",
|
||||
"rustc_version 0.2.3",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-id-start",
|
||||
@@ -11553,7 +11552,7 @@ dependencies = [
|
||||
"rustc-hash 1.1.0",
|
||||
"serde",
|
||||
"siphasher 0.3.11",
|
||||
"sourcemap 9.1.2",
|
||||
"sourcemap 9.2.0",
|
||||
"swc_allocator",
|
||||
"swc_atoms",
|
||||
"swc_eq_ignore_macros",
|
||||
@@ -11617,7 +11616,7 @@ dependencies = [
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sourcemap 9.1.2",
|
||||
"sourcemap 9.2.0",
|
||||
"swc_allocator",
|
||||
"swc_atoms",
|
||||
"swc_common",
|
||||
@@ -12753,9 +12752,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.25"
|
||||
version = "0.22.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485"
|
||||
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"toml_datetime",
|
||||
@@ -13920,7 +13919,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -13967,7 +13966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14077,7 +14076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -14092,7 +14091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
@@ -14105,7 +14104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -14119,7 +14118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14183,7 +14182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14197,7 +14196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -14220,7 +14219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14232,7 +14231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14241,7 +14240,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14253,7 +14252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14265,7 +14264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14277,7 +14276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14289,7 +14288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14301,7 +14300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14312,7 +14311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14323,7 +14322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14334,7 +14333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -14354,7 +14353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -14371,7 +14370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14383,7 +14382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14401,7 +14400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.16",
|
||||
@@ -14425,7 +14424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14435,7 +14434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -14468,7 +14467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -14478,7 +14477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -32,7 +32,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
649ec8cf49254e24b2c0f21c2ed509b2ab5b4f88
|
||||
96a2129f585a1bc6567ef08bd27aaa4aca70543f
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add up migration script here
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_id_created_at ON public.metrics (id, created_at DESC) WHERE id LIKE 'queue_%';
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add up migration script here
|
||||
CREATE INDEX IF NOT EXISTS job_stats_id ON job_stats (job_id);
|
||||
@@ -44,6 +44,7 @@ fn replace_full_import(x: &str) -> Option<String> {
|
||||
lazy_static! {
|
||||
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)\s*$").unwrap();
|
||||
static ref PIN_RE: Regex = Regex::new(r"(?:\s*#\s*(pin|repin):\s*)(\S*)").unwrap();
|
||||
static ref PKG_RE: Regex = Regex::new(r"^([^!=<>]+)(?:[!=<>]|$)").unwrap();
|
||||
}
|
||||
|
||||
fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<NImport> {
|
||||
@@ -167,30 +168,28 @@ fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>>
|
||||
.take_while(|e| *e != '\n')
|
||||
.collect::<String>();
|
||||
|
||||
if hs.trim_start().is_empty(){
|
||||
if hs.trim_start().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
PIN_RE
|
||||
.captures(&hs)
|
||||
.and_then(|x| {
|
||||
x.get(1).zip(x.get(2)).and_then(|(ty_m, pkg_m)| {
|
||||
let pkg = pkg_m.as_str().to_owned();
|
||||
if ty_m.as_str() == "pin" {
|
||||
Some(vec![NImport::Pin {
|
||||
pins: vec![ImportPin { pkg, path: path.to_owned() }],
|
||||
key,
|
||||
}])
|
||||
} else if ty_m.as_str() == "repin" {
|
||||
Some(vec![NImport::Repin {
|
||||
pin: ImportPin { pkg, path: path.to_owned() },
|
||||
key,
|
||||
}])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
PIN_RE.captures(&hs).and_then(|x| {
|
||||
x.get(1).zip(x.get(2)).and_then(|(ty_m, pkg_m)| {
|
||||
let pkg = pkg_m.as_str().to_owned();
|
||||
if ty_m.as_str() == "pin" {
|
||||
Some(vec![NImport::Pin {
|
||||
pins: vec![ImportPin { pkg, path: path.to_owned() }],
|
||||
key,
|
||||
}])
|
||||
} else if ty_m.as_str() == "repin" {
|
||||
Some(vec![NImport::Repin {
|
||||
pin: ImportPin { pkg, path: path.to_owned() },
|
||||
key,
|
||||
}])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let mut nimports: Vec<NImport> = ast
|
||||
@@ -268,7 +267,14 @@ pub async fn parse_python_imports(
|
||||
Ok(p.pkg)
|
||||
}).collect_vec(),
|
||||
NImportResolved::Repin { pin: ImportPin { pkg, .. }, .. } => vec![Ok(pkg)],
|
||||
NImportResolved::Auto { pkg, ..} => vec![Ok(pkg)],
|
||||
NImportResolved::Auto { pkg, key } => vec![
|
||||
|
||||
if let Some(key) = key {
|
||||
Ok(format!("{pkg} # (mapped from {key})"))
|
||||
} else {
|
||||
Ok(pkg)
|
||||
}
|
||||
],
|
||||
})
|
||||
.flatten()
|
||||
.collect::<error::Result<Vec<String>>>()?
|
||||
@@ -284,6 +290,13 @@ pub async fn parse_python_imports(
|
||||
Ok((imports, compile_error_hint))
|
||||
}
|
||||
|
||||
fn extract_pkg_name(requirement: &str) -> String {
|
||||
PKG_RE
|
||||
.captures(requirement)
|
||||
.map(|x| x.get(1).map(|m| m.as_str().to_string()).unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn parse_python_imports_inner(
|
||||
code: &str,
|
||||
@@ -343,11 +356,15 @@ async fn parse_python_imports_inner(
|
||||
RE.captures(x).and_then(|x| {
|
||||
x.get(1).map(|m| {
|
||||
let requirement = m.as_str().to_string();
|
||||
let key = extract_pkg_name(&requirement);
|
||||
requirements.insert(
|
||||
requirement.clone(),
|
||||
NImportResolved::Repin {
|
||||
pin: ImportPin { pkg: requirement, path: Default::default() },
|
||||
key: Default::default(),
|
||||
key.clone(),
|
||||
NImportResolved::Pin {
|
||||
pins: vec![ImportPin {
|
||||
pkg: requirement.clone(),
|
||||
path: Default::default(),
|
||||
}],
|
||||
key,
|
||||
},
|
||||
);
|
||||
})
|
||||
@@ -368,9 +385,16 @@ async fn parse_python_imports_inner(
|
||||
RE.captures(x).and_then(|x| {
|
||||
x.get(1).map(|m| {
|
||||
let requirement = m.as_str().to_string();
|
||||
let key = extract_pkg_name(&requirement);
|
||||
imports.insert(
|
||||
requirement.clone(),
|
||||
NImportResolved::Auto { key: None, pkg: requirement },
|
||||
key.clone(),
|
||||
NImportResolved::Pin {
|
||||
pins: vec![ImportPin {
|
||||
pkg: requirement,
|
||||
path: Default::default(),
|
||||
}],
|
||||
key,
|
||||
},
|
||||
);
|
||||
})
|
||||
})
|
||||
|
||||
@@ -325,6 +325,7 @@ pub static FULL_IMPORTS_MAP: PyMap = phf_map! {
|
||||
"azure.mgmt.nspkg" => "azure-mgmt-nspkg",
|
||||
"azure.keyvault.secrets" => "azure-keyvault-secrets",
|
||||
"azure.storage.blob" => "azure-storage-blob",
|
||||
"azure.storage.filedatalake" => "azure-storage-file-datalake",
|
||||
// Add new entry here ^
|
||||
};
|
||||
|
||||
@@ -376,5 +377,6 @@ pub static SHORT_IMPORTS_MAP: PyMap = phf_map! {
|
||||
"socks" => "PySocks",
|
||||
"taiga" => "python-taiga",
|
||||
"docx" => "python-docx",
|
||||
"vt" => "vt-py",
|
||||
// Add new entry here ^
|
||||
};
|
||||
|
||||
@@ -29,7 +29,15 @@ def main():
|
||||
)
|
||||
.await?;
|
||||
// println!("{}", serde_json::to_string(&r)?);
|
||||
assert_eq!(r, vec!["matplotlib", "wmill", "zanzibar"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
"matplotlib # (mapped from matplotlib.pyplot)",
|
||||
"wmill",
|
||||
"zanzibar # (mapped from zanzibar.estonie)"
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4004,10 +4004,11 @@ async fn test_pins_python(db: Pool<Postgres>) {
|
||||
# py311
|
||||
# extra_requirements:
|
||||
# tiny==0.1.3
|
||||
# bottle==0.13.2
|
||||
|
||||
import f.system.requirements
|
||||
import f.system.pins
|
||||
import tiny # repin: bottle==0.13.0
|
||||
import tiny # repin: tiny==0.1.3
|
||||
import simplejson
|
||||
|
||||
def main():
|
||||
@@ -4021,7 +4022,7 @@ def main():
|
||||
ScriptLang::Python3,
|
||||
vec![
|
||||
"# py311",
|
||||
"bottle==0.13.0",
|
||||
"bottle==0.13.2",
|
||||
"microdot==2.2.0",
|
||||
"simplejson==3.19.3",
|
||||
"tiny==0.1.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.485.1
|
||||
version: 1.485.3
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -13430,6 +13430,8 @@ components:
|
||||
type: string
|
||||
raw_code:
|
||||
type: string
|
||||
leaf_job:
|
||||
$ref: "#/components/schemas/LeafJob"
|
||||
canceled:
|
||||
type: boolean
|
||||
canceled_by:
|
||||
@@ -16729,4 +16731,30 @@ components:
|
||||
channel_name:
|
||||
type: string
|
||||
description: Microsoft Teams channel name
|
||||
minLength: 1
|
||||
minLength: 1
|
||||
|
||||
LeafJob:
|
||||
type: object
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
SingleJob:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of an individual job
|
||||
required:
|
||||
- SingleJob
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
ListJob:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Array of UUIDs for multiple related jobs
|
||||
required:
|
||||
- ListJob
|
||||
additionalProperties: false
|
||||
description: Map of keys to JobResult, which can be either SingleJob (individual UUID) or ListJob (array of UUIDs)
|
||||
@@ -570,26 +570,6 @@ pub async fn run_server(
|
||||
.nest("/ai", ai::global_service())
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
agent_workers_ee::global_service().layer(Extension(agent_cache.clone()))
|
||||
}
|
||||
#[cfg(not(feature = "agent_worker_server"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
})
|
||||
.nest("/w/:workspace_id/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
agent_workers_router.layer(Extension(agent_cache.clone()))
|
||||
}
|
||||
#[cfg(not(feature = "agent_worker_server"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
})
|
||||
.nest("/jobs", jobs::global_root_service())
|
||||
.nest(
|
||||
"/srch/w/:workspace_id/index",
|
||||
@@ -627,6 +607,26 @@ pub async fn run_server(
|
||||
)
|
||||
.nest("/mcp/w/:workspace_id", mcp_router)
|
||||
.layer(from_extractor::<OptAuthed>())
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
agent_workers_ee::global_service().layer(Extension(agent_cache.clone()))
|
||||
}
|
||||
#[cfg(not(feature = "agent_worker_server"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
})
|
||||
.nest("/w/:workspace_id/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
agent_workers_router.layer(Extension(agent_cache.clone()))
|
||||
}
|
||||
#[cfg(not(feature = "agent_worker_server"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
})
|
||||
.nest(
|
||||
"/w/:workspace_id/jobs_u",
|
||||
jobs::workspace_unauthed_service().layer(cors.clone()),
|
||||
|
||||
@@ -570,15 +570,18 @@ pub mod script {
|
||||
let fut = CACHE.get_or_insert_async(hash, async move {
|
||||
match conn {
|
||||
Connection::Sql(db) => fetch_script_from_db(&db, hash, loc).await,
|
||||
Connection::Http(_) => Err(error::Error::InternalErr(format!(
|
||||
"Cannot fetch script in HTTP mode"
|
||||
))),
|
||||
Connection::Http(client) => {
|
||||
let r = client
|
||||
.get::<RawScriptApi>(&format!("/api/agent_workers/script/{}", hash.0))
|
||||
.await?;
|
||||
Ok(r.into())
|
||||
}
|
||||
}
|
||||
});
|
||||
fut.map_ok(|ScriptFull { data, meta }| (data, meta))
|
||||
}
|
||||
|
||||
async fn fetch_script_from_db(
|
||||
pub async fn fetch_script_from_db(
|
||||
db: &DB,
|
||||
hash: ScriptHash,
|
||||
loc: &'static Location<'_>,
|
||||
|
||||
@@ -164,7 +164,7 @@ pub async fn cancel_single_job<'c>(
|
||||
let username = username.to_string();
|
||||
let w_id = w_id.to_string();
|
||||
let db = db.clone();
|
||||
tracing::info!("cancelling job {:?}", db);
|
||||
tracing::info!("cancelling job {:?}", job_running.id);
|
||||
let job_running = job_running.clone();
|
||||
tokio::task::spawn(async move {
|
||||
let reason: String = reason
|
||||
|
||||
@@ -1269,7 +1269,7 @@ pub async fn run_worker(
|
||||
if job.is_err() && !same_worker_job.recoverable {
|
||||
tracing::error!(
|
||||
worker = %worker_name, hostname = %hostname,
|
||||
"failed to fetch same_worker job on a non recoverable job, exiting"
|
||||
"failed to fetch same_worker job on a non recoverable job, exiting: {job:?}",
|
||||
);
|
||||
job_completed_tx
|
||||
.kill()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.485.1";
|
||||
export const VERSION = "v1.485.3";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -63,7 +63,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.485.1";
|
||||
export const VERSION = "1.485.3";
|
||||
|
||||
const command = new Command()
|
||||
.name("wmill")
|
||||
|
||||
@@ -609,11 +609,16 @@ export async function* readDirRecursiveWithIgnore(
|
||||
|
||||
while (stack.length > 0) {
|
||||
const e = stack.pop()!;
|
||||
// console.log(e.path);
|
||||
yield e;
|
||||
for await (const e2 of e.c()) {
|
||||
if (e2.path.startsWith(".git" + SEP)) {
|
||||
continue;
|
||||
if (e2.isDirectory) {
|
||||
const dirName = e2.path.split(SEP).pop();
|
||||
if (dirName == "node_modules" || dirName?.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// console.log(e2.path);
|
||||
stack.push({
|
||||
path: e2.path,
|
||||
ignored: e.ignored || ignore(e2.path, e2.isDirectory),
|
||||
|
||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.485.1",
|
||||
"version": "1.485.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.485.1",
|
||||
"version": "1.485.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -11220,8 +11220,6 @@
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.485.1",
|
||||
"version": "1.485.3",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
206
frontend/src/lib/components/DBManager.svelte
Normal file
206
frontend/src/lib/components/DBManager.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { MoreVertical, Plus, Table2 } from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { ClearableInput, Drawer, DrawerContent } from './common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
import DBTable from './DBTable.svelte'
|
||||
import type { DbTableActionFactory, IDbTableOps } from './dbOps'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import DbTableEditor, { type DBTableEditorProps } from './DBTableEditor.svelte'
|
||||
|
||||
type Props = {
|
||||
dbSchema: DBSchema
|
||||
dbSupportsSchemas: boolean
|
||||
getColDefs: (tableKey: string) => Promise<ColumnDef[]>
|
||||
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
||||
dbTableActionsFactory?: DbTableActionFactory[]
|
||||
refresh?: () => void
|
||||
dbTableEditorPropsFactory?: (params: { selectedSchemaKey?: string }) => DBTableEditorProps
|
||||
}
|
||||
let {
|
||||
dbSchema,
|
||||
dbTableOpsFactory,
|
||||
getColDefs,
|
||||
dbTableActionsFactory,
|
||||
refresh,
|
||||
dbTableEditorPropsFactory,
|
||||
dbSupportsSchemas
|
||||
}: Props = $props()
|
||||
|
||||
let schemaKeys = $derived(Object.keys(dbSchema.schema))
|
||||
let search = $state('')
|
||||
let selected: {
|
||||
schemaKey?: undefined | string
|
||||
tableKey?: undefined | string
|
||||
} = $state({})
|
||||
|
||||
$effect(() => {
|
||||
if (!selected.schemaKey && schemaKeys.length) {
|
||||
selected = {
|
||||
schemaKey:
|
||||
'public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let tableKeys = $derived.by(() => {
|
||||
if (dbSchema.lang === 'graphql') {
|
||||
sendUserToast('graphql not supported by DBExplorerTable', true)
|
||||
return []
|
||||
}
|
||||
if (!selected.schemaKey) return []
|
||||
return Object.keys(dbSchema.schema[selected.schemaKey])
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (tableKeys.length && !selected.tableKey) {
|
||||
selected.tableKey = filteredTableKeys[0]
|
||||
}
|
||||
})
|
||||
|
||||
let filteredTableKeys = $derived.by(() => {
|
||||
const l = tableKeys.filter((tk) => tk.includes(search))
|
||||
l.sort()
|
||||
return l
|
||||
})
|
||||
|
||||
let tableKey = $derived(
|
||||
dbSupportsSchemas && selected.schemaKey
|
||||
? `${selected.schemaKey}.${selected.tableKey}`
|
||||
: selected.tableKey
|
||||
)
|
||||
|
||||
let askingForConfirmation:
|
||||
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
|
||||
| undefined = $state()
|
||||
|
||||
let dbTableEditorState: { open: boolean } = $state({ open: false })
|
||||
|
||||
let dbTableEditorProps = $derived(
|
||||
dbTableEditorPropsFactory?.({ selectedSchemaKey: selected.schemaKey })
|
||||
)
|
||||
</script>
|
||||
|
||||
<Splitpanes>
|
||||
<Pane size={24} class="relative flex flex-col">
|
||||
<div class="mx-3 mt-3">
|
||||
{#if dbSupportsSchemas}
|
||||
<select
|
||||
value={selected.schemaKey}
|
||||
onchange={(e) => {
|
||||
selected = { schemaKey: e.currentTarget.value }
|
||||
}}
|
||||
>
|
||||
{#each schemaKeys as schemaKey}
|
||||
<option value={schemaKey}>{schemaKey}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<ClearableInput wrapperClass="mt-3" bind:value={search} placeholder="Search table..." />
|
||||
</div>
|
||||
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
|
||||
{#each filteredTableKeys as tableKey}
|
||||
<button
|
||||
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
|
||||
(selected.tableKey === tableKey ? 'bg-gray-500/25' : 'hover:bg-gray-500/10')}
|
||||
onclick={() => (selected.tableKey = tableKey)}
|
||||
>
|
||||
<Table2 class="text-gray-500/40 shrink-0" size={16} />
|
||||
<p class="truncate text-ellipsis grow text-left">{tableKey}</p>
|
||||
{#if dbTableActionsFactory}
|
||||
{@const dbTableActions = dbTableActionsFactory.map((f) =>
|
||||
f({
|
||||
tableKey: `${selected.schemaKey}.${tableKey}`,
|
||||
refresh: refresh ?? (() => {})
|
||||
})
|
||||
)}
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
dbTableActions.map((tableAction) => ({
|
||||
displayName: tableAction.displayName,
|
||||
...(tableAction.icon ? { icon: tableAction.icon } : {}),
|
||||
action: () =>
|
||||
(askingForConfirmation = {
|
||||
title: tableAction.confirmTitle ?? 'Are you sure ?',
|
||||
confirmationText: tableAction.confirmBtnText ?? 'Confirm',
|
||||
open: true,
|
||||
onConfirm: async () => {
|
||||
askingForConfirmation && (askingForConfirmation.loading = true)
|
||||
try {
|
||||
await tableAction.action()
|
||||
tableAction.successText && sendUserToast(tableAction.successText)
|
||||
} catch (e) {
|
||||
let msg: string | undefined = (e as Error).message
|
||||
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
||||
sendUserToast(msg ?? 'Action failed!', true)
|
||||
}
|
||||
askingForConfirmation = undefined
|
||||
}
|
||||
})
|
||||
}))}
|
||||
class="w-fit"
|
||||
>
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
<MoreVertical
|
||||
size={8}
|
||||
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
on:click={() => (dbTableEditorState = { open: true })}
|
||||
wrapperClasses="mx-2 my-2 text-sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
>
|
||||
New table
|
||||
</Button>
|
||||
</Pane>
|
||||
<Pane class="p-3 pt-1">
|
||||
{#if tableKey}
|
||||
{#await getColDefs(tableKey) then colDefs}
|
||||
{#if colDefs && colDefs?.length}
|
||||
{@const dbTableOps = dbTableOpsFactory({ colDefs, tableKey })}
|
||||
<DBTable {dbTableOps} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<ConfirmationModal
|
||||
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
|
||||
on:canceled={() => (askingForConfirmation = undefined)}
|
||||
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
|
||||
/>
|
||||
|
||||
{#if dbTableEditorProps}
|
||||
<Drawer
|
||||
size="600px"
|
||||
open={dbTableEditorState.open}
|
||||
on:close={() => (dbTableEditorState = { open: false })}
|
||||
>
|
||||
<DrawerContent
|
||||
on:close={() => (dbTableEditorState = { open: false })}
|
||||
title="Create a new table"
|
||||
>
|
||||
<DbTableEditor
|
||||
{...dbTableEditorProps}
|
||||
{dbSchema}
|
||||
currentSchema={selected.schemaKey}
|
||||
onConfirm={async (values) => {
|
||||
await dbTableEditorProps.onConfirm(values)
|
||||
dbTableEditorState = { open: false }
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
272
frontend/src/lib/components/DBManagerDrawerButton.svelte
Normal file
272
frontend/src/lib/components/DBManagerDrawerButton.svelte
Normal file
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import { sendUserToast, sortArray } from '$lib/utils'
|
||||
import { ArrowLeft, Database, Expand, Loader2, Minimize, RefreshCcw } from 'lucide-svelte'
|
||||
import {
|
||||
dbSupportsSchemas,
|
||||
getDbSchemas,
|
||||
getLanguageByResourceType,
|
||||
loadAllTablesMetaData,
|
||||
loadTableMetaData,
|
||||
type DbType,
|
||||
type TableMetadata
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import DbManager from './DBManager.svelte'
|
||||
import { Alert } from './common'
|
||||
import { dbDeleteTableActionWithPreviewScript, dbTableOpsWithPreviewScripts } from './dbOps'
|
||||
import { makeCreateTableQuery } from './apps/components/display/dbtable/queries/createTable'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SqlRepl from './SqlRepl.svelte'
|
||||
import SimpleAgTable from './SimpleAgTable.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
resourceType: DbType
|
||||
resourcePath: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { resourceType, resourcePath }: Props = $props()
|
||||
|
||||
let dbSchema: DBSchema | undefined = $derived(
|
||||
resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
|
||||
)
|
||||
|
||||
let isDrawerOpen: boolean = $state(false)
|
||||
|
||||
let shouldDisplayError = $derived(
|
||||
resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
|
||||
)
|
||||
|
||||
// `refreshCount` is a derived state. `refreshing` is the source of truth
|
||||
let refreshCount = $state(0)
|
||||
$effect(() => {
|
||||
if (refreshing) untrack(() => (refreshCount += 1))
|
||||
})
|
||||
|
||||
let refreshing = $state(false)
|
||||
$effect(() => {
|
||||
if (refreshing) getSchema()
|
||||
})
|
||||
const refresh = () => !refreshing && (refreshing = true)
|
||||
|
||||
let expand = $state(false)
|
||||
$effect(() => {
|
||||
if (!isDrawerOpen) expand = false
|
||||
})
|
||||
|
||||
async function getSchema() {
|
||||
if ($dbSchemas[resourcePath] && !refreshing) return
|
||||
try {
|
||||
const oldDbSchema = $dbSchemas[resourcePath]
|
||||
await getDbSchemas(
|
||||
resourceType,
|
||||
resourcePath,
|
||||
$workspaceStore,
|
||||
$dbSchemas,
|
||||
(message: string) => {
|
||||
if (isDrawerOpen) {
|
||||
sendUserToast(message, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
// avoid infinite loop on error due to the way getDbSchemas is implemented
|
||||
// and relying on an assignement side effect
|
||||
if (oldDbSchema !== $dbSchemas[resourcePath]) $dbSchemas = $dbSchemas
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
let windowWidth = $state(window.innerWidth)
|
||||
|
||||
let replPanelSize = $state(36)
|
||||
const REPL_MIN_SIZE = 1.5
|
||||
|
||||
let replResultData: undefined | Record<string, any>[] = $state(undefined)
|
||||
|
||||
let cachedColDefs: Record<string, TableMetadata> = {}
|
||||
let cachedLastRefreshCount = 0
|
||||
|
||||
async function getColDefs(tableKey: string) {
|
||||
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
|
||||
cachedLastRefreshCount = refreshCount
|
||||
if (cachedColDefs[tableKey]) {
|
||||
return cachedColDefs[tableKey]
|
||||
}
|
||||
try {
|
||||
cachedColDefs =
|
||||
(await loadAllTablesMetaData('$res:' + resourcePath, $workspaceStore, resourceType)) ??
|
||||
cachedColDefs
|
||||
return cachedColDefs[tableKey]
|
||||
} catch (e) {
|
||||
const result = await loadTableMetaData(
|
||||
'$res:' + resourcePath,
|
||||
$workspaceStore,
|
||||
tableKey,
|
||||
resourceType
|
||||
)
|
||||
|
||||
if (result) cachedColDefs[tableKey] = result
|
||||
return result ?? []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerWidth={windowWidth}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (replResultData) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if shouldDisplayError}
|
||||
<Alert type="error" size="xs" title="Schema not available" class="mt-2">
|
||||
Schema could not be loaded. Please check the permissions of the resource.
|
||||
</Alert>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
spacingSize="xs2"
|
||||
btnClasses="mt-1 w-24"
|
||||
on:click={async () => {
|
||||
if (!dbSchema || !$workspaceStore) refreshing = true
|
||||
isDrawerOpen = true
|
||||
}}
|
||||
>
|
||||
<Database size={18} /> Manager
|
||||
</Button>
|
||||
<Drawer bind:open={isDrawerOpen} size={expand ? `${windowWidth}px` : '1200px'} preventEscape>
|
||||
<DrawerContent
|
||||
title={replResultData ? 'Query Result' : 'Database Manager'}
|
||||
on:close={() => {
|
||||
if (replResultData) {
|
||||
replResultData = undefined
|
||||
} else {
|
||||
isDrawerOpen = false
|
||||
}
|
||||
}}
|
||||
CloseIcon={replResultData ? ArrowLeft : undefined}
|
||||
noPadding
|
||||
>
|
||||
{#if dbSchema && $workspaceStore}
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={'absolute inset-0 z-10 p-8 ' +
|
||||
(replResultData
|
||||
? 'bg-surface/90'
|
||||
: 'transition-colors bg-transparent pointer-events-none select-none')}
|
||||
onclick={(e) => {
|
||||
// Only proceed if the click is directly on this div and not on the child elements
|
||||
if (e.target === e.currentTarget) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if replResultData}
|
||||
{#key replResultData}
|
||||
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={dbSupportsSchemas(resourceType)}
|
||||
{dbSchema}
|
||||
{getColDefs}
|
||||
dbTableOpsFactory={({ colDefs, tableKey }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
tableKey,
|
||||
resourcePath,
|
||||
resourceType,
|
||||
workspace: $workspaceStore
|
||||
})}
|
||||
dbTableActionsFactory={[
|
||||
dbDeleteTableActionWithPreviewScript({
|
||||
resourcePath,
|
||||
resourceType,
|
||||
workspace: $workspaceStore
|
||||
})
|
||||
]}
|
||||
{refresh}
|
||||
dbTableEditorPropsFactory={({ selectedSchemaKey }) => ({
|
||||
resourceType,
|
||||
previewSql: (values) =>
|
||||
makeCreateTableQuery(values, resourceType, selectedSchemaKey),
|
||||
async onConfirm(values) {
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath },
|
||||
content: makeCreateTableQuery(values, resourceType, selectedSchemaKey),
|
||||
language: getLanguageByResourceType(resourceType)
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
|
||||
<SqlRepl
|
||||
{resourcePath}
|
||||
{resourceType}
|
||||
onData={(data) => {
|
||||
replResultData = data
|
||||
}}
|
||||
placeholderTableName={sortArray(
|
||||
Object.keys(
|
||||
dbSchema?.schema[
|
||||
'public' in dbSchema?.schema
|
||||
? 'public'
|
||||
: 'dbo' in dbSchema?.schema
|
||||
? 'dbo'
|
||||
: Object.keys(dbSchema?.schema)?.[0]
|
||||
]
|
||||
)
|
||||
)?.[0]}
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<Splitpanes>
|
||||
<Pane class="relative flex justify-center items-center">
|
||||
<Loader2 class="animate-spin" size={32} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/if}
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
loading={refreshing}
|
||||
on:click={() => refresh()}
|
||||
startIcon={{ icon: RefreshCcw }}
|
||||
size="xs"
|
||||
color="light"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
on:click={() => (expand = !expand)}
|
||||
startIcon={{ icon: expand ? Minimize : Expand }}
|
||||
size="xs"
|
||||
color="light"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -1,58 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import { Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import {
|
||||
formatGraphqlSchema,
|
||||
formatSchema,
|
||||
getDbSchemas,
|
||||
scripts
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { formatGraphqlSchema, formatSchema } from './apps/components/display/dbtable/utils'
|
||||
|
||||
export let resourceType: string | undefined
|
||||
export let resourcePath: string | undefined = undefined
|
||||
let dbSchema: DBSchema | undefined = undefined
|
||||
let loading = false
|
||||
|
||||
let drawer: Drawer | undefined
|
||||
|
||||
async function getSchema() {
|
||||
if (!resourceType || !resourcePath) return
|
||||
if ($dbSchemas[resourcePath]) return
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await getDbSchemas(
|
||||
resourceType,
|
||||
resourcePath,
|
||||
$workspaceStore,
|
||||
$dbSchemas,
|
||||
(message: string) => {
|
||||
if (drawer?.isOpen()) {
|
||||
sendUserToast(message, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
$dbSchemas = $dbSchemas
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
loading = false
|
||||
type Props = {
|
||||
dbSchema: DBSchema
|
||||
}
|
||||
|
||||
$: resourcePath && Object.keys(scripts).includes(resourceType || '') && getSchema()
|
||||
|
||||
$: dbSchema = resourcePath && resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
|
||||
|
||||
$: shouldDisplayError = resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
|
||||
let { dbSchema }: Props = $props()
|
||||
|
||||
function handleSelected({ detail }: CustomEvent<string>) {
|
||||
if (dbSchema && dbSchema.lang !== 'graphql') {
|
||||
@@ -61,60 +19,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<Loader2 size={14} class="animate-spin " />
|
||||
{/if}
|
||||
|
||||
{#if dbSchema}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="blue"
|
||||
spacingSize="xs2"
|
||||
btnClasses="mt-1"
|
||||
on:click={drawer?.openDrawer ?? (() => {})}
|
||||
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
|
||||
<ToggleButtonGroup
|
||||
class="mb-4"
|
||||
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
|
||||
on:selected={handleSelected}
|
||||
let:item
|
||||
>
|
||||
Explore schema
|
||||
</Button>
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Schema Explorer" on:close={drawer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
on:click={getSchema}
|
||||
startIcon={{
|
||||
icon: RefreshCcw
|
||||
}}
|
||||
{loading}
|
||||
size="xs"
|
||||
color="light"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
|
||||
<ToggleButtonGroup
|
||||
class="mb-4"
|
||||
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
|
||||
on:selected={handleSelected}
|
||||
let:item
|
||||
>
|
||||
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if dbSchema.lang === 'graphql'}
|
||||
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
|
||||
{/await}
|
||||
{:else}
|
||||
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{:else if shouldDisplayError}
|
||||
<Alert type="error" size="xs" title="Schema not available" class="mt-2">
|
||||
Schema could not be loaded. Please check the permissions of the resource.
|
||||
</Alert>
|
||||
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if dbSchema.lang === 'graphql'}
|
||||
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
|
||||
{/await}
|
||||
{:else}
|
||||
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
|
||||
{/if}
|
||||
|
||||
206
frontend/src/lib/components/DBTable.svelte
Normal file
206
frontend/src/lib/components/DBTable.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
|
||||
import { transformColumnDefs } from './apps/components/display/table/utils'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import { Button } from './common'
|
||||
import { Download } from 'lucide-svelte'
|
||||
import Popover from './Popover.svelte'
|
||||
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
|
||||
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
|
||||
import type { IDbTableOps } from './dbOps'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import 'ag-grid-community/styles/ag-grid.css'
|
||||
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
|
||||
|
||||
type Props = {
|
||||
dbTableOps: IDbTableOps
|
||||
}
|
||||
let { dbTableOps }: Props = $props()
|
||||
|
||||
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
|
||||
let quicksearch = $state('')
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
|
||||
let datasource: IDatasource = {
|
||||
getRows: async function (params) {
|
||||
if (!$workspaceStore) return params.failCallback()
|
||||
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
|
||||
|
||||
const items = await dbTableOps.getRows({
|
||||
offset: params.startRow,
|
||||
limit: params.endRow - params.startRow,
|
||||
quicksearch: params.context.quicksearch,
|
||||
order_by: params.sortModel?.[0]?.colId ?? dbTableOps.colDefs[0]?.field,
|
||||
is_desc: params.sortModel?.[0]?.sort === 'desc'
|
||||
})
|
||||
|
||||
if (items.length < params.endRow - params.startRow) lastRow = params.startRow + items.length
|
||||
params.successCallback(items, lastRow)
|
||||
}
|
||||
}
|
||||
let rowCount = $state(0)
|
||||
let refreshCount = $state(0)
|
||||
const refresh = () => (refreshCount += 1)
|
||||
|
||||
$effect(() => eGui && mountGrid())
|
||||
function mountGrid() {
|
||||
if (eGui && !api) {
|
||||
createGrid(eGui, {
|
||||
rowModelType: 'infinite',
|
||||
pagination: false,
|
||||
...(dbTableOps.onUpdate && {
|
||||
defaultColDef: {
|
||||
flex: 1,
|
||||
minWidth: 160,
|
||||
editable: true,
|
||||
onCellValueChanged: (e) => {
|
||||
if (!$workspaceStore) return
|
||||
const colDef = e.colDef as unknown as { field: string; datatype: string }
|
||||
dbTableOps
|
||||
.onUpdate?.(
|
||||
{ values: { ...(e.data as object), [colDef.field]: e.oldValue } },
|
||||
colDef,
|
||||
e.newValue
|
||||
)
|
||||
.then(() => {
|
||||
sendUserToast('Value updated')
|
||||
})
|
||||
.catch(() => {
|
||||
sendUserToast('Error updating value', true)
|
||||
refresh?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
|
||||
infiniteInitialRowCount: 100,
|
||||
cacheBlockSize: 100,
|
||||
cacheOverflowSize: 10,
|
||||
maxBlocksInCache: 20,
|
||||
suppressColumnMoveAnimation: true,
|
||||
suppressDragLeaveHidesColumns: true,
|
||||
onGridReady: (e) => {
|
||||
api = e.api
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let prevUpdateKey: any = undefined
|
||||
$effect(() => {
|
||||
if (!$workspaceStore || !api) return
|
||||
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount }
|
||||
if (deepEqual(key, prevUpdateKey)) return
|
||||
prevUpdateKey = key
|
||||
updateGrid()
|
||||
})
|
||||
function updateGrid() {
|
||||
dbTableOps.getCount({ quicksearch }).then((result) => (rowCount = result))
|
||||
|
||||
api?.purgeInfiniteCache()
|
||||
api?.updateGridOptions({
|
||||
datasource,
|
||||
columnDefs: transformColumnDefs({
|
||||
columnDefs: dbTableOps.colDefs ?? [],
|
||||
...(dbTableOps.onDelete && {
|
||||
onDelete: (values) => {
|
||||
if (!$workspaceStore) return
|
||||
dbTableOps
|
||||
.onDelete?.({ values })
|
||||
.then(() => {
|
||||
refresh?.()
|
||||
sendUserToast('Row deleted')
|
||||
})
|
||||
.catch(() => {
|
||||
sendUserToast('Error deleting row', true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
context: {
|
||||
quicksearch
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="h-full relative flex flex-col">
|
||||
<div class="flex py-2 h-12 justify-between gap-4">
|
||||
<DebouncedInput
|
||||
class="w-full max-w-[300px]"
|
||||
type="text"
|
||||
bind:value={quicksearch}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
{#if dbTableOps.onInsert}
|
||||
<InsertRowDrawerButton
|
||||
columnDefs={dbTableOps.colDefs ?? []}
|
||||
dbType={dbTableOps.resourceType}
|
||||
onInsert={(values) => {
|
||||
if (!$workspaceStore) return
|
||||
dbTableOps.onInsert?.({ values }).then((result) => {
|
||||
refresh?.()
|
||||
sendUserToast('Row inserted')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={'flex flex-col flex-1 component-wrapper divide-y wm-aggrid-container'}
|
||||
bind:clientHeight
|
||||
bind:clientWidth
|
||||
>
|
||||
<div
|
||||
style:height="{clientHeight}px"
|
||||
style:width="{clientWidth}px"
|
||||
class="ag-theme-alpine"
|
||||
class:ag-theme-alpine-dark={darkMode}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={eGui}
|
||||
class={api ? '' : 'opacity-0'}
|
||||
style:height="100%"
|
||||
onkeydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selectedCell = api?.getFocusedCell()
|
||||
if (selectedCell) {
|
||||
const rowIndex = selectedCell.rowIndex
|
||||
const colId = selectedCell.column?.getId()
|
||||
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
|
||||
const selectedValue = rowNode?.data?.[colId]
|
||||
navigator.clipboard.writeText(selectedValue)
|
||||
sendUserToast('Copied cell value to clipboard', false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 w-full justify-between items-center text-xs text-primary p-2">
|
||||
<div>
|
||||
<Popover>
|
||||
<svelte:fragment slot="text">Download</svelte:fragment>
|
||||
<Button
|
||||
startIcon={{ icon: Download }}
|
||||
color="light"
|
||||
size="xs2"
|
||||
on:click={() => api?.exportDataAsCsv()}
|
||||
iconOnly
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
{#if rowCount}
|
||||
{firstRow}{'->'}{lastRow + 1} of {rowCount} rows
|
||||
{:else}
|
||||
{firstRow}{'->'}{lastRow + 1}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
435
frontend/src/lib/components/DBTableEditor.svelte
Normal file
435
frontend/src/lib/components/DBTableEditor.svelte
Normal file
@@ -0,0 +1,435 @@
|
||||
<script lang="ts" module>
|
||||
function validate(values: CreateTableValues, dbSchema?: DBSchema) {
|
||||
const columnNamesErrs = values.columns.flatMap((column) => {
|
||||
const isUnique = values.columns.filter((c) => c.name === column.name).length === 1
|
||||
return !column.name.length || !isUnique ? [column.name] : []
|
||||
})
|
||||
const fkErrs = values.foreignKeys.map((fk) => ({
|
||||
emptyTarget: !fk.targetTable,
|
||||
nonExistingSourceColumns: fk.columns
|
||||
.map((c) => c.sourceColumn)
|
||||
.filter((sc) => values.columns.every((c) => c.name !== sc)),
|
||||
nonExistingTargetColumns: fk.columns
|
||||
.map((c) => c.targetColumn)
|
||||
.filter(
|
||||
(tc) =>
|
||||
!tc ||
|
||||
!Object.keys(
|
||||
dbSchema?.schema?.[fk.targetTable?.split('.')?.[0] ?? '']?.[
|
||||
fk.targetTable?.split('.')[1]
|
||||
] ?? {}
|
||||
).includes(tc)
|
||||
)
|
||||
}))
|
||||
const someFkErr = fkErrs.some(
|
||||
(fkErr) =>
|
||||
fkErr.emptyTarget ||
|
||||
fkErr.nonExistingSourceColumns.length ||
|
||||
fkErr.nonExistingTargetColumns.length
|
||||
)
|
||||
|
||||
const errs = {
|
||||
name: !values.name.length,
|
||||
columns: columnNamesErrs.length ? columnNamesErrs : undefined,
|
||||
foreignKeys: someFkErr ? fkErrs : undefined
|
||||
}
|
||||
if (Object.values(errs).every((v) => !v)) return undefined
|
||||
return errs
|
||||
}
|
||||
|
||||
export type DBTableEditorProps = {
|
||||
onConfirm: (values: CreateTableValues) => void | Promise<void>
|
||||
previewSql?: (values: CreateTableValues) => string
|
||||
resourceType: DbType
|
||||
dbSchema?: DBSchema
|
||||
currentSchema?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ArrowRight, ClipboardCopy, Info, Plus, Settings, X } from 'lucide-svelte'
|
||||
|
||||
import { Button } from './common'
|
||||
import { Cell } from './table'
|
||||
import DataTable from './table/DataTable.svelte'
|
||||
import Head from './table/Head.svelte'
|
||||
import {
|
||||
datatypeHasLength,
|
||||
dbSupportsSchemas,
|
||||
type DbType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import { DB_TYPES } from '$lib/consts'
|
||||
import Select from './apps/svelte-select/lib/Select.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Tooltip from './meltComponents/Tooltip.svelte'
|
||||
import {
|
||||
datatypeDefaultLength,
|
||||
type CreateTableValues
|
||||
} from './apps/components/display/dbtable/queries/createTable'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
const { onConfirm, resourceType, previewSql, dbSchema, currentSchema }: DBTableEditorProps =
|
||||
$props()
|
||||
|
||||
const columnTypes = DB_TYPES[resourceType]
|
||||
const defaultColumnType = (
|
||||
{
|
||||
postgresql: 'VARCHAR',
|
||||
snowflake: 'varchar',
|
||||
ms_sql_server: 'varchar',
|
||||
bigquery: 'string',
|
||||
mysql: 'varchar'
|
||||
} satisfies Record<DbType, string>
|
||||
)[resourceType]
|
||||
|
||||
const values: CreateTableValues = $state({
|
||||
name: '',
|
||||
columns: [],
|
||||
foreignKeys: []
|
||||
})
|
||||
|
||||
function addColumn({ name, primaryKey }: { name: string; primaryKey?: boolean }) {
|
||||
values.columns.push({
|
||||
name,
|
||||
datatype: defaultColumnType,
|
||||
...(datatypeHasLength(defaultColumnType) && {
|
||||
datatype_length: datatypeDefaultLength(defaultColumnType)
|
||||
}),
|
||||
...(primaryKey && { primaryKey })
|
||||
})
|
||||
}
|
||||
addColumn({ name: 'id', primaryKey: true })
|
||||
|
||||
const errors: ReturnType<typeof validate> = $derived(validate(values, dbSchema))
|
||||
|
||||
let askingForConfirmation:
|
||||
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void; codeContent?: string })
|
||||
| undefined = $state()
|
||||
|
||||
let darkMode = $state(false)
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-6">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
placeholder="my_table"
|
||||
class={errors?.name ? 'border !border-red-600/60' : ''}
|
||||
bind:value={values.name}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>Columns</label>
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Type</Cell>
|
||||
<Cell head last>Primary</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each values.columns as column, i}
|
||||
<tr>
|
||||
<Cell first>
|
||||
<input
|
||||
type="text"
|
||||
class={'h-10 ' +
|
||||
(errors?.columns?.includes(column.name) ? 'border !border-red-600/60' : '')}
|
||||
style="height: 2rem;"
|
||||
placeholder="column_name"
|
||||
bind:value={column.name}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class="!w-48"
|
||||
value={column.datatype}
|
||||
on:change={(e) => {
|
||||
column.datatype = e.detail.value
|
||||
if (datatypeHasLength(column.datatype)) {
|
||||
column.datatype_length = datatypeDefaultLength(column.datatype)
|
||||
} else {
|
||||
column.datatype_length = undefined
|
||||
}
|
||||
}}
|
||||
items={columnTypes}
|
||||
clearable={false}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell last class="flex items-center mt-1.5">
|
||||
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.primaryKey} />
|
||||
<Popover class="ml-8" contentClasses="py-3 px-5 flex flex-col gap-6">
|
||||
{#snippet trigger()}
|
||||
<Settings size={18} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{#if datatypeHasLength(column.datatype)}
|
||||
<label class="text-xs">
|
||||
Length
|
||||
<input type="number" placeholder="0" bind:value={column.datatype_length} />
|
||||
</label>
|
||||
{/if}
|
||||
<label class="text-xs">
|
||||
<span class="flex gap-1 mb-1">
|
||||
Default value
|
||||
<Tooltip>
|
||||
<Info size={14} />
|
||||
{#snippet text()}
|
||||
Surround your expressions with curly brackets:
|
||||
<code>
|
||||
{'{NOW()}'}
|
||||
</code>.
|
||||
<br />
|
||||
By default, it will be parsed as a literal
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</span>
|
||||
<input type="text" placeholder="NULL" bind:value={column.defaultValue} />
|
||||
</label>
|
||||
{#if !column.primaryKey}
|
||||
<label class="flex gap-2 items-center text-xs">
|
||||
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.not_null} />
|
||||
Not nullable
|
||||
</label>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
color="light"
|
||||
startIcon={{ icon: X }}
|
||||
wrapperClasses="w-fit ml-2"
|
||||
btnClasses="p-0"
|
||||
on:click={() => values.columns.splice(i, 1)}
|
||||
/>
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr class="w-full">
|
||||
<td colspan={99} class="p-1">
|
||||
<Button
|
||||
wrapperClasses="mx-auto"
|
||||
startIcon={{ icon: Plus }}
|
||||
color="light"
|
||||
on:click={() => addColumn({ name: '' })}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>Foreign Keys</label>
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Table</Cell>
|
||||
<Cell head last>Columns</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each values.foreignKeys as foreignKey, foreignKeyIndex}
|
||||
{@const fkErrors = errors?.foreignKeys?.[foreignKeyIndex]}
|
||||
<tr>
|
||||
<Cell first class="flex">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge('!w-48', fkErrors?.emptyTarget ? 'border !border-red-600/60' : '')}
|
||||
placeholder=""
|
||||
value={foreignKey.targetTable}
|
||||
on:change={(e) => (foreignKey.targetTable = e.detail.value)}
|
||||
items={getFlatTableNamesFromSchema(dbSchema).map((o) => ({
|
||||
value: o,
|
||||
label:
|
||||
(currentSchema && o.startsWith(currentSchema)) ||
|
||||
!dbSupportsSchemas(resourceType)
|
||||
? o.split('.')[1]
|
||||
: o
|
||||
}))}
|
||||
clearable={false}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each foreignKey.columns as column, columnIndex}
|
||||
<div class="flex">
|
||||
<div class="flex items-center gap-1 w-60">
|
||||
<!-- Div wrappers with absolute select are to prevent the Select content
|
||||
from overflowing -->
|
||||
<div class="grow h-[2rem] relative">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge(
|
||||
'!absolute inset-0',
|
||||
fkErrors?.nonExistingSourceColumns.includes(column.sourceColumn)
|
||||
? 'border !border-red-600/60'
|
||||
: ''
|
||||
)}
|
||||
placeholder=""
|
||||
value={column.sourceColumn}
|
||||
on:change={(e) => (column.sourceColumn = e.detail.value)}
|
||||
items={values.columns.map((c) => c.name)}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
<ArrowRight size={16} class="h-fit shrink-0" />
|
||||
<div class="grow h-[2rem] relative">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge(
|
||||
'!absolute inset-0',
|
||||
fkErrors?.nonExistingTargetColumns.includes(column.targetColumn)
|
||||
? 'border !border-red-600/60'
|
||||
: ''
|
||||
)}
|
||||
placeholder=""
|
||||
value={column.targetColumn}
|
||||
on:change={(e) => (column.targetColumn = e.detail.value)}
|
||||
items={Object.keys(
|
||||
dbSchema?.schema?.[foreignKey.targetTable?.split('.')?.[0] ?? '']?.[
|
||||
foreignKey.targetTable?.split('.')[1]
|
||||
] ?? {}
|
||||
)}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex">
|
||||
{#if columnIndex === 0}
|
||||
<Popover contentClasses="py-3 px-5 w-52 flex flex-col gap-6">
|
||||
{#snippet trigger()}
|
||||
<Settings size={18} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<span>
|
||||
ON DELETE <select bind:value={foreignKey.onDelete}>
|
||||
<option value="NO ACTION" selected>NO ACTION</option>
|
||||
<option value="CASCADE" selected>CASCADE</option>
|
||||
<option value="SET NULL" selected>SET NULL</option>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
ON UPDATE <select bind:value={foreignKey.onUpdate}>
|
||||
<option value="NO ACTION" selected>NO ACTION</option>
|
||||
<option value="CASCADE" selected>CASCADE</option>
|
||||
<option value="SET NULL" selected>SET NULL</option>
|
||||
</select>
|
||||
</span>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
<Button
|
||||
color="light"
|
||||
startIcon={{ icon: X }}
|
||||
wrapperClasses="w-fit ml-2"
|
||||
btnClasses="p-0"
|
||||
on:click={foreignKey.columns.length > 1
|
||||
? () => foreignKey.columns.splice(columnIndex, 1)
|
||||
: () => values.foreignKeys.splice(foreignKeyIndex, 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
class="w-60 border-dashed dark:border-gray-600 border-2 rounded-md flex justify-center items-center py-1 gap-2 text-primary-500 font-normal"
|
||||
onclick={() => foreignKey.columns.push({})}
|
||||
>
|
||||
<Plus class="h-fit" size={12} /> Add
|
||||
</button>
|
||||
</div></Cell
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr class="w-full">
|
||||
<td colspan={99} class="p-1">
|
||||
<Button
|
||||
wrapperClasses="mx-auto"
|
||||
startIcon={{ icon: Plus }}
|
||||
color="light"
|
||||
on:click={() =>
|
||||
values.foreignKeys.push({
|
||||
columns: [{}],
|
||||
onDelete: 'NO ACTION',
|
||||
onUpdate: 'NO ACTION'
|
||||
})}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!!errors}
|
||||
on:click={() =>
|
||||
(askingForConfirmation = {
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
askingForConfirmation && (askingForConfirmation.loading = true)
|
||||
await onConfirm(values)
|
||||
sendUserToast(values.name + ' created!')
|
||||
} catch (e) {
|
||||
let msg: string | undefined = (e as Error)?.message
|
||||
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
|
||||
sendUserToast(msg, true)
|
||||
}
|
||||
askingForConfirmation = undefined
|
||||
},
|
||||
title: 'Confirm running the following:',
|
||||
confirmationText: 'Create ' + values.name,
|
||||
open: true,
|
||||
...(previewSql && { codeContent: previewSql(values) })
|
||||
})}>Create table</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
|
||||
on:canceled={() => (askingForConfirmation = undefined)}
|
||||
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
|
||||
>
|
||||
{#if askingForConfirmation?.codeContent}
|
||||
<div class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative">
|
||||
<code class="whitespace-pre-wrap">
|
||||
{askingForConfirmation.codeContent}
|
||||
</code>
|
||||
<Button
|
||||
on:click={() => copyToClipboard(askingForConfirmation?.codeContent)}
|
||||
size="xs"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
color="none"
|
||||
wrapperClasses="absolute z-10 top-0 right-0"
|
||||
></Button>
|
||||
</div>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import JobStatus from '$lib/components/JobStatus.svelte'
|
||||
import { displayDate, truncateRev } from '$lib/utils'
|
||||
import ScheduleEditor from './ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import TimeAgo from './TimeAgo.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { ListOrdered, PenBox } from 'lucide-svelte'
|
||||
import JobArgs from './JobArgs.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import ScheduleEditor from './ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import { type Schedule } from '$lib/gen'
|
||||
|
||||
export let schedule: any
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
|
||||
|
||||
import { Button } from './common'
|
||||
import DBSchemaExplorer from './DBSchemaExplorer.svelte'
|
||||
import DBManagerDrawerButton from './DBManagerDrawerButton.svelte'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import { Pen, Plus, RotateCw } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isDbType } from './apps/components/display/dbtable/utils'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -221,7 +222,7 @@
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{#if showSchemaExplorer}
|
||||
<DBSchemaExplorer {resourceType} resourcePath={value} />
|
||||
{#if showSchemaExplorer && isDbType(resourceType) && value}
|
||||
<DBManagerDrawerButton {resourceType} resourcePath={value} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import ScheduleEditor from './ScheduleEditor.svelte'
|
||||
import { Button } from './common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { ScheduleService, type Schedule } from '$lib/gen'
|
||||
@@ -15,6 +14,7 @@
|
||||
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
|
||||
import { type Writable, writable } from 'svelte/store'
|
||||
import Description from '$lib/components/Description.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
|
||||
export let schema: any
|
||||
export let isFlow: boolean
|
||||
|
||||
94
frontend/src/lib/components/SimpleAgTable.svelte
Normal file
94
frontend/src/lib/components/SimpleAgTable.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createGrid, type GridApi } from 'ag-grid-community'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
type Props = {
|
||||
data: Record<string, any>[]
|
||||
class?: string
|
||||
}
|
||||
let { data, class: className }: Props = $props()
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
|
||||
$effect(() => eGui && mountGrid())
|
||||
function mountGrid() {
|
||||
if (eGui) {
|
||||
createGrid(eGui, {
|
||||
onGridReady: (e) => {
|
||||
api = e.api
|
||||
},
|
||||
autoSizeStrategy: { type: 'fitCellContents' },
|
||||
...updateGridOptions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let columnDefs = $derived.by(() => {
|
||||
if (!data.length) return []
|
||||
const defs: { field: string }[] = []
|
||||
for (const row of data) {
|
||||
for (const key in row) {
|
||||
if (!defs.some((def) => def.field === key)) {
|
||||
defs.push({ field: key })
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
})
|
||||
|
||||
let updateGridOptions = $derived({
|
||||
rowData: data.map((row) => {
|
||||
const newRow: Record<string, any> = {}
|
||||
for (const key in row) {
|
||||
newRow[key] = typeof row[key] === 'string' ? row[key].trim() : JSON.stringify(row[key])
|
||||
}
|
||||
return newRow
|
||||
}),
|
||||
columnDefs
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
api?.updateGridOptions(updateGridOptions)
|
||||
})
|
||||
|
||||
let darkMode = $state(false)
|
||||
let [clientHeight, clientWidth] = $state([0, 0])
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div
|
||||
class={'flex flex-col h-full component-wrapper divide-y wm-aggrid-container ' +
|
||||
(api ? '' : 'opacity-0 ') +
|
||||
className}
|
||||
bind:clientHeight
|
||||
bind:clientWidth
|
||||
>
|
||||
<div
|
||||
style:height="{clientHeight}px"
|
||||
style:width="{clientWidth}px"
|
||||
class="ag-theme-alpine"
|
||||
class:ag-theme-alpine-dark={darkMode}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={eGui}
|
||||
style:height="100%"
|
||||
onkeydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selectedCell = api?.getFocusedCell()
|
||||
if (selectedCell) {
|
||||
const rowIndex = selectedCell.rowIndex
|
||||
const colId = selectedCell.column?.getId()
|
||||
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
|
||||
const selectedValue = rowNode?.data?.[colId]
|
||||
navigator.clipboard.writeText(selectedValue)
|
||||
sendUserToast('Copied cell value to clipboard', false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
178
frontend/src/lib/components/SqlRepl.svelte
Normal file
178
frontend/src/lib/components/SqlRepl.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script module lang="ts">
|
||||
// code may be composed of many sql statements separated by ';'
|
||||
// this function splits them while taking into account that ';' may not
|
||||
// be the end of a statement (string or escaped)
|
||||
function splitSqlStatements(code: string) {
|
||||
const statements: string[] = []
|
||||
let currentStatement = ''
|
||||
let inSingleQuote = false
|
||||
let inDoubleQuote = false
|
||||
let inBacktick = false
|
||||
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const char = code[i]
|
||||
const prevChar = i > 0 ? code[i - 1] : null
|
||||
|
||||
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
|
||||
inSingleQuote = !inSingleQuote
|
||||
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
|
||||
inBacktick = !inBacktick
|
||||
}
|
||||
|
||||
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
|
||||
statements.push(currentStatement.trim())
|
||||
currentStatement = ''
|
||||
} else {
|
||||
currentStatement += char
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStatement.trim()) {
|
||||
statements.push(currentStatement.trim())
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { untrack } from 'svelte'
|
||||
import { getLanguageByResourceType } from './apps/components/display/dbtable/utils'
|
||||
import StepHistory, { type StepHistoryData } from './flows/propPicker/StepHistory.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { _ } from 'ag-grid-community'
|
||||
|
||||
type Props = {
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
onData: (data: Record<string, any>[]) => void
|
||||
placeholderTableName?: string
|
||||
}
|
||||
let { resourcePath, resourceType, onData, placeholderTableName }: Props = $props()
|
||||
|
||||
const DEFAULT_SQL = 'SELECT * FROM _'
|
||||
let code = $state(DEFAULT_SQL)
|
||||
$effect(() => {
|
||||
const _code = untrack(() => code)
|
||||
if (placeholderTableName && _code === DEFAULT_SQL) {
|
||||
code = _code.replace('_', placeholderTableName ?? 'table')
|
||||
}
|
||||
})
|
||||
let isRunning = $state(false)
|
||||
|
||||
let runHistory: (StepHistoryData & { code: string; result: Record<string, any>[] })[] = $state([])
|
||||
|
||||
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
|
||||
if (isRunning || !$workspaceStore) return
|
||||
isRunning = true
|
||||
try {
|
||||
const statements = splitSqlStatements(code)
|
||||
|
||||
// Transform all to JSON in case of select. This fixes the issue of
|
||||
// custom postgres enum type failing to convert to a rust type in the backend.
|
||||
// We don't always put the fix by default for row ordering concerns
|
||||
let transformedCode = code
|
||||
if (doPostgresRowToJsonFix) {
|
||||
transformedCode = statements
|
||||
.map((statement) => {
|
||||
if (statement.trim().toUpperCase().startsWith('SELECT')) {
|
||||
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
|
||||
}
|
||||
return statement
|
||||
})
|
||||
.join(';')
|
||||
}
|
||||
|
||||
let { job, result } = (await runPreviewJobAndPollResult(
|
||||
{
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: transformedCode,
|
||||
args: {
|
||||
database: '$res:' + resourcePath
|
||||
}
|
||||
}
|
||||
},
|
||||
{ withJobData: true }
|
||||
)) as any
|
||||
if (statements.length > 1) {
|
||||
result = result[result.length - 1]
|
||||
}
|
||||
if (!Array.isArray(result)) {
|
||||
sendUserToast('Query result is not an array', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (doPostgresRowToJsonFix) {
|
||||
result = result.map((row: any) => row['row_to_json'])
|
||||
}
|
||||
|
||||
if (statements[statements.length - 1].toUpperCase().trim().startsWith('SELECT')) {
|
||||
runHistory.push({
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: '',
|
||||
id: job.id,
|
||||
success: true,
|
||||
code,
|
||||
result
|
||||
})
|
||||
onData(result)
|
||||
}
|
||||
if (doPostgresRowToJsonFix)
|
||||
sendUserToast('Query failed but recovered with the row_to_json fix')
|
||||
else sendUserToast('Query executed')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (resourceType === 'postgresql' && !doPostgresRowToJsonFix) {
|
||||
console.error('Error running query, trying with row_to_json fix')
|
||||
isRunning = false
|
||||
return await run({ doPostgresRowToJsonFix: true })
|
||||
}
|
||||
sendUserToast('Error running query: ' + (e.message ?? e.error.message), true)
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
let editor = $state<Editor | null>(null)
|
||||
</script>
|
||||
|
||||
<Splitpanes>
|
||||
<Pane class="relative">
|
||||
<Editor
|
||||
bind:this={editor}
|
||||
bind:code
|
||||
lang="sql"
|
||||
scriptLang="mysql"
|
||||
class="w-full h-full"
|
||||
cmdEnterAction={run}
|
||||
/>
|
||||
<Button
|
||||
wrapperClasses="absolute z-10 bottom-2 right-6"
|
||||
color={isRunning ? 'red' : undefined}
|
||||
variant="border"
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
on:click={() => run()}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</Pane>
|
||||
<Pane size={24} minSize={16}>
|
||||
<StepHistory
|
||||
staticInputs={runHistory}
|
||||
on:select={(e) => {
|
||||
const data = e.detail as (typeof runHistory)[number]
|
||||
editor?.setCode(data.code)
|
||||
onData(data.result)
|
||||
}}
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
@@ -44,9 +44,10 @@
|
||||
<Select
|
||||
{disabled}
|
||||
placeholder={nullTag ? `default: ${nullTag}` : 'lang default'}
|
||||
containerStyles={'--height: 30px !important; --input-padding: 0px !important;' + darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
containerStyles={'--height: 30px !important; --input-padding: 0px !important; ' +
|
||||
(darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles)}
|
||||
{items}
|
||||
value={tag ? { value: tag, label: tag } : undefined}
|
||||
on:clear={() => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent, Button } from '$lib/components/common'
|
||||
import { CornerDownLeft, Plus } from 'lucide-svelte'
|
||||
import InsertRow from './dbtable/InsertRow.svelte'
|
||||
import type { ColumnDef, DbType } from './dbtable/utils'
|
||||
|
||||
type Props = {
|
||||
columnDefs: ColumnDef[]
|
||||
dbType: DbType
|
||||
onInsert: (args: Record<string, any>) => void | Promise<void>
|
||||
}
|
||||
|
||||
let { columnDefs, dbType, onInsert }: Props = $props()
|
||||
|
||||
let args: Record<string, any> = $state({})
|
||||
let insertDrawer: Drawer | undefined = $state()
|
||||
let isInsertable = $state(false)
|
||||
|
||||
const onConfirm = async () => {
|
||||
await onInsert(args)
|
||||
insertDrawer?.closeDrawer()
|
||||
args = {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onConfirm()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
startIcon={{ icon: Plus }}
|
||||
variant="border"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
args = {}
|
||||
insertDrawer?.openDrawer()
|
||||
}}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
|
||||
<Drawer bind:this={insertDrawer} size="800px">
|
||||
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
color="dark"
|
||||
size="xs"
|
||||
on:click={onConfirm}
|
||||
disabled={!isInsertable}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<InsertRow bind:args bind:isInsertable {columnDefs} {dbType} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -21,11 +21,7 @@
|
||||
import { getContext, tick } from 'svelte'
|
||||
import UpdateCell from './UpdateCell.svelte'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import InsertRow from './InsertRow.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { Drawer } from '$lib/components/common'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { AppInput, StaticInput } from '$lib/components/apps/inputType'
|
||||
@@ -41,6 +37,7 @@
|
||||
import { CancelablePromise } from '$lib/gen'
|
||||
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
|
||||
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
|
||||
import InsertRowDrawerButton from '../InsertRowDrawerButton.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -173,8 +170,6 @@
|
||||
)
|
||||
}
|
||||
|
||||
let args: Record<string, any> = {}
|
||||
|
||||
let outputs = initOutput($worldStore, id, {
|
||||
selectedRowIndex: 0,
|
||||
selectedRow: {},
|
||||
@@ -189,7 +184,7 @@
|
||||
|
||||
let lastResource: string | undefined = undefined
|
||||
|
||||
function updateOneOfConfiguration<T, U extends string, V>(
|
||||
function updateOneOfConfiguration<U extends string, V>(
|
||||
oneOfConfiguration: OneOfConfiguration,
|
||||
resolvedConfig: {
|
||||
configuration: Record<U, V>
|
||||
@@ -249,7 +244,7 @@
|
||||
resolvedConfig.type.configuration[resolvedConfig?.type?.selected].resource.split(':')[1],
|
||||
$workspaceStore,
|
||||
dbSchemas,
|
||||
(message: string) => {}
|
||||
() => {}
|
||||
)
|
||||
|
||||
updateOneOfConfiguration(
|
||||
@@ -263,7 +258,7 @@
|
||||
resolvedConfig?.type?.selected,
|
||||
resource.split(':')[1],
|
||||
$workspaceStore!
|
||||
)
|
||||
)
|
||||
: [],
|
||||
loading: false
|
||||
}
|
||||
@@ -479,8 +474,6 @@
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
let isInsertable: boolean = false
|
||||
|
||||
$: $worldStore && render && connectToComponents()
|
||||
|
||||
function connectToComponents() {
|
||||
@@ -521,7 +514,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function insert() {
|
||||
async function insert(args: object) {
|
||||
try {
|
||||
const selected = resolvedConfig.type.selected
|
||||
|
||||
@@ -539,8 +532,6 @@
|
||||
} catch (e) {
|
||||
sendUserToast(e.message, true)
|
||||
}
|
||||
|
||||
args = {}
|
||||
}
|
||||
|
||||
let runnableComponent: RunnableComponent
|
||||
@@ -651,17 +642,11 @@
|
||||
<div class="flex flex-row gap-2">
|
||||
<RefreshButton {id} {loading} />
|
||||
{#if hideInsert !== true}
|
||||
<Button
|
||||
startIcon={{ icon: Plus }}
|
||||
color="dark"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
args = {}
|
||||
insertDrawer?.openDrawer()
|
||||
}}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
<InsertRowDrawerButton
|
||||
columnDefs={resolvedConfig.columnDefs}
|
||||
dbType={resolvedConfig.type.selected}
|
||||
onInsert={(args) => insert(args)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,7 +681,7 @@
|
||||
</div>
|
||||
</RunnableWrapper>
|
||||
|
||||
<Portal name="db-explorer">
|
||||
<!-- <Portal name="db-explorer">
|
||||
<Drawer bind:this={insertDrawer} size="800px">
|
||||
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
@@ -710,4 +695,4 @@
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Portal>
|
||||
</Portal> -->
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { buildParameters, type DbType } from '../utils'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils'
|
||||
|
||||
function makeCountQuery(
|
||||
export function makeCountQuery(
|
||||
dbType: DbType,
|
||||
table: string,
|
||||
whereClause: string | undefined = undefined,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { dbSupportsSchemas, type DbType } from '../utils'
|
||||
|
||||
export type CreateTableValues = {
|
||||
name: string
|
||||
columns: CreateTableValuesColumn[]
|
||||
foreignKeys: {
|
||||
targetTable?: string
|
||||
columns: {
|
||||
sourceColumn?: string
|
||||
targetColumn?: string
|
||||
}[]
|
||||
onDelete: 'CASCADE' | 'SET NULL' | 'NO ACTION'
|
||||
onUpdate: 'CASCADE' | 'SET NULL' | 'NO ACTION'
|
||||
}[]
|
||||
}
|
||||
|
||||
type CreateTableValuesColumn = {
|
||||
name: string
|
||||
datatype: string
|
||||
primaryKey?: boolean
|
||||
defaultValue?: string
|
||||
not_null?: boolean
|
||||
datatype_length?: number // e.g varchar(255)
|
||||
}
|
||||
|
||||
export function makeCreateTableQuery(
|
||||
values: CreateTableValues,
|
||||
resourceType: DbType,
|
||||
schema?: string
|
||||
) {
|
||||
const pkCount = values.columns.reduce((p, c) => p + (c.primaryKey ? 1 : 0), 0)
|
||||
|
||||
function transformColumn(c: CreateTableValuesColumn): string {
|
||||
const datatype = c.datatype_length ? `${c.datatype}(${c.datatype_length})` : c.datatype
|
||||
const defValue = c.defaultValue && formatDefaultValue(c.defaultValue, datatype, resourceType)
|
||||
|
||||
let str = ` ${c.name} ${datatype}`
|
||||
if (c.not_null) str += ' NOT NULL'
|
||||
if (defValue) str += ` DEFAULT ${defValue}`
|
||||
if (pkCount === 1 && c.primaryKey) str += ' PRIMARY KEY'
|
||||
return str
|
||||
}
|
||||
|
||||
function transformFk(fk: CreateTableValues['foreignKeys'][number]): string {
|
||||
const sourceColumns = fk.columns.map((c) => c.sourceColumn).filter(Boolean)
|
||||
const targetColumns = fk.columns.map((c) => c.targetColumn).filter(Boolean)
|
||||
const targetTable =
|
||||
useSchema || !fk.targetTable?.includes('.')
|
||||
? fk.targetTable
|
||||
: fk.targetTable?.split('.').pop()
|
||||
|
||||
let l = ` FOREIGN KEY (${sourceColumns.join(', ')}) REFERENCES ${targetTable} (${targetColumns.join(
|
||||
', '
|
||||
)})`
|
||||
if (fk.onDelete !== 'NO ACTION') l += ` ON DELETE ${fk.onDelete}`
|
||||
if (fk.onUpdate !== 'NO ACTION') l += ` ON UPDATE ${fk.onUpdate}`
|
||||
return l
|
||||
}
|
||||
|
||||
const useSchema = dbSupportsSchemas(resourceType)
|
||||
|
||||
const lines = values.columns.map(transformColumn)
|
||||
lines.push(...values.foreignKeys.map(transformFk))
|
||||
if (pkCount > 1) {
|
||||
const pks = values.columns.filter((c) => c.primaryKey)
|
||||
lines.push(` PRIMARY KEY (${pks.map((c) => c.name).join(', ')})`)
|
||||
}
|
||||
|
||||
return `CREATE TABLE ${useSchema && schema ? schema.trim() + '.' : ''}${values.name.trim()} (
|
||||
${lines.join(',\n')}
|
||||
);`
|
||||
}
|
||||
|
||||
function formatDefaultValue(str: string, datatype: string, resourceType: DbType): string {
|
||||
if (!str) return ''
|
||||
if (str.startsWith('{') && str.endsWith('}')) {
|
||||
return str.slice(1, str.length - 1)
|
||||
}
|
||||
if (resourceType === 'postgresql') {
|
||||
return `CAST('${str}' AS ${datatype})`
|
||||
}
|
||||
return `'${str}'`
|
||||
}
|
||||
|
||||
export function datatypeDefaultLength(datatype: string): number {
|
||||
datatype = datatype.toLowerCase()
|
||||
if (datatype == 'bit') return 1
|
||||
if (['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary'].includes(datatype)) {
|
||||
return 255
|
||||
} else {
|
||||
return 10
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
|
||||
|
||||
function deleteWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) {
|
||||
export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbType) {
|
||||
let query = buildParameters(
|
||||
dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns,
|
||||
dbType
|
||||
@@ -71,7 +71,7 @@ export function getDeleteInput(
|
||||
name: 'AppDbExplorer',
|
||||
type: 'runnableByName',
|
||||
inlineScript: {
|
||||
content: deleteWithAllValues(table, columns, dbType),
|
||||
content: makeDeleteQuery(table, columns, dbType),
|
||||
language: getLanguageByResourceType(dbType),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DbType } from '../utils'
|
||||
|
||||
export function makeDeleteTableQuery(tableKey: string, resourceType: DbType): string {
|
||||
// same for all sql dbs
|
||||
return `DROP TABLE ${tableKey};`
|
||||
}
|
||||
@@ -79,7 +79,7 @@ function makeSnowflakeSelectQuery(
|
||||
return query
|
||||
}
|
||||
|
||||
function makeSelectQuery(
|
||||
export function makeSelectQuery(
|
||||
table: string,
|
||||
columnDefs: ColumnDef[],
|
||||
whereClause: string | undefined,
|
||||
@@ -155,6 +155,9 @@ CASE WHEN :order_by = '${column.field}' AND :is_desc IS true THEN \`${column.fie
|
||||
case 'ms_sql_server':
|
||||
// MSSQL uses CONCAT for string concatenation and supports OFFSET FETCH for pagination
|
||||
// Note: MSSQL does not have a built-in ILIKE function, so we use LIKE with a case-insensitive collation if needed
|
||||
//
|
||||
// Note 2: CONCAT in mssql requires 2 to 254 arguments. But we can't change this query without breaking
|
||||
// existing policies
|
||||
const orderBy = columnDefs
|
||||
.map((column) => {
|
||||
return `
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
|
||||
|
||||
function updateWithAllValues(
|
||||
export function makeUpdateQuery(
|
||||
table: string,
|
||||
column: ColumnDef,
|
||||
columns: ColumnDef[],
|
||||
column: { datatype: string; field: string },
|
||||
columns: { datatype: string; field: string }[],
|
||||
dbType: DbType
|
||||
) {
|
||||
let query = buildParameters(
|
||||
[
|
||||
{
|
||||
field: 'value_to_update',
|
||||
datatype: column.datatype
|
||||
},
|
||||
{ field: 'value_to_update', datatype: column.datatype },
|
||||
...(dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns)
|
||||
],
|
||||
dbType
|
||||
@@ -85,7 +82,7 @@ export function getUpdateInput(
|
||||
name: 'AppDbExplorer',
|
||||
type: 'runnableByName',
|
||||
inlineScript: {
|
||||
content: updateWithAllValues(table, column, columns, dbType),
|
||||
content: makeUpdateQuery(table, column, columns, dbType),
|
||||
language: getLanguageByResourceType(dbType),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'graphql'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
import { stringifySchema } from '$lib/components/copilot/lib'
|
||||
import { runPreviewJobAndPollResult } from '$lib/components/jobs/utils'
|
||||
|
||||
export enum ColumnIdentity {
|
||||
ByDefault = 'By Default',
|
||||
@@ -61,15 +62,99 @@ export async function loadTableMetaData(
|
||||
if (!resource || !table || !workspace) {
|
||||
return undefined
|
||||
}
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: await makeLoadTableMetaDataQuery(resource, workspace, table, resourceType),
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let code: string = ''
|
||||
const maxRetries = 8
|
||||
let attempts = 0
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6)))
|
||||
|
||||
const testResult = (await JobService.getCompletedJob({
|
||||
workspace: workspace,
|
||||
id: job
|
||||
})) as any
|
||||
|
||||
if (testResult.success) {
|
||||
attempts = maxRetries
|
||||
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
return testResult.result[0].map(lowercaseKeys)
|
||||
} else {
|
||||
return testResult.result.map(lowercaseKeys)
|
||||
}
|
||||
} else {
|
||||
attempts++
|
||||
}
|
||||
} catch (error) {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Failed to load table metadata after maximum retries.')
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadAllTablesMetaData(
|
||||
resource: string,
|
||||
workspace: string | undefined,
|
||||
resourceType: string
|
||||
): Promise<Record<string, TableMetadata> | undefined> {
|
||||
if (!resource || !workspace) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
let result = (await runPreviewJobAndPollResult({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: await makeLoadTableMetaDataQuery(resource, workspace, undefined, resourceType),
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})) as ({ table_name: string; schema_name?: string } & object)[]
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
result = (result as any)[0]
|
||||
}
|
||||
const map: Record<string, TableMetadata> = {}
|
||||
|
||||
for (const _col of result) {
|
||||
const col = lowercaseKeys(_col)
|
||||
const tableKey = col.schema_name ? `${col.schema_name}.${col.table_name}` : col.table_name
|
||||
if (!(tableKey in map)) {
|
||||
map[tableKey] = []
|
||||
}
|
||||
map[tableKey].push(col)
|
||||
}
|
||||
return map
|
||||
} catch (e) {
|
||||
throw new Error('Error loading all tables metadata: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeLoadTableMetaDataQuery(
|
||||
resource: string,
|
||||
workspace: string,
|
||||
table: string | undefined,
|
||||
resourceType: string
|
||||
): Promise<string> {
|
||||
if (resourceType === 'mysql') {
|
||||
const resourceObj = (await ResourceService.getResourceValue({
|
||||
workspace,
|
||||
path: resource.split(':')[1]
|
||||
})) as any
|
||||
code = `
|
||||
return `
|
||||
SELECT
|
||||
COLUMN_NAME as field,
|
||||
COLUMN_TYPE as DataType,
|
||||
@@ -77,19 +162,31 @@ export async function loadTableMetaData(
|
||||
CASE WHEN COLUMN_KEY = 'PRI' THEN 'YES' ELSE 'NO' END as IsPrimaryKey,
|
||||
CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity,
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
TABLE_NAME as table_name`
|
||||
}
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
INFORMATION_SCHEMA.COLUMNS${
|
||||
table
|
||||
? `
|
||||
WHERE
|
||||
TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${
|
||||
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
|
||||
}'
|
||||
ORDER BY
|
||||
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
|
||||
}'`
|
||||
: `
|
||||
WHERE
|
||||
TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')`
|
||||
}
|
||||
ORDER BY
|
||||
TABLE_NAME,
|
||||
ORDINAL_POSITION;
|
||||
`
|
||||
} else if (resourceType === 'postgresql') {
|
||||
code = `
|
||||
SELECT
|
||||
return `
|
||||
SELECT
|
||||
a.attname as field,
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType,
|
||||
(SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128)
|
||||
@@ -110,17 +207,32 @@ export async function loadTableMetaData(
|
||||
END as IsNullable,
|
||||
(SELECT true
|
||||
FROM pg_catalog.pg_enum e
|
||||
WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum
|
||||
FROM pg_catalog.pg_attribute a
|
||||
WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
ns.nspname AS schema_name,
|
||||
c.relname AS table_name`
|
||||
}
|
||||
FROM pg_catalog.pg_attribute a${
|
||||
table
|
||||
? `
|
||||
WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${
|
||||
table.split('.').reverse()[0]
|
||||
}' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}')
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum;
|
||||
`
|
||||
: `
|
||||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
||||
WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
|
||||
AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'`
|
||||
}
|
||||
ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'};
|
||||
|
||||
`
|
||||
} else if (resourceType === 'ms_sql_server') {
|
||||
code = `
|
||||
return `
|
||||
SELECT
|
||||
COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
@@ -128,32 +240,52 @@ export async function loadTableMetaData(
|
||||
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity,
|
||||
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as IsPrimaryKey, -- This line still needs correction for primary key identification
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
TABLE_NAME as table_name`
|
||||
}
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
INFORMATION_SCHEMA.COLUMNS${
|
||||
table
|
||||
? `
|
||||
WHERE
|
||||
TABLE_NAME = '${table}'
|
||||
TABLE_NAME = '${table}'`
|
||||
: ''
|
||||
}
|
||||
ORDER BY
|
||||
ORDINAL_POSITION;
|
||||
|
||||
`
|
||||
} else if (resourceType === 'snowflake' || resourceType === 'snowflake_oauth') {
|
||||
code = `
|
||||
return `
|
||||
select COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
COLUMN_DEFAULT as DefaultValue,
|
||||
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity,
|
||||
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 1 ELSE 0 END as IsPrimaryKey,
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
|
||||
from information_schema.columns
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
table_name as table_name,
|
||||
table_schema as schema_name`
|
||||
}
|
||||
from information_schema.columns${
|
||||
table
|
||||
? `
|
||||
where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${
|
||||
table.split('.').reverse()[1] ?? 'PUBLIC'
|
||||
}'
|
||||
table.split('.').reverse()[1] ?? 'PUBLIC'
|
||||
}'`
|
||||
: "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n"
|
||||
}
|
||||
order by ORDINAL_POSITION;
|
||||
`
|
||||
} else if (resourceType === 'bigquery') {
|
||||
code = `SELECT
|
||||
// TODO: find a solution for this (query uses hardcoded dataset name)
|
||||
if (!table) throw new Error('Table name is required for BigQuery')
|
||||
return `SELECT
|
||||
c.COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
|
||||
@@ -164,58 +296,14 @@ ORDER BY
|
||||
FROM
|
||||
${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c
|
||||
LEFT JOIN
|
||||
test_dataset.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
|
||||
${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
|
||||
on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME
|
||||
WHERE
|
||||
c.TABLE_NAME = "${table.split('.')[1]}"
|
||||
c.TABLE_NAME = '${table.split('.')[1]}'
|
||||
order by c.ORDINAL_POSITION;`
|
||||
} else {
|
||||
throw new Error('Unsupported database type:' + resourceType)
|
||||
}
|
||||
|
||||
const maxRetries = 3
|
||||
let attempts = 0
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: code,
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const testResult = (await JobService.getCompletedJob({
|
||||
workspace: workspace,
|
||||
id: job
|
||||
})) as any
|
||||
|
||||
if (testResult.success) {
|
||||
attempts = maxRetries
|
||||
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
return lowercaseKeys(testResult.result[0])
|
||||
} else {
|
||||
return lowercaseKeys(testResult.result)
|
||||
}
|
||||
} else {
|
||||
attempts++
|
||||
}
|
||||
} catch (error) {
|
||||
attempts++
|
||||
}
|
||||
// Exponential back-off
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts))
|
||||
}
|
||||
|
||||
console.error('Failed to load table metadata after maximum retries.')
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function resourceTypeToLang(rt: string) {
|
||||
@@ -226,14 +314,12 @@ export function resourceTypeToLang(rt: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function lowercaseKeys(arr: Array<Record<string, any>>): Array<any> {
|
||||
return arr.map((obj) => {
|
||||
const newObj = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
newObj[key.toLowerCase()] = obj[key]
|
||||
})
|
||||
return newObj
|
||||
function lowercaseKeys(obj: Record<string, any>): any {
|
||||
const newObj = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
newObj[key.toLowerCase()] = obj[key]
|
||||
})
|
||||
return newObj
|
||||
}
|
||||
|
||||
const scripts: Record<
|
||||
@@ -569,7 +655,9 @@ export function formatGraphqlSchema(schema: IntrospectionQuery): string {
|
||||
return printSchema(buildClientSchema(schema))
|
||||
}
|
||||
|
||||
export type DbType = 'mysql' | 'ms_sql_server' | 'postgresql' | 'snowflake' | 'bigquery'
|
||||
export type DbType = (typeof dbTypes)[number]
|
||||
export const dbTypes = ['mysql', 'ms_sql_server', 'postgresql', 'snowflake', 'bigquery'] as const
|
||||
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
|
||||
|
||||
export function buildVisibleFieldList(columnDefs: ColumnDef[], dbType: DbType) {
|
||||
// Filter out hidden columns to avoid counting the wrong number of rows
|
||||
@@ -714,3 +802,13 @@ export async function getTablesByResource(
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function dbSupportsSchemas(dbType: DbType): boolean {
|
||||
return dbType === 'postgresql' || dbType === 'snowflake' || dbType === 'bigquery'
|
||||
}
|
||||
|
||||
export function datatypeHasLength(datatype: string): boolean {
|
||||
datatype = datatype.toLowerCase()
|
||||
const lengthDataTypes = ['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary', 'bit']
|
||||
return lengthDataTypes.some((type) => datatype === type)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
* Base class for embedding a svelte component within an AGGrid call.
|
||||
* See: https://stackoverflow.com/a/72608215
|
||||
*/
|
||||
import type { ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
|
||||
import type { ColDef, ColGroupDef, ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
|
||||
import { ColumnIdentity, type ColumnDef } from '../dbtable/utils'
|
||||
import type { TableAction } from '$lib/components/apps/editor/component'
|
||||
import { mount, unmount } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
|
||||
/**
|
||||
* Class for defining a cell renderer.
|
||||
@@ -110,3 +115,139 @@ export function defaultCellRenderer(cellRendererType: string) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function transformColumnDefs({
|
||||
columnDefs,
|
||||
actions,
|
||||
customActionsHeader,
|
||||
wrapActions,
|
||||
tableActionsFactory,
|
||||
onDelete,
|
||||
onInvalidColumnDefs
|
||||
}: {
|
||||
columnDefs: ColumnDef[]
|
||||
actions?: TableAction[]
|
||||
customActionsHeader?: string
|
||||
wrapActions?: boolean
|
||||
tableActionsFactory?: ReturnType<typeof cellRendererFactory>
|
||||
onDelete?: (values: object) => void
|
||||
onInvalidColumnDefs?: (errors: string[]) => void
|
||||
}): (ColDef<unknown, any> | ColGroupDef<unknown>)[] {
|
||||
if (!columnDefs) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { isValid, errors } = validateColumnDefs(columnDefs)
|
||||
|
||||
if (!isValid) {
|
||||
onInvalidColumnDefs?.(errors)
|
||||
return []
|
||||
}
|
||||
|
||||
let r: any[] = columnDefs?.filter((x) => x && !x.ignored) ?? []
|
||||
|
||||
if (onDelete) {
|
||||
r.push({
|
||||
field: 'delete',
|
||||
headerName: 'Delete',
|
||||
cellRenderer: cellRendererFactory((c, p) => {
|
||||
const btnComponent = mount(Button, {
|
||||
target: c.eGui,
|
||||
props: {
|
||||
btnClasses: 'w-12',
|
||||
wrapperClasses: 'flex justify-end items-center h-full',
|
||||
color: 'light',
|
||||
size: 'sm',
|
||||
variant: 'contained',
|
||||
iconOnly: true,
|
||||
startIcon: { icon: Trash2 },
|
||||
nonCaptureEvent: true
|
||||
}
|
||||
})
|
||||
return {
|
||||
destroy: () => {
|
||||
unmount(btnComponent)
|
||||
},
|
||||
refresh(params) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}),
|
||||
cellRendererParams: {
|
||||
onClick: (e) => {
|
||||
onDelete?.(e)
|
||||
}
|
||||
},
|
||||
lockPosition: 'right',
|
||||
editable: false,
|
||||
flex: 0,
|
||||
width: 100
|
||||
})
|
||||
}
|
||||
|
||||
if (actions?.length) {
|
||||
r.push({
|
||||
headerName: customActionsHeader ?? 'Actions',
|
||||
cellRenderer: tableActionsFactory,
|
||||
autoHeight: true,
|
||||
cellStyle: { textAlign: 'center' },
|
||||
cellClass: 'grid-cell-centered',
|
||||
lockPosition: 'right',
|
||||
|
||||
...(!wrapActions ? { minWidth: 130 * actions?.length } : {})
|
||||
})
|
||||
}
|
||||
|
||||
return r.map((fields) => {
|
||||
let cr = defaultCellRenderer(fields.cellRendererType)
|
||||
return {
|
||||
...fields,
|
||||
...(cr ? { cellRenderer: cr } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateColumnDefs(columnDefs: ColumnDef[]): {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
let isValid = true
|
||||
const errors: string[] = []
|
||||
|
||||
if (!Array.isArray(columnDefs)) {
|
||||
return { isValid: false, errors: ['Column definitions must be an array.'] }
|
||||
}
|
||||
|
||||
// Validate each column definition
|
||||
columnDefs.forEach((colDef, index) => {
|
||||
let noField = !colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === ''
|
||||
|
||||
if (
|
||||
(colDef.isidentity === ColumnIdentity.ByDefault ||
|
||||
colDef.isidentity === ColumnIdentity.Always) &&
|
||||
colDef.hideInsert == undefined
|
||||
) {
|
||||
colDef.hideInsert = true
|
||||
}
|
||||
|
||||
// Check if 'field' property exists and is a non-empty string
|
||||
if (noField && !(colDef.children && Array.isArray(colDef.children))) {
|
||||
isValid = false
|
||||
errors.push(
|
||||
`Column at index ${index} is missing a valid 'field' property nor having any children.`
|
||||
)
|
||||
}
|
||||
|
||||
if (colDef.children && Array.isArray(colDef.children)) {
|
||||
const { isValid: isChildrenValid, errors: childrenErrors } = validateColumnDefs(
|
||||
colDef.children
|
||||
)
|
||||
if (!isChildrenValid) {
|
||||
isValid = false
|
||||
errors.push(...childrenErrors.map((err) => `Error in children at index ${index}: ${err}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { isValid, errors }
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
export let noBg = false
|
||||
export let small: boolean = false
|
||||
export let Icon: any | undefined = undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
on:click={() => dispatch('close')}
|
||||
startIcon={{ icon: X }}
|
||||
startIcon={{ icon: Icon ?? X }}
|
||||
iconOnly
|
||||
size="sm"
|
||||
color="light"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export let forceOverflowVisible = false
|
||||
export let tooltip: string = ''
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let CloseIcon: any | undefined = undefined
|
||||
|
||||
export let fullScreen: boolean = true
|
||||
</script>
|
||||
@@ -16,7 +17,7 @@
|
||||
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
|
||||
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
|
||||
<div class="flex items-center gap-2 w-full truncate">
|
||||
<CloseButton on:close />
|
||||
<CloseButton on:close Icon={CloseIcon} />
|
||||
|
||||
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
|
||||
>{title ?? ''}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { FlowService, type Flow, DraftService } from '$lib/gen'
|
||||
@@ -204,7 +204,7 @@
|
||||
disabled: archived,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Deployments',
|
||||
@@ -258,7 +258,7 @@
|
||||
disabled: !owner,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
|
||||
@@ -487,6 +487,31 @@ async function callTool(
|
||||
}
|
||||
}
|
||||
|
||||
async function processToolCall(
|
||||
toolCall: ChatCompletionMessageToolCall,
|
||||
messages: ChatCompletionMessageParam[],
|
||||
lang: ScriptLang | 'bunnative'
|
||||
) {
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments)
|
||||
let result = ''
|
||||
try {
|
||||
result = await callTool(toolCall.function.name, args, lang, get(workspaceStore) ?? '')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
result =
|
||||
'Error while calling tool, MUST tell the user to check the browser console for more details, and then respond as much as possible to the original request'
|
||||
}
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: result
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function chatRequest(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
abortController: AbortController,
|
||||
@@ -554,23 +579,7 @@ export async function chatRequest(
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
for (const toolCall of toolCalls) {
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments)
|
||||
const result = await callTool(
|
||||
toolCall.function.name,
|
||||
args,
|
||||
lang,
|
||||
get(workspaceStore) ?? ''
|
||||
)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: result
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw new Error('Error while calling tool')
|
||||
}
|
||||
await processToolCall(toolCall, messages, lang)
|
||||
}
|
||||
} else {
|
||||
break
|
||||
|
||||
@@ -385,7 +385,7 @@ function getProviderAndCompletionConfig<K extends boolean>({
|
||||
: {
|
||||
model: modelProvider.model,
|
||||
temperature: 0,
|
||||
tools
|
||||
...(tools && tools.length > 0 ? { tools } : {})
|
||||
}),
|
||||
max_tokens: getModelMaxTokens(modelProvider.model),
|
||||
messages: processedMessages,
|
||||
|
||||
169
frontend/src/lib/components/dbOps.ts
Normal file
169
frontend/src/lib/components/dbOps.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
getLanguageByResourceType,
|
||||
type ColumnDef,
|
||||
type DbType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import { makeSelectQuery } from './apps/components/display/dbtable/queries/select'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { makeCountQuery } from './apps/components/display/dbtable/queries/count'
|
||||
import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update'
|
||||
import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete'
|
||||
import { makeInsertQuery } from './apps/components/display/dbtable/queries/insert'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
import { makeDeleteTableQuery } from './apps/components/display/dbtable/queries/deleteTable'
|
||||
|
||||
export type IDbTableOps = {
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
tableKey: string
|
||||
colDefs: ColumnDef[]
|
||||
|
||||
getRows: (params: {
|
||||
offset: number
|
||||
limit: number
|
||||
quicksearch: string
|
||||
order_by: string
|
||||
is_desc: boolean
|
||||
}) => Promise<unknown[]>
|
||||
getCount: (params: { quicksearch: string }) => Promise<number>
|
||||
onUpdate?: (
|
||||
row: { values: object },
|
||||
colDef: { field: string; datatype: string },
|
||||
newValue: string
|
||||
) => Promise<void>
|
||||
onDelete?: (row: { values: object }) => Promise<void>
|
||||
onInsert?: (row: { values: object }) => Promise<void>
|
||||
}
|
||||
|
||||
export function dbTableOpsWithPreviewScripts({
|
||||
resourcePath,
|
||||
resourceType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
workspace
|
||||
}: {
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
tableKey: string
|
||||
colDefs: ColumnDef[]
|
||||
workspace: string
|
||||
}): IDbTableOps {
|
||||
return {
|
||||
resourcePath,
|
||||
resourceType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
getCount: async ({ quicksearch }) => {
|
||||
const countQuery = makeCountQuery(resourceType, tableKey, undefined, colDefs)
|
||||
const result = await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, quicksearch },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: countQuery
|
||||
}
|
||||
})
|
||||
const count = result?.[0].count as number
|
||||
return count
|
||||
},
|
||||
getRows: async (params) => {
|
||||
const query = makeSelectQuery(tableKey, colDefs, undefined, resourceType as DbType)
|
||||
let items = (await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...params },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: query
|
||||
}
|
||||
})) as unknown[]
|
||||
if (resourceType === 'ms_sql_server') items = items?.[0] as unknown[]
|
||||
if (!items || !Array.isArray(items)) {
|
||||
throw 'items is not an array'
|
||||
}
|
||||
return items
|
||||
},
|
||||
onUpdate: async ({ values }, colDef, newValue) => {
|
||||
const updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, resourceType)
|
||||
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: {
|
||||
database: '$res:' + resourcePath,
|
||||
value_to_update: newValue,
|
||||
...values
|
||||
},
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: updateQuery
|
||||
}
|
||||
})
|
||||
},
|
||||
onDelete: async ({ values }) => {
|
||||
const deleteQuery = makeDeleteQuery(tableKey, colDefs, resourceType)
|
||||
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...values },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: deleteQuery
|
||||
}
|
||||
})
|
||||
},
|
||||
onInsert: async ({ values }) => {
|
||||
const insertQuery = makeInsertQuery(tableKey, colDefs, resourceType)
|
||||
runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...values },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: insertQuery
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DbTableAction = {
|
||||
action: () => void | Promise<void>
|
||||
displayName: string
|
||||
confirmTitle?: string
|
||||
confirmBtnText?: string
|
||||
icon?: any
|
||||
successText?: string
|
||||
}
|
||||
|
||||
export type DbTableActionFactory = (params: {
|
||||
tableKey: string
|
||||
refresh: () => void
|
||||
}) => DbTableAction
|
||||
|
||||
export function dbDeleteTableActionWithPreviewScript({
|
||||
workspace,
|
||||
resourcePath,
|
||||
resourceType
|
||||
}: {
|
||||
workspace: string
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
}): DbTableActionFactory {
|
||||
return ({ tableKey, refresh }) => ({
|
||||
confirmTitle: `Are you sure you want to delete '${tableKey}' ? This action is irreversible`,
|
||||
displayName: 'Delete',
|
||||
confirmBtnText: `Delete permanently`,
|
||||
icon: Trash2,
|
||||
successText: `Table '${tableKey}' deleted successfully`,
|
||||
action: async () => {
|
||||
const deleteQuery = makeDeleteTableQuery(tableKey, resourceType)
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: deleteQuery
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
<script module lang="ts">
|
||||
export type StepHistoryData = {
|
||||
id: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
success: boolean
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import { JobService } from '$lib/gen'
|
||||
@@ -14,16 +23,20 @@
|
||||
export let mockValue: any = undefined
|
||||
export let mockEnabled: boolean = false
|
||||
export let path: string = ''
|
||||
export let staticInputs: undefined | StepHistoryData[] = undefined
|
||||
export let noHistory: 'isLoop' | 'isInsideLoop' | undefined = undefined
|
||||
|
||||
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext') ?? {}
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let infiniteList: InfiniteList | undefined = undefined
|
||||
let loadInputsPageFn: ((page: number, perPage: number) => Promise<any>) | undefined = undefined
|
||||
let loadInputsPageFn:
|
||||
| ((page: number, perPage: number) => Promise<StepHistoryData[]>)
|
||||
| undefined = undefined
|
||||
|
||||
function initLoadInputs() {
|
||||
loadInputsPageFn = async (page: number, perPage: number) => {
|
||||
if (staticInputs) return staticInputs
|
||||
const previousJobs = await JobService.listCompletedJobs({
|
||||
workspace: $workspaceStore!,
|
||||
scriptPathExact: path === '' ? $pathStore + '/' + moduleId : path,
|
||||
@@ -42,6 +55,8 @@
|
||||
infiniteList?.setLoader(loadInputsPageFn)
|
||||
}
|
||||
|
||||
$: staticInputs && infiniteList?.loadData('forceRefresh')
|
||||
|
||||
async function getJobResultAndLogs(jobId: string, noLogs: boolean) {
|
||||
try {
|
||||
const job = await JobService.getJob({
|
||||
|
||||
39
frontend/src/lib/components/jobs/utils.ts
Normal file
39
frontend/src/lib/components/jobs/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { JobService, type RunScriptPreviewData } from '$lib/gen'
|
||||
|
||||
export async function runPreviewJobAndPollResult(
|
||||
data: RunScriptPreviewData,
|
||||
{ maxRetries = 7, withJobData }: { maxRetries?: number; withJobData?: boolean } = {}
|
||||
): Promise<unknown> {
|
||||
const uuid = await JobService.runScriptPreview(data)
|
||||
|
||||
let attempts = 0
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * (attempts || 0.75)))
|
||||
const job = await JobService.getCompletedJobResultMaybe({
|
||||
id: uuid,
|
||||
workspace: data.workspace
|
||||
})
|
||||
if (job.success) {
|
||||
if (withJobData) {
|
||||
return { job: { id: uuid }, result: job.result }
|
||||
} else {
|
||||
return job.result as any
|
||||
}
|
||||
} else if (job.completed) {
|
||||
attempts = maxRetries
|
||||
let errorMsg: string | undefined = (job?.result as any)?.error?.message
|
||||
if (typeof errorMsg !== 'string') errorMsg = undefined
|
||||
console.error('JOB FAILED', job.result)
|
||||
throw new Error(errorMsg ?? 'Job failed')
|
||||
}
|
||||
} catch (e) {
|
||||
if (attempts == maxRetries) {
|
||||
throw e
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
throw 'Could not get job result, should not get here'
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
isJobSelectable
|
||||
} from '$lib/utils'
|
||||
import { Badge, Button } from '../common'
|
||||
import ScheduleEditor from '../ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { UserService } from '$lib/gen'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Clipboard, Plus } from 'lucide-svelte'
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
@@ -42,6 +42,21 @@
|
||||
let newMcpScope = $state('favorites')
|
||||
let newMcpToken = $state<string | undefined>(undefined)
|
||||
|
||||
function ensureCurrentWorkspaceIncluded(
|
||||
workspacesList: UserWorkspace[],
|
||||
currentWorkspace: string | undefined
|
||||
) {
|
||||
if (!currentWorkspace) {
|
||||
return workspacesList
|
||||
}
|
||||
const hasCurrentWorkspace = workspacesList.some((w) => w.id === currentWorkspace)
|
||||
if (hasCurrentWorkspace) {
|
||||
return workspacesList
|
||||
}
|
||||
return [{ id: currentWorkspace, name: currentWorkspace }, ...workspacesList]
|
||||
}
|
||||
|
||||
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore))
|
||||
const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/sse?token=`)
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -221,8 +236,8 @@
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="label">Workspace</label>
|
||||
<select bind:value={newTokenWorkspace} disabled={$userWorkspaces.length === 1}>
|
||||
{#each $userWorkspaces as workspace}
|
||||
<select bind:value={newTokenWorkspace} disabled={workspaces.length === 1}>
|
||||
{#each workspaces as workspace}
|
||||
<option value={workspace.id}>{workspace.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
34
frontend/src/lib/components/triggers/scheduled/utils.ts
Normal file
34
frontend/src/lib/components/triggers/scheduled/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { JobService, ScheduleService } from "$lib/gen"
|
||||
import { goto } from "$lib/navigation"
|
||||
import { sendUserToast } from "$lib/utils"
|
||||
|
||||
export async function runScheduleNow(
|
||||
path: string,
|
||||
schedulePath: string,
|
||||
isFlow: boolean,
|
||||
workspace_id: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath
|
||||
const args = (
|
||||
await ScheduleService.getSchedule({
|
||||
workspace: workspace_id,
|
||||
path: schedulePath
|
||||
})
|
||||
).args
|
||||
const run = await runByPath({
|
||||
path,
|
||||
requestBody: args ?? {},
|
||||
workspace: workspace_id
|
||||
})
|
||||
|
||||
sendUserToast(`Schedule ${path} will run now`, false, [
|
||||
{
|
||||
label: 'Go to the run page',
|
||||
callback: () => goto('/run/' + run + '?workspace=' + workspace_id)
|
||||
}
|
||||
])
|
||||
} catch (err) {
|
||||
sendUserToast(`Cannot run schedule now: ${err.body}`, true)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,14 @@
|
||||
drawer?.openEdit(ePath, isFlow)
|
||||
}
|
||||
|
||||
export async function openNew(is_flow: boolean, initial_script_path?: string) {
|
||||
export async function openNew(
|
||||
is_flow: boolean,
|
||||
initial_script_path?: string,
|
||||
schedule_path?: string
|
||||
) {
|
||||
open = true
|
||||
await tick()
|
||||
drawer?.openNew(is_flow, initial_script_path)
|
||||
drawer?.openNew(is_flow, initial_script_path, schedule_path)
|
||||
}
|
||||
|
||||
let drawer: ScheduleEditorInner
|
||||
@@ -17,7 +17,8 @@
|
||||
ScriptService,
|
||||
type Flow,
|
||||
SettingService,
|
||||
type Retry
|
||||
type Retry,
|
||||
type Schedule
|
||||
} from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
|
||||
@@ -25,11 +26,12 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte'
|
||||
import FlowRetries from './flows/content/FlowRetries.svelte'
|
||||
import WorkerTagPicker from './WorkerTagPicker.svelte'
|
||||
import Label from './Label.svelte'
|
||||
import DateTimeInput from './DateTimeInput.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
|
||||
import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
|
||||
import { runScheduleNow } from '../scheduled/utils'
|
||||
|
||||
let optionTabSelected: 'error_handler' | 'recovery_handler' | 'success_handler' | 'retries' =
|
||||
'error_handler'
|
||||
@@ -65,6 +67,7 @@
|
||||
let failedTimes = 1
|
||||
let failedExact = false
|
||||
let recoveredTimes = 1
|
||||
let duplicate = false
|
||||
let retry: Retry | undefined = undefined
|
||||
|
||||
let script_path = ''
|
||||
@@ -94,16 +97,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
export async function openNew(nis_flow: boolean, initial_script_path?: string) {
|
||||
drawerLoading = true
|
||||
try {
|
||||
drawer?.openDrawer()
|
||||
args = {}
|
||||
runnable = undefined
|
||||
is_flow = nis_flow
|
||||
schedule = '0 0 12 * *'
|
||||
paused_until = undefined
|
||||
showPauseUntil = false
|
||||
async function setScheduleHandler(s?: Schedule) {
|
||||
if (s) {
|
||||
if (s.on_failure) {
|
||||
let splitted = s.on_failure.split('/')
|
||||
errorHandleritemKind = splitted[0] as 'flow' | 'script'
|
||||
errorHandlerPath = splitted.slice(1)?.join('/')
|
||||
errorHandlerCustomInitialPath = errorHandlerPath
|
||||
failedTimes = s.on_failure_times ?? 1
|
||||
failedExact = s.on_failure_exact ?? false
|
||||
errorHandlerExtraArgs = s.on_failure_extra_args ?? {}
|
||||
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
|
||||
} else {
|
||||
errorHandlerPath = undefined
|
||||
errorHandleritemKind = 'script'
|
||||
errorHandlerCustomInitialPath = undefined
|
||||
errorHandlerExtraArgs = {}
|
||||
failedExact = false
|
||||
failedTimes = 1
|
||||
errorHandlerSelected = 'slack'
|
||||
}
|
||||
if (s.on_recovery) {
|
||||
let splitted = s.on_recovery.split('/')
|
||||
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
recoveryHandlerPath = splitted.slice(1)?.join('/')
|
||||
recoveryHandlerCustomInitialPath = recoveryHandlerPath
|
||||
recoveredTimes = s.on_recovery_times ?? 1
|
||||
recoveryHandlerExtraArgs = s.on_recovery_extra_args ?? {}
|
||||
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
|
||||
} else {
|
||||
recoveryHandlerPath = undefined
|
||||
recoveryHandlerItemKind = 'script'
|
||||
recoveryHandlerCustomInitialPath = undefined
|
||||
recoveredTimes = 1
|
||||
recoveryHandlerSelected = 'slack'
|
||||
recoveryHandlerExtraArgs = {}
|
||||
}
|
||||
if (s.on_success) {
|
||||
let splitted = s.on_success.split('/')
|
||||
successHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
successHandlerPath = splitted.slice(1)?.join('/')
|
||||
successHandlerCustomInitialPath = successHandlerPath
|
||||
successHandlerExtraArgs = s.on_success_extra_args ?? {}
|
||||
successHandlerSelected = getHandlerType('success', successHandlerPath)
|
||||
} else {
|
||||
successHandlerPath = undefined
|
||||
successHandlerItemKind = 'script'
|
||||
successHandlerCustomInitialPath = undefined
|
||||
successHandlerSelected = 'slack'
|
||||
successHandlerExtraArgs = {}
|
||||
}
|
||||
}
|
||||
else {
|
||||
let defaultErrorHandlerMaybe = undefined
|
||||
let defaultRecoveryHandlerMaybe = undefined
|
||||
let defaultSuccessHandlerMaybe = undefined
|
||||
@@ -119,17 +164,6 @@
|
||||
})) as any
|
||||
}
|
||||
|
||||
edit = false
|
||||
itemKind = nis_flow ? 'flow' : 'script'
|
||||
initialScriptPath = initial_script_path ?? ''
|
||||
summary = ''
|
||||
description = ''
|
||||
no_flow_overlap = false
|
||||
path = initialScriptPath
|
||||
initialPath = initialScriptPath
|
||||
script_path = initialScriptPath
|
||||
await loadScript(script_path)
|
||||
|
||||
if (defaultErrorHandlerMaybe !== undefined && defaultErrorHandlerMaybe !== null) {
|
||||
wsErrorHandlerMuted = defaultErrorHandlerMaybe['wsErrorHandlerMuted']
|
||||
let splitted = (defaultErrorHandlerMaybe['errorHandlerPath'] as string).split('/')
|
||||
@@ -181,7 +215,54 @@
|
||||
successHandlerCustomInitialPath = undefined
|
||||
successHandlerSelected = 'slack'
|
||||
}
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
}
|
||||
|
||||
export async function openNew(
|
||||
nis_flow: boolean,
|
||||
initial_script_path?: string,
|
||||
schedule_path?: string
|
||||
) {
|
||||
drawerLoading = true
|
||||
try {
|
||||
let s: Schedule | undefined
|
||||
if (schedule_path) {
|
||||
s = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: schedule_path
|
||||
})
|
||||
duplicate = true
|
||||
}
|
||||
drawer?.openDrawer()
|
||||
runnable = undefined
|
||||
is_flow = s?.is_flow ?? nis_flow
|
||||
edit = false
|
||||
itemKind = is_flow ? 'flow' : 'script'
|
||||
initialScriptPath = initial_script_path ?? ''
|
||||
path = duplicate === true ? '' : initialScriptPath
|
||||
|
||||
initialPath = path
|
||||
cronVersion = s?.cron_version ?? 'v2'
|
||||
initialCronVersion = cronVersion
|
||||
isLatestCron = cronVersion == 'v2'
|
||||
schedule = s?.schedule ?? '0 0 12 * *'
|
||||
initialSchedule = schedule
|
||||
timezone = s?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
paused_until = s?.paused_until
|
||||
showPauseUntil = paused_until !== undefined
|
||||
summary = s?.summary ?? ''
|
||||
description = s?.description ?? ''
|
||||
script_path = s?.script_path ?? initialScriptPath
|
||||
args = s?.args ?? {}
|
||||
tag = s?.tag
|
||||
|
||||
await loadScript(script_path)
|
||||
|
||||
no_flow_overlap = s?.no_flow_overlap ?? false
|
||||
wsErrorHandlerMuted = s?.ws_error_handler_muted ?? false
|
||||
retry = s?.retry
|
||||
|
||||
await setScheduleHandler(s)
|
||||
} finally {
|
||||
drawerLoading = false
|
||||
}
|
||||
@@ -322,6 +403,7 @@
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
is_flow = s.is_flow
|
||||
cronVersion = s.cron_version ?? 'v2'
|
||||
initialCronVersion = cronVersion
|
||||
isLatestCron = cronVersion == 'v2'
|
||||
@@ -334,63 +416,16 @@
|
||||
summary = s.summary ?? ''
|
||||
description = s.description ?? ''
|
||||
script_path = s.script_path ?? ''
|
||||
await loadScript(script_path)
|
||||
|
||||
is_flow = s.is_flow
|
||||
no_flow_overlap = s.no_flow_overlap ?? false
|
||||
wsErrorHandlerMuted = s.ws_error_handler_muted ?? false
|
||||
retry = s.retry
|
||||
if (s.on_failure) {
|
||||
let splitted = s.on_failure.split('/')
|
||||
errorHandleritemKind = splitted[0] as 'flow' | 'script'
|
||||
errorHandlerPath = splitted.slice(1)?.join('/')
|
||||
errorHandlerCustomInitialPath = errorHandlerPath
|
||||
failedTimes = s.on_failure_times ?? 1
|
||||
failedExact = s.on_failure_exact ?? false
|
||||
errorHandlerExtraArgs = s.on_failure_extra_args ?? {}
|
||||
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
|
||||
} else {
|
||||
errorHandlerPath = undefined
|
||||
errorHandleritemKind = 'script'
|
||||
errorHandlerCustomInitialPath = undefined
|
||||
errorHandlerExtraArgs = {}
|
||||
failedExact = false
|
||||
failedTimes = 1
|
||||
errorHandlerSelected = 'slack'
|
||||
}
|
||||
if (s.on_recovery) {
|
||||
let splitted = s.on_recovery.split('/')
|
||||
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
recoveryHandlerPath = splitted.slice(1)?.join('/')
|
||||
recoveryHandlerCustomInitialPath = recoveryHandlerPath
|
||||
recoveredTimes = s.on_recovery_times ?? 1
|
||||
recoveryHandlerExtraArgs = s.on_recovery_extra_args ?? {}
|
||||
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
|
||||
} else {
|
||||
recoveryHandlerPath = undefined
|
||||
recoveryHandlerItemKind = 'script'
|
||||
recoveryHandlerCustomInitialPath = undefined
|
||||
recoveredTimes = 1
|
||||
recoveryHandlerSelected = 'slack'
|
||||
recoveryHandlerExtraArgs = {}
|
||||
}
|
||||
if (s.on_success) {
|
||||
let splitted = s.on_success.split('/')
|
||||
successHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
successHandlerPath = splitted.slice(1)?.join('/')
|
||||
successHandlerCustomInitialPath = successHandlerPath
|
||||
successHandlerExtraArgs = s.on_success_extra_args ?? {}
|
||||
successHandlerSelected = getHandlerType('success', successHandlerPath)
|
||||
} else {
|
||||
successHandlerPath = undefined
|
||||
successHandlerItemKind = 'script'
|
||||
successHandlerCustomInitialPath = undefined
|
||||
successHandlerSelected = 'slack'
|
||||
successHandlerExtraArgs = {}
|
||||
}
|
||||
args = s.args ?? {}
|
||||
can_write = canWrite(s.path, s.extra_perms, $userStore)
|
||||
tag = s.tag
|
||||
|
||||
await loadScript(script_path)
|
||||
|
||||
no_flow_overlap = s.no_flow_overlap ?? false
|
||||
wsErrorHandlerMuted = s.ws_error_handler_muted ?? false
|
||||
retry = s.retry
|
||||
await setScheduleHandler(s)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load schedule: ${err}`, true)
|
||||
}
|
||||
@@ -571,7 +606,7 @@
|
||||
<svelte:fragment slot="actions">
|
||||
{#if !drawerLoading}
|
||||
{#if edit}
|
||||
<div class="mr-8">
|
||||
<div class="mr-8 flex flex-row gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
@@ -581,6 +616,16 @@
|
||||
>
|
||||
View runs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
disabled={!allowSchedule || pathError != '' || emptyString(script_path)}
|
||||
on:click={() => {
|
||||
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
|
||||
}}
|
||||
>
|
||||
Run now
|
||||
</Button>
|
||||
</div>
|
||||
{#if can_write}
|
||||
<div class="mr-8 center-center -mt-1">
|
||||
@@ -749,7 +794,7 @@
|
||||
Pick a script or flow to be triggered by the schedule<Required required={true} />
|
||||
</p>
|
||||
<ScriptPicker
|
||||
disabled={initialScriptPath != '' || !can_write}
|
||||
disabled={(initialScriptPath != '' && !duplicate) || !can_write}
|
||||
initialPath={initialScriptPath}
|
||||
kinds={['script']}
|
||||
allowFlow={true}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DbType } from './components/apps/components/display/dbtable/utils'
|
||||
|
||||
export const DEFAULT_WEBHOOK_TYPE: 'async' | 'sync' = 'async'
|
||||
|
||||
export const HOME_SHOW_HUB = true
|
||||
@@ -201,3 +203,11 @@ export const MSSQL_TYPES = [
|
||||
'decimal',
|
||||
'bit'
|
||||
]
|
||||
|
||||
export const DB_TYPES: Record<DbType, string[]> = {
|
||||
bigquery: BIGQUERY_TYPES,
|
||||
ms_sql_server: MSSQL_TYPES,
|
||||
mysql: MYSQL_TYPES,
|
||||
postgresql: POSTGRES_TYPES,
|
||||
snowflake: SNOWFLAKE_TYPES
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface UserExt {
|
||||
folders_owners: string[]
|
||||
}
|
||||
|
||||
export interface UserWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
color: string | null
|
||||
operator_settings?: OperatorSettings
|
||||
}
|
||||
|
||||
const persistedWorkspace = BROWSER && getWorkspace()
|
||||
|
||||
function getWorkspace(): string | undefined {
|
||||
@@ -60,31 +68,26 @@ export const superadmin = writable<string | false | undefined>(undefined)
|
||||
export const devopsRole = writable<string | false | undefined>(undefined)
|
||||
export const lspTokenStore = writable<string | undefined>(undefined)
|
||||
export const hubBaseUrlStore = writable<string>('https://hub.windmill.dev')
|
||||
export const userWorkspaces: Readable<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
color: string | null
|
||||
operator_settings?: OperatorSettings
|
||||
}>
|
||||
> = derived([usersWorkspaceStore, superadmin], ([store, superadmin]) => {
|
||||
const originalWorkspaces = store?.workspaces ?? []
|
||||
if (superadmin) {
|
||||
return [
|
||||
...originalWorkspaces.filter((x) => x.id != 'admins'),
|
||||
{
|
||||
id: 'admins',
|
||||
name: 'Admins',
|
||||
username: 'superadmin',
|
||||
color: null,
|
||||
operator_settings: null
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return originalWorkspaces
|
||||
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
|
||||
[usersWorkspaceStore, superadmin],
|
||||
([store, superadmin]) => {
|
||||
const originalWorkspaces = store?.workspaces ?? []
|
||||
if (superadmin) {
|
||||
return [
|
||||
...originalWorkspaces.filter((x) => x.id != 'admins'),
|
||||
{
|
||||
id: 'admins',
|
||||
name: 'Admins',
|
||||
username: 'superadmin',
|
||||
color: null,
|
||||
operator_settings: null
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return originalWorkspaces
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
export const copilotInfo = writable<{
|
||||
enabled: boolean
|
||||
codeCompletionModel?: AIProviderModel
|
||||
@@ -232,3 +235,16 @@ export const workspaceColor: Readable<string | null | undefined> = derived(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
|
||||
const schema = dbSchema?.schema ?? {}
|
||||
const tableNames: string[] = []
|
||||
|
||||
for (const schemaKey in schema) {
|
||||
for (const tableKey in schema[schemaKey]) {
|
||||
tableNames.push(`${schemaKey}.${tableKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
return tableNames
|
||||
}
|
||||
|
||||
@@ -692,6 +692,12 @@ export function sortObject<T>(o: T & object): T {
|
||||
}, {}) as T
|
||||
}
|
||||
|
||||
export function sortArray<T>(array: T[], compareFn?: (a: T, b: T) => number): T[] {
|
||||
const arr = [...array]
|
||||
arr.sort(compareFn)
|
||||
return arr
|
||||
}
|
||||
|
||||
export function generateRandomString(len: number = 24): string {
|
||||
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte'
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import DbManagerDrawerButton from '$lib/components/DBManagerDrawerButton.svelte'
|
||||
import { isDbType } from '$lib/components/apps/components/display/dbtable/utils'
|
||||
|
||||
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
|
||||
type ResourceTypeW = ResourceType & { canWrite: boolean }
|
||||
@@ -136,23 +138,23 @@
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: tab === 'states'
|
||||
? x.resource_type === 'state'
|
||||
: tab === 'cache'
|
||||
? x.resource_type === 'cache'
|
||||
: tab === 'theme'
|
||||
? x.resource_type === 'app_theme'
|
||||
: true
|
||||
})
|
||||
? x.resource_type === 'state'
|
||||
: tab === 'cache'
|
||||
? x.resource_type === 'cache'
|
||||
: tab === 'theme'
|
||||
? x.resource_type === 'app_theme'
|
||||
: true
|
||||
})
|
||||
: preFilteredItemsOwners?.filter((x) => {
|
||||
return (
|
||||
x.resource_type === typeFilter &&
|
||||
(tab === 'workspace'
|
||||
? x.resource_type !== 'app_theme' &&
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: true)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadResources(): Promise<void> {
|
||||
resources = await loadResourceInternal(undefined, 'cache,state')
|
||||
@@ -488,10 +490,7 @@
|
||||
>
|
||||
<label>
|
||||
<div class="mb-1 font-semibold text-secondary">Description</div>
|
||||
<textarea
|
||||
use:autosize
|
||||
autocomplete="off"
|
||||
bind:value={editResourceType.description}
|
||||
<textarea use:autosize autocomplete="off" bind:value={editResourceType.description}
|
||||
></textarea></label
|
||||
>
|
||||
<div>
|
||||
@@ -569,7 +568,8 @@
|
||||
</label>
|
||||
<label>
|
||||
<div class="mb-1 font-semibold text-secondary">Description</div>
|
||||
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}></textarea></label
|
||||
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}
|
||||
></textarea></label
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between w-full items-center">
|
||||
@@ -603,422 +603,435 @@
|
||||
f={(x) => x.path + ' ' + x.resource_type + ' ' + x.description + ' '}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.resources}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<div class="flex flex-row justify-end gap-4">
|
||||
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
|
||||
Add resource type
|
||||
</Button>
|
||||
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
|
||||
Add resource
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div class="flex justify-between">
|
||||
<Tabs
|
||||
class="w-full"
|
||||
bind:selected={tab}
|
||||
on:selected={(e) => {
|
||||
if (e.detail == 'cache') {
|
||||
loading.resources = true
|
||||
loadCache()
|
||||
} else if (e.detail == 'states') {
|
||||
loading.resources = true
|
||||
loadState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="types">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Resource Types
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
Every resource has a Resource Type attached to it which contains its schema and make it
|
||||
easy in scripts and flows to accept only resources of a specific resource type.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="states">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
States
|
||||
<Tooltip>
|
||||
States are actually resources (but excluded from the Workspace tab for clarity). States
|
||||
are used by scripts to keep data persistent between runs of the same script by the same
|
||||
trigger (schedule or user)
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="cache">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Cache
|
||||
<Tooltip>
|
||||
Cached results are actually resources (but excluded from the Workspace tab for clarity).
|
||||
Cache are used by flows's step to cache result to avoid recomputing unnecessarily
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="theme">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Theme
|
||||
<Tooltip>
|
||||
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
|
||||
are used by the apps to customize their look and feel.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={reload}
|
||||
startIcon={{
|
||||
icon: RotateCw,
|
||||
classes: loading.resources || loading.types ? 'animate-spin' : ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if tab != 'states' && tab != 'cache'}
|
||||
<ListFilters
|
||||
queryName="app_filter"
|
||||
bind:selectedFilter={typeFilter}
|
||||
filters={types}
|
||||
resourceType
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto pb-40 mt-4">
|
||||
{#if loading.resources}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else if filteredItems?.length == 0}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-md font-medium">No resources found</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Try changing the filters or creating a new resource
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<div class="flex flex-row justify-end gap-4">
|
||||
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
|
||||
Add resource type
|
||||
</Button>
|
||||
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
|
||||
Add resource
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div class="flex justify-between">
|
||||
<Tabs
|
||||
class="w-full"
|
||||
bind:selected={tab}
|
||||
on:selected={(e) => {
|
||||
if (e.detail == 'cache') {
|
||||
loading.resources = true
|
||||
loadCache()
|
||||
} else if (e.detail == 'states') {
|
||||
loading.resources = true
|
||||
loadState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="types">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Resource Types
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
Every resource has a Resource Type attached to it which contains its schema and make
|
||||
it easy in scripts and flows to accept only resources of a specific resource type.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="states">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
States
|
||||
<Tooltip>
|
||||
States are actually resources (but excluded from the Workspace tab for clarity).
|
||||
States are used by scripts to keep data persistent between runs of the same script by
|
||||
the same trigger (schedule or user)
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="cache">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Cache
|
||||
<Tooltip>
|
||||
Cached results are actually resources (but excluded from the Workspace tab for
|
||||
clarity). Cache are used by flows's step to cache result to avoid recomputing
|
||||
unnecessarily
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="theme">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Theme
|
||||
<Tooltip>
|
||||
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
|
||||
are used by the apps to customize their look and feel.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={reload}
|
||||
startIcon={{
|
||||
icon: RotateCw,
|
||||
classes: loading.resources || loading.types ? 'animate-spin' : ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if tab != 'states' && tab != 'cache'}
|
||||
<ListFilters
|
||||
queryName="app_filter"
|
||||
bind:selectedFilter={typeFilter}
|
||||
filters={types}
|
||||
resourceType
|
||||
/>
|
||||
{:else}
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Resource type</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head />
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if filteredItems}
|
||||
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
class="break-all"
|
||||
href="#{path}"
|
||||
on:click={() => resourceEditor?.initEdit?.(path)}
|
||||
>{#if marked}{@html marked}{:else}{path}{/if}</a
|
||||
>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
|
||||
if (linkedRt) {
|
||||
resourceTypeViewerObj = {
|
||||
rt: linkedRt.name,
|
||||
//@ts-ignore
|
||||
schema: linkedRt.schema,
|
||||
description: linkedRt.description ?? '',
|
||||
formatExtension: linkedRt.format_extension
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto pb-40 mt-4">
|
||||
{#if loading.resources}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else if filteredItems?.length == 0}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-md font-medium">No resources found</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Try changing the filters or creating a new resource
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Resource type</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head />
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if filteredItems}
|
||||
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
class="break-all"
|
||||
href="#{path}"
|
||||
on:click={() => resourceEditor?.initEdit?.(path)}
|
||||
>{#if marked}{@html marked}{:else}{path}{/if}</a
|
||||
>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
|
||||
if (linkedRt) {
|
||||
resourceTypeViewerObj = {
|
||||
rt: linkedRt.name,
|
||||
//@ts-ignore
|
||||
schema: linkedRt.schema,
|
||||
description: linkedRt.description ?? '',
|
||||
formatExtension: linkedRt.format_extension
|
||||
}
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
} else {
|
||||
sendUserToast(
|
||||
`Resource type ${resource_type} not found in workspace.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
} else {
|
||||
sendUserToast(
|
||||
`Resource type ${resource_type} not found in workspace.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
name={resource_type}
|
||||
after={true}
|
||||
formatExtension={resourceNameToFileExt(resource_type)}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs">
|
||||
{removeMarkdown(truncate(description ?? '', 30))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-row text-center">
|
||||
<div class="w-10">
|
||||
{#if is_linked}
|
||||
<Popover>
|
||||
<Link />
|
||||
<div slot="text">
|
||||
This resource is linked with a variable of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-10">
|
||||
{#if is_refreshed}
|
||||
<Popover>
|
||||
<RotateCw />
|
||||
<div slot="text">
|
||||
The OAuth token will be kept up-to-date in the background by Windmill
|
||||
using its refresh token
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if is_oauth}
|
||||
<div class="w-10 pt-1.5">
|
||||
{#if refresh_error}
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
name={resource_type}
|
||||
after={true}
|
||||
formatExtension={resourceNameToFileExt(resource_type)}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs">
|
||||
{removeMarkdown(truncate(description ?? '', 30))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-row text-center">
|
||||
<div class="w-10">
|
||||
{#if is_linked}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Link />
|
||||
<div slot="text">
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
|
||||
<div slot="text">
|
||||
The access_token is expired, it will get renewed the next time this
|
||||
variable is fetched or you can request is to be refreshed in the
|
||||
dropdown on the right.
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
The resource was connected through OAuth and the token is not
|
||||
expired.
|
||||
This resource is linked with a variable of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-10">
|
||||
{#if is_refreshed}
|
||||
<Popover>
|
||||
<RotateCw />
|
||||
<div slot="text">
|
||||
The OAuth token will be kept up-to-date in the background by
|
||||
Windmill using its refresh token
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if is_oauth}
|
||||
<div class="w-10 pt-1.5">
|
||||
{#if refresh_error}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
|
||||
<div slot="text">
|
||||
The access_token is expired, it will get renewed the next time
|
||||
this variable is fetched or you can request is to be refreshed in
|
||||
the dropdown on the right.
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
The resource was connected through OAuth and the token is not
|
||||
expired.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="flex justify-end">
|
||||
{#if path && isDbType(resource_type)}
|
||||
<DbManagerDrawerButton
|
||||
resourcePath={path}
|
||||
resourceType={resource_type}
|
||||
class="mr-8"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: !canWrite ? 'View Permissions' : 'Share',
|
||||
icon: Share,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
disabled: !canWrite,
|
||||
action: () => {
|
||||
resourceEditor?.initEdit?.(path)
|
||||
}
|
||||
},
|
||||
...(isDeployable('resource', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'resource')
|
||||
<Dropdown
|
||||
class="w-fit"
|
||||
items={[
|
||||
{
|
||||
displayName: !canWrite ? 'View Permissions' : 'Share',
|
||||
icon: Share,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
disabled: !canWrite,
|
||||
action: () => {
|
||||
resourceEditor?.initEdit?.(path)
|
||||
}
|
||||
},
|
||||
...(isDeployable('resource', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'resource')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
disabled: !canWrite,
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteResource(path, account)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
disabled: !canWrite,
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteResource(path, account)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteResource(path, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RotateCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadResources()
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RotateCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadResources()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'types'}
|
||||
{#if loading.types}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="overflow-auto mt-4">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if resourceTypes}
|
||||
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
resourceTypeViewerObj = {
|
||||
rt: name,
|
||||
//@ts-ignore
|
||||
schema: schema,
|
||||
description: description ?? '',
|
||||
formatExtension: format_extension
|
||||
}
|
||||
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
}}
|
||||
>
|
||||
<IconedResourceType after={true} {name} formatExtension={format_extension} />
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
|
||||
{removeMarkdown(truncate(description ?? '', 200))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
{#if !canWrite}
|
||||
<Badge>
|
||||
Shared globally
|
||||
<Tooltip>
|
||||
This resource type is from the 'admins' workspace shared with all
|
||||
workspaces
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="border"
|
||||
btnClasses="border-0"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => handleDeleteResourceType(name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => startEditResourceType(name)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Badge>
|
||||
Non Editable
|
||||
<Tooltip>
|
||||
Since resource types are shared with the whole workspace, only admins can
|
||||
edit/delete them
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'types'}
|
||||
{#if loading.types}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="overflow-auto mt-4">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if resourceTypes}
|
||||
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
resourceTypeViewerObj = {
|
||||
rt: name,
|
||||
//@ts-ignore
|
||||
schema: schema,
|
||||
description: description ?? '',
|
||||
formatExtension: format_extension
|
||||
}
|
||||
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
after={true}
|
||||
{name}
|
||||
formatExtension={format_extension}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
|
||||
{removeMarkdown(truncate(description ?? '', 200))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
{#if !canWrite}
|
||||
<Badge>
|
||||
Shared globally
|
||||
<Tooltip>
|
||||
This resource type is from the 'admins' workspace shared with all
|
||||
workspaces
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="border"
|
||||
btnClasses="border-0"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => handleDeleteResourceType(name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => startEditResourceType(name)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Badge>
|
||||
Non Editable
|
||||
<Tooltip>
|
||||
Since resource types are shared with the whole workspace, only admins
|
||||
can edit/delete them
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
|
||||
|
||||
@@ -81,13 +81,13 @@
|
||||
import { json } from 'svelte-highlight/languages'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import WorkflowTimeline from '$lib/components/WorkflowTimeline.svelte'
|
||||
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
|
||||
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
|
||||
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
|
||||
import CustomPopover from '$lib/components/CustomPopover.svelte'
|
||||
import { isWindmillTooBigObject } from '$lib/components/job_args'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
|
||||
let job: Job | undefined
|
||||
let jobUpdateLastFetch: Date | undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ScheduleService,
|
||||
JobService,
|
||||
type ScheduleWJobs,
|
||||
type WorkspaceDeployUISettings,
|
||||
WorkspaceService
|
||||
@@ -13,7 +12,6 @@
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
@@ -22,6 +20,7 @@
|
||||
Calendar,
|
||||
Circle,
|
||||
Code,
|
||||
Copy,
|
||||
Eye,
|
||||
FileUp,
|
||||
List,
|
||||
@@ -45,6 +44,8 @@
|
||||
import { onMount } from 'svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
|
||||
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
|
||||
|
||||
@@ -106,36 +107,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function runScheduleNow(
|
||||
path: string,
|
||||
schedulePath: string,
|
||||
isFlow: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath
|
||||
const args = (
|
||||
await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: schedulePath
|
||||
})
|
||||
).args
|
||||
const run = await runByPath({
|
||||
path,
|
||||
requestBody: args ?? {},
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
|
||||
sendUserToast(`Schedule ${path} will run now`, false, [
|
||||
{
|
||||
label: 'Go to the run page',
|
||||
callback: () => goto('/run/' + run + '?workspace=' + $workspaceStore)
|
||||
}
|
||||
])
|
||||
} catch (err) {
|
||||
sendUserToast(`Cannot run schedule now: ${err.body}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadSchedules()
|
||||
@@ -200,18 +171,18 @@
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
)
|
||||
: schedules?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
)
|
||||
: schedules?.filter(
|
||||
(x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
)
|
||||
|
||||
$: if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
@@ -221,10 +192,10 @@
|
||||
selectedFilterKind === 'schedule'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
).sort()
|
||||
|
||||
$: items = filter !== '' ? filteredItems : preFilteredItems
|
||||
|
||||
@@ -430,6 +401,13 @@
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: `Duplicate schedule`,
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(is_flow, script_path, path)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
@@ -463,7 +441,7 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
@@ -483,7 +461,7 @@
|
||||
displayName: 'Run now',
|
||||
icon: Play,
|
||||
action: () => {
|
||||
runScheduleNow(script_path, path, is_flow)
|
||||
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
|
||||
|
||||
$workspaceStore = $page.params.workspace
|
||||
|
||||
@@ -472,11 +472,16 @@ const config = {
|
||||
'4k': '3800px'
|
||||
},
|
||||
animation: {
|
||||
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite'
|
||||
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite',
|
||||
'zoom-in': 'zoom-in 0.25s ease-in-out'
|
||||
},
|
||||
keyframes: {
|
||||
'spin-counter-clockwise': {
|
||||
to: { transform: 'rotate(-360deg)' }
|
||||
},
|
||||
'zoom-in': {
|
||||
'0%': { transform: 'scale(0.95)' },
|
||||
'100%': { transform: 'scale(1)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ httpcore==1.0.2
|
||||
httpx==0.26.0
|
||||
idna==3.6
|
||||
sniffio==1.3.0
|
||||
docker==7.1.0
|
||||
|
||||
@@ -2,3 +2,4 @@ from .identity_script_test import *
|
||||
from .increment_flow_test import *
|
||||
from .schedule_test import *
|
||||
from .windmill_sdk_test import *
|
||||
from .agent_workers import *
|
||||
|
||||
265
integration_tests/test/agent_workers.py
Normal file
265
integration_tests/test/agent_workers.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import unittest
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import atexit
|
||||
import docker
|
||||
from docker.errors import NotFound
|
||||
|
||||
from .wmill_integration_test_utils import WindmillClient
|
||||
|
||||
AGENT_CONTAINER_NAME = "windmill_agent_test"
|
||||
|
||||
# Global cleanup function registered with atexit
|
||||
def cleanup_docker_container():
|
||||
print(f"At exit: Attempting to remove container {AGENT_CONTAINER_NAME}")
|
||||
try:
|
||||
docker_client = docker.from_env()
|
||||
try:
|
||||
container = docker_client.containers.get(AGENT_CONTAINER_NAME)
|
||||
container.stop()
|
||||
container.remove()
|
||||
print(f"At exit: Successfully removed container {AGENT_CONTAINER_NAME}")
|
||||
except NotFound:
|
||||
print(f"At exit: Container {AGENT_CONTAINER_NAME} not found")
|
||||
except Exception as e:
|
||||
print(f"At exit: Error removing container: {e}")
|
||||
except Exception as e:
|
||||
print(f"At exit: Could not initialize Docker client: {e}")
|
||||
|
||||
atexit.register(cleanup_docker_container)
|
||||
|
||||
|
||||
class TestAgentWorkers(unittest.TestCase):
|
||||
_client: WindmillClient
|
||||
_docker_client = None
|
||||
_agent_container = None
|
||||
_container_name = AGENT_CONTAINER_NAME
|
||||
_agent_token = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
print("Running {}".format(cls.__name__))
|
||||
cls._client = WindmillClient()
|
||||
|
||||
cls._client.add_global_custom_tag("agent_test")
|
||||
cls._docker_client = docker.from_env()
|
||||
cls._start_agent_container()
|
||||
cls._wait_for_agent_connection()
|
||||
|
||||
@classmethod
|
||||
def _start_agent_container(cls):
|
||||
wm_image = os.environ.get("WM_IMAGE", "ghcr.io/windmill-labs/windmill-ee")
|
||||
wm_version = os.environ.get("WM_VERSION", "latest")
|
||||
|
||||
cls._agent_token = cls._client.create_agent_token(
|
||||
worker_group="agent",
|
||||
tags=["agent", "python3", "bash", "agent_test"],
|
||||
exp=int(time.time()) + 3600 # 1 hour from now
|
||||
)
|
||||
|
||||
try:
|
||||
container = cls._docker_client.containers.get(cls._container_name)
|
||||
container.stop()
|
||||
container.remove()
|
||||
print(f"Removed existing container {cls._container_name}")
|
||||
except NotFound:
|
||||
pass
|
||||
|
||||
# Get the host IP address for connecting back to the Windmill server
|
||||
host_ip = "host.docker.internal"
|
||||
if os.name == "posix" and os.uname().sysname == "Linux":
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
host_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
print(f"Starting agent container connecting to Windmill server at http://{host_ip}:8000")
|
||||
|
||||
# Create and start the container
|
||||
container = cls._docker_client.containers.run(
|
||||
f"{wm_image}:{wm_version}",
|
||||
name=cls._container_name,
|
||||
detach=True,
|
||||
environment={
|
||||
"BASE_INTERNAL_URL": f"http://{host_ip}:8000",
|
||||
"MODE": "agent",
|
||||
"AGENT_TOKEN": cls._agent_token
|
||||
},
|
||||
volumes={
|
||||
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
|
||||
},
|
||||
restart_policy={"Name": "unless-stopped"},
|
||||
mem_limit="2g",
|
||||
cpu_count=1,
|
||||
)
|
||||
|
||||
cls._agent_container = container
|
||||
print(f"Started agent container: {cls._container_name}")
|
||||
|
||||
@classmethod
|
||||
def _wait_for_agent_connection(cls):
|
||||
print("Waiting for agent to connect to the server...")
|
||||
connected = False
|
||||
max_attempts = 60
|
||||
for attempt in range(max_attempts):
|
||||
# Query server for connected workers
|
||||
workers = cls._client.get_workers_list(ping_since=60)
|
||||
|
||||
# Check if any worker is in the "agent" worker_group
|
||||
for worker in workers:
|
||||
if worker.get("worker_group") == "agent":
|
||||
connected = True
|
||||
print(f"Agent connected with details: {worker}")
|
||||
break
|
||||
|
||||
if connected:
|
||||
break
|
||||
|
||||
print(f"Waiting for agent to connect... Attempt {attempt+1}/{max_attempts}")
|
||||
time.sleep(1)
|
||||
|
||||
if not connected:
|
||||
raise Exception("Agent failed to connect within the expected time")
|
||||
|
||||
print("Agent successfully connected!")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
print("Cleaning up after tests...")
|
||||
|
||||
if hasattr(cls, "_client") and cls._client is not None:
|
||||
cls._client.remove_global_custom_tag("agent_test")
|
||||
|
||||
if cls._docker_client is None:
|
||||
try:
|
||||
cls._docker_client = docker.from_env()
|
||||
except Exception as e:
|
||||
print(f"Error initializing Docker client for cleanup: {e}")
|
||||
return
|
||||
|
||||
if cls._agent_container is not None:
|
||||
try:
|
||||
cls._agent_container.stop()
|
||||
cls._agent_container.remove()
|
||||
print(f"Cleaned up container using container object: {cls._container_name}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up container using object: {e}")
|
||||
|
||||
try:
|
||||
container = cls._docker_client.containers.get(cls._container_name)
|
||||
container.stop()
|
||||
container.remove()
|
||||
print(f"Cleaned up container by name: {cls._container_name}")
|
||||
except NotFound:
|
||||
print(f"Container {cls._container_name} not found during cleanup")
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up container by name: {e}")
|
||||
|
||||
print("Cleanup complete")
|
||||
|
||||
def test_create_agent_token(self):
|
||||
token = self._agent_token
|
||||
self.assertIsNotNone(token)
|
||||
|
||||
# JWT tokens have the format: jwt_agent_<prefix>_<token>
|
||||
self.assertTrue(token.startswith("jwt_agent_"), "Token should start with jwt_agent_")
|
||||
|
||||
# Test that it's a valid JWT format (should contain 2 dots in the JWT part)
|
||||
parts = token.split('_')
|
||||
self.assertGreaterEqual(len(parts), 3, "Token should have at least 3 parts separated by underscores")
|
||||
|
||||
# The actual JWT is after the second underscore
|
||||
jwt_part = parts[2]
|
||||
self.assertEqual(jwt_part.count('.'), 2, "JWT should contain exactly 2 dots")
|
||||
|
||||
# Check that the token contains three base64-encoded parts
|
||||
jwt_segments = jwt_part.split('.')
|
||||
self.assertEqual(len(jwt_segments), 3, "JWT should have 3 segments")
|
||||
for segment in jwt_segments:
|
||||
self.assertGreater(len(segment), 0, "JWT segment should not be empty")
|
||||
|
||||
# Decode the JWT payload (second segment)
|
||||
payload_base64 = jwt_segments[1]
|
||||
|
||||
# Add padding if needed
|
||||
padding_needed = len(payload_base64) % 4
|
||||
if padding_needed:
|
||||
payload_base64 += '=' * (4 - padding_needed)
|
||||
|
||||
# Base64 decode the payload
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_base64)
|
||||
payload_json = payload_bytes.decode('utf-8')
|
||||
payload = json.loads(payload_json)
|
||||
|
||||
# Check payload structure
|
||||
self.assertIn('worker_group', payload, "Payload should contain worker_group")
|
||||
self.assertEqual(payload['worker_group'], 'agent', "worker_group should be 'agent'")
|
||||
|
||||
self.assertIn('suffix', payload, "Payload should contain suffix")
|
||||
self.assertIsNone(payload['suffix'], "suffix should be null")
|
||||
|
||||
self.assertIn('tags', payload, "Payload should contain tags")
|
||||
self.assertIsInstance(payload['tags'], list, "tags should be a list")
|
||||
self.assertIn('agent', payload['tags'], "tags should contain 'agent'")
|
||||
|
||||
self.assertIn('exp', payload, "Payload should contain exp")
|
||||
self.assertIsInstance(payload['exp'], int, "exp should be an integer")
|
||||
|
||||
def test_agent_is_connected(self):
|
||||
"""Test that the agent is connected to the server."""
|
||||
workers = self._client.get_workers_list(ping_since=60)
|
||||
|
||||
# Find the agent worker
|
||||
agent_worker = None
|
||||
for worker in workers:
|
||||
if worker.get("worker_group") == "agent":
|
||||
agent_worker = worker
|
||||
break
|
||||
|
||||
self.assertIsNotNone(agent_worker, "Agent worker should be connected")
|
||||
self.assertEqual(agent_worker.get("worker_group"), "agent")
|
||||
|
||||
# Check tags
|
||||
tags = agent_worker.get("custom_tags", [])
|
||||
self.assertIn("agent", tags)
|
||||
self.assertIn("python3", tags)
|
||||
self.assertIn("bash", tags)
|
||||
self.assertIn("agent_test", tags)
|
||||
|
||||
def test_bash_script_on_agent(self):
|
||||
"""Test running a bash script on the agent worker."""
|
||||
# Create a simple bash script tagged to run on the agent
|
||||
script_path = "u/admin/agent_bash_test"
|
||||
script_content = """
|
||||
#!/bin/bash
|
||||
msg="$1"
|
||||
echo "Running on $(hostname)"
|
||||
echo "Argument received: $msg"
|
||||
echo $msg
|
||||
"""
|
||||
|
||||
# Create the script with the agent_test tag so it runs on our agent
|
||||
self._client.create_script(
|
||||
path=script_path,
|
||||
content=script_content,
|
||||
language="bash",
|
||||
tag="agent_test"
|
||||
)
|
||||
|
||||
try:
|
||||
# Run the script
|
||||
result = self._client.run_sync(script_path, {"msg": "Hello from agent test!"})
|
||||
|
||||
print(f"Script result: {result}")
|
||||
|
||||
# Verify the result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result, "Hello from agent test!")
|
||||
|
||||
finally:
|
||||
# Clean up the script
|
||||
self._client.delete_script(script_path)
|
||||
@@ -114,17 +114,23 @@ class WindmillClient:
|
||||
raise Exception(response.content.decode())
|
||||
return response.json()
|
||||
|
||||
def create_script(self, path: str, content: str, language: str):
|
||||
def create_script(self, path: str, content: str, language: str, tag: str = None):
|
||||
print(f"Creating script {path}")
|
||||
|
||||
payload = {
|
||||
"path": path,
|
||||
"content": content,
|
||||
"description": "",
|
||||
"summary": "",
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if tag is not None:
|
||||
payload["tag"] = tag
|
||||
|
||||
response = self._client.post(
|
||||
f"/api/w/{self._workspace}/scripts/create",
|
||||
json={
|
||||
"path": path,
|
||||
"content": content,
|
||||
"description": "",
|
||||
"summary": "",
|
||||
"language": language,
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code // 100 != 2:
|
||||
raise Exception(response.content.decode())
|
||||
@@ -252,3 +258,162 @@ class WindmillClient:
|
||||
def get_version(self):
|
||||
response = self._client.get("/api/version")
|
||||
return response.content.decode()
|
||||
|
||||
def get_global_custom_tags(self):
|
||||
"""
|
||||
Get the current list of global custom tags.
|
||||
|
||||
Returns:
|
||||
list: List of custom tags or empty list if not set or an error occurred.
|
||||
"""
|
||||
try:
|
||||
response = self._client.get("/api/settings/global/custom_tags")
|
||||
if response.status_code // 100 == 2:
|
||||
tags = response.json()
|
||||
return tags if tags is not None else []
|
||||
else:
|
||||
print(f"Error retrieving global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Exception when retrieving global custom tags: {e}")
|
||||
return []
|
||||
|
||||
def add_global_custom_tag(self, tag):
|
||||
"""
|
||||
Add a tag to the global custom tags if it's not already present.
|
||||
|
||||
Args:
|
||||
tag (str): The tag to add to global custom tags.
|
||||
|
||||
Returns:
|
||||
bool: True if the tag was added or already exists, False if there was an error.
|
||||
"""
|
||||
try:
|
||||
current_tags = self.get_global_custom_tags()
|
||||
|
||||
if tag in current_tags:
|
||||
print(f"Tag '{tag}' already exists in global custom tags")
|
||||
return True
|
||||
|
||||
new_tags = current_tags + [tag]
|
||||
print(f"Adding '{tag}' to global custom tags: {new_tags}")
|
||||
|
||||
response = self._client.post(
|
||||
"/api/settings/global/custom_tags",
|
||||
json={
|
||||
"value": new_tags,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code // 100 == 2:
|
||||
print(f"Successfully added '{tag}' to global custom tags")
|
||||
return True
|
||||
else:
|
||||
print(f"Error adding tag to global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Exception when adding global custom tag: {e}")
|
||||
return False
|
||||
|
||||
def remove_global_custom_tag(self, tag):
|
||||
"""
|
||||
Remove a tag from the global custom tags if it exists.
|
||||
|
||||
Args:
|
||||
tag (str): The tag to remove from global custom tags.
|
||||
|
||||
Returns:
|
||||
bool: True if the tag was removed or didn't exist, False if there was an error.
|
||||
"""
|
||||
try:
|
||||
current_tags = self.get_global_custom_tags()
|
||||
|
||||
if tag not in current_tags:
|
||||
print(f"Tag '{tag}' doesn't exist in global custom tags")
|
||||
return True
|
||||
|
||||
new_tags = [t for t in current_tags if t != tag]
|
||||
print(f"Removing '{tag}' from global custom tags: {new_tags}")
|
||||
|
||||
response = self._client.post(
|
||||
"/api/settings/global/custom_tags",
|
||||
json={
|
||||
"value": new_tags,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code // 100 == 2:
|
||||
print(f"Successfully removed '{tag}' from global custom tags")
|
||||
return True
|
||||
else:
|
||||
print(f"Error removing tag from global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Exception when removing global custom tag: {e}")
|
||||
return False
|
||||
|
||||
def get_workers_list(self, ping_since=60, page=0, per_page=100):
|
||||
"""
|
||||
Get a list of workers currently connected to the Windmill server.
|
||||
|
||||
Args:
|
||||
ping_since (int): Only include workers that have pinged in the last N seconds. Default is 60.
|
||||
page (int): Page number for pagination. Default is 0.
|
||||
per_page (int): Number of results per page. Default is 100.
|
||||
|
||||
Returns:
|
||||
list: List of worker objects or empty list if no workers found or an error occurred.
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"ping_since": ping_since
|
||||
}
|
||||
|
||||
response = self._client.get(
|
||||
"/api/workers/list",
|
||||
params=params
|
||||
)
|
||||
if response.status_code // 100 == 2:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"Error retrieving workers list: Status {response.status_code}, Response: {response.content.decode()}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Exception when retrieving workers list: {e}")
|
||||
return []
|
||||
|
||||
def create_agent_token(self, worker_group="agent", tags=None, exp=None):
|
||||
"""
|
||||
Create an agent JWT token using superadmin privilege.
|
||||
|
||||
Args:
|
||||
worker_group (str): The worker group for the agent, defaults to "agent"
|
||||
tags (list): Tags for the agent, defaults to ["agent"]
|
||||
exp (int): Expiration timestamp, defaults to a timestamp about 1 year in the future
|
||||
|
||||
Returns:
|
||||
str: The JWT token for the agent
|
||||
"""
|
||||
if tags is None:
|
||||
tags = ["agent"]
|
||||
|
||||
if exp is None:
|
||||
exp = int(time.time()) + 31536000 # 60*60*24*365 = 1 year
|
||||
|
||||
print(f"Creating agent token for worker_group={worker_group}, tags={tags}")
|
||||
response = self._client.post(
|
||||
"/api/agent_workers/create_agent_token",
|
||||
json={
|
||||
"worker_group": worker_group,
|
||||
"tags": tags,
|
||||
"exp": exp
|
||||
},
|
||||
)
|
||||
if response.status_code // 100 != 2:
|
||||
raise Exception(response.content.decode())
|
||||
|
||||
token = response.content.decode().strip('"')
|
||||
print(f"Created agent token: {token[:15]}...{token[-15:]}")
|
||||
return token
|
||||
|
||||
@@ -4,8 +4,8 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.485.1"
|
||||
wmill_pg = ">=1.485.1"
|
||||
wmill = ">=1.485.3"
|
||||
wmill_pg = ">=1.485.3"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.485.1
|
||||
version: 1.485.3
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.485.1'
|
||||
ModuleVersion = '1.485.3'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill-pg"
|
||||
version = "1.485.1"
|
||||
version = "1.485.3"
|
||||
description = "An extension client for the wmill client library focused on pg"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.485.1",
|
||||
"version": "1.485.3",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./client.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.485.1",
|
||||
"version": "1.485.3",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.485.1
|
||||
1.485.3
|
||||
|
||||
Reference in New Issue
Block a user