feat: assets as a primary concept (#6125)

* assets migration

* parse assets (duckdb)

* iterate on assets

* S3 object Preview

* remove pagination

* filterText

* better occurence list

* tweak

* assets in JobPreview

* clone impl

* AssetsDetectedBadge

* improve DbManagerButton + asset dropdown button

* edit resource btn

* warning when incorrect resource

* +Resource in DuckDB

* +S3 Object editor bar

* nit fix rename

* flow asset badge

* More Generic OnChange

* Highlight assets used in modules

* Show occurence count in flow

* Better UX, avoid moving parts

* nit

* Asset nodes

* move to dedicated Asset ctx

* fix layoutNodes not handling first assetsMap

* explore asset btn in flow asset node

* correct offset

* single computeAssetNodes function

* Fix y positioning of nodes with assets

* resource editor

* write mode node (ui)

* accessType in ctx + fix insert button positioning

* right positioning when mixing read and write nodes

* right positioning when mixing R and W assets

* Better layout fix algorithm

* listAssetsByUsage and asset nodes on transitive usages

* refactor + remove linkAssets

* Refactor to allow for custom R/W modes

* AssetsDropdownButton in flow script editor

* R/W/RW selection and changes node pos in flow

* layoutNodes doesnt need recompute now

* fix wrong assumption that nodes recompute when assets change

* r/w/rw multi toggle

* MultiToggle cool animation + clearable

* rename + 1px nit

* remove mini toggle button group, use ToggleButtonGroup

* Combinator parser that detects R / W asset context

* nit fix missing flex-1

* missing order by

* better ui indication for access type

* special x offset case when only one asset node for clarity

* parse getResource in TS with swc ecma parser

* support load and write s3 detection in TS

* Python asset parser

* support wmill api calls without special $res: or s3:// syntax

* detect out of context asset uris python

* do not use access type override when not ambiguous in flow graph

* parse_assets match case in rust

* AsRef<str> refactor

* From impl

* Save flow assets

* Save script asset usages + fixes + save fallback access types

* asset sub icon

* max total asset node width to avoid overlap

* small refactor

* don't parse comments in duckdb assets

* fix assets clearing on parse error

* fix script asset save in wrong place

* load initial asset fallback access types

* support variables

* ui fixes

* Support S3Object as URI in TS client

* support new syntax in python client

* Support +S3Object in EditorBar for TS and python

* Reduce resource requests in assets page

* import windmill client when necessary

* update s3Types.d.ts

* nit fix

* Show input resources and s3 objects as assets

* improve asset icons

* DarkModeObserver refactor

* asset page tabs

* Moved resource variables and s3object pages to assets tabs

* fetch resource usages

* Get variables usages

* move assets usage dropdown to component

* Revert "move assets usage dropdown to component"

This reverts commit 622ea4ab12.

* Revert "Get variables usages"

This reverts commit b11ced4e29.

* Revert "fetch resource usages"

This reverts commit aa5187ad4b.

* Revert "Moved resource variables and s3object pages to assets tabs"

This reverts commit 4430487be4.

* Revert "asset page tabs"

This reverts commit dacc2f0da5.

* move assets usage dropdown to component

* asset icon in asset pages

* tooltip

* details

* Storage selector in S3 File Picker

* make edge less opaque

* Refactor computeAssetNodes to separate in and out nodes

* AssetsOverflowedNode

* nits

* fix assets not being parsed in flows sometimes

* show asset kind and resource_type

* ui nits

* support res:// in duckdb

* add banner for old deployments

* Fix permissionning

* fix broken disable /enable all

* assets page view permission for operators

* Disable ExploreAssetButton for operators

* asset kind as subtitle

* do not spam getResource in assets page. prob. revert fail

* update assets page on workspace change

* reload storage names on ws change

* delete assets on archive / deletion

* sqlx prepare

* missing update when updating user

* add indexes on asset

* better message

* missing loadInit: false

* dead code

* use transaction

* typo

* update package.json

* update package.json

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2025-07-11 11:56:53 +02:00
committed by GitHub
parent 3d711a2664
commit 433341b295
88 changed files with 3058 additions and 263 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE asset SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "029b81eb00250eacded407b12bcfbab2b3f35354bdb9ef6e30281a4ff6235060"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": []
},
"hash": "1c5caaaa86e3488549cad179e992172315be5d53dcc266d713da01fd27f310b6"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "534c7337ed8f1bc0e17843d6caeaf7e36c72efaeb90f94fe3b2e53b953ba2f24"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f"
}
@@ -0,0 +1,51 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)\n VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable"
]
}
}
},
{
"Custom": {
"name": "asset_access_type",
"kind": {
"Enum": [
"r",
"w",
"rw"
]
}
}
},
"Varchar",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": []
},
"hash": "74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "78bb75578a880715fb482445883e0e762f289b7695f29bfd44fa23323c7e8523"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource' AND asset.path = resource.path AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "92f03f4df5e86eb40b255ad0f2cc85e0302c37b0f312366098104cd280a91ef6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758"
}
+13
View File
@@ -8198,6 +8198,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "notify"
version = "6.1.1"
@@ -14942,6 +14951,7 @@ dependencies = [
"url",
"uuid",
"windmill-macros",
"windmill-parser",
"windmill-parser-py",
"windmill-parser-sql",
"windmill-parser-ts",
@@ -15093,6 +15103,7 @@ version = "1.504.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
"rustpython-ast",
"rustpython-parser",
"serde_json",
"windmill-parser",
@@ -15144,8 +15155,10 @@ version = "1.504.0"
dependencies = [
"anyhow",
"lazy_static",
"nom 8.0.0",
"regex",
"regex-lite",
"serde",
"serde_json",
"windmill-parser",
]
@@ -0,0 +1,4 @@
DROP TABLE asset;
DROP TYPE ASSET_USAGE_KIND;
DROP TYPE ASSET_ACCESS_TYPE;
DROP TYPE ASSET_KIND;
@@ -0,0 +1,16 @@
CREATE TYPE ASSET_USAGE_KIND AS ENUM ('script', 'flow');
CREATE TYPE ASSET_ACCESS_TYPE AS ENUM ('r', 'w', 'rw');
CREATE TYPE ASSET_KIND AS ENUM ('s3object', 'resource', 'variable');
CREATE TABLE asset (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
path VARCHAR(255) NOT NULL,
kind ASSET_KIND NOT NULL,
usage_access_type ASSET_ACCESS_TYPE,
usage_path VARCHAR(255) NOT NULL,
usage_kind ASSET_USAGE_KIND NOT NULL,
PRIMARY KEY (workspace_id, path, kind, usage_path, usage_kind)
);
CREATE INDEX idx_asset_usage ON asset (workspace_id, usage_path, usage_kind);
CREATE INDEX idx_asset_kind_path ON asset (workspace_id, kind, path);
@@ -13,4 +13,5 @@ windmill-parser.workspace = true
rustpython-parser.workspace = true
itertools.workspace = true
serde_json.workspace = true
anyhow.workspace = true
anyhow.workspace = true
rustpython-ast = { version = "0.4.0", features = ["visitor"] }
@@ -0,0 +1,98 @@
use rustpython_ast::{Constant, Expr, ExprConstant, Visitor};
use rustpython_parser::{ast::Suite, Parse};
use windmill_parser::asset_parser::{
merge_assets, parse_asset_syntax, AssetKind, AssetUsageAccessType, ParseAssetsResult,
};
use AssetUsageAccessType::*;
pub fn parse_assets(input: &str) -> anyhow::Result<Vec<ParseAssetsResult<String>>> {
let ast = Suite::parse(input, "main.py")
.map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))?;
let mut assets_finder = AssetsFinder { assets: vec![] };
ast.into_iter()
.for_each(|stmt| assets_finder.visit_stmt(stmt));
Ok(merge_assets(assets_finder.assets))
}
struct AssetsFinder {
assets: Vec<ParseAssetsResult<String>>,
}
impl Visitor for AssetsFinder {
// visit_call_expr will not recurse if it detects an asset,
// so this will only be called when no further context was found
fn visit_expr_constant(&mut self, node: ExprConstant) {
match node.value {
Constant::Str(s) => {
if let Some((kind, path)) = parse_asset_syntax(&s) {
self.assets.push(ParseAssetsResult {
kind,
path: path.to_string(),
access_type: None,
});
}
}
_ => self.generic_visit_expr_constant(node),
}
}
fn visit_expr_call(&mut self, node: rustpython_ast::ExprCall) {
match self.visit_expr_call_inner(&node) {
Ok(_) => {}
Err(_) => self.generic_visit_expr_call(node),
}
}
}
impl AssetsFinder {
fn visit_expr_call_inner(&mut self, node: &rustpython_ast::ExprCall) -> Result<(), ()> {
let ident: String = node
.func
.as_name_expr()
.and_then(|o| o.id.parse().ok())
.or_else(|| {
node.func
.as_attribute_expr()
.and_then(|attr| attr.attr.parse().ok())
})
.ok_or(())?;
let (kind, access_type, arg) = match ident.as_str() {
"load_s3_file" => (AssetKind::S3Object, Some(R), Arg::Pos(0)),
"load_s3_file_reader" => (AssetKind::S3Object, Some(R), Arg::Pos(0)),
"write_s3_file" => (AssetKind::S3Object, Some(W), Arg::Pos(0)),
"get_resource" => (AssetKind::Resource, None, Arg::Pos(0)),
"set_resource" => (AssetKind::Resource, Some(W), Arg::Named("path")),
"get_boto3_connection_settings" => (AssetKind::Resource, None, Arg::Pos(0)),
"get_polars_connection_settings" => (AssetKind::Resource, None, Arg::Pos(0)),
"get_duckdb_connection_settings" => (AssetKind::Resource, None, Arg::Pos(0)),
"get_variable" => (AssetKind::Variable, Some(R), Arg::Pos(0)),
"set_variable" => (AssetKind::Variable, Some(W), Arg::Pos(0)),
_ => return Err(()),
};
let arg_val = match arg {
Arg::Pos(i) => node.args.get(i),
Arg::Named(name) => node
.keywords
.iter()
.find(|kw| kw.arg.as_deref() == Some(name))
.map(|kw| &kw.value),
};
match arg_val {
Some(Expr::Constant(ExprConstant { value: Constant::Str(value), .. })) => {
let path = parse_asset_syntax(&value).map(|(_, p)| p).unwrap_or(&value);
self.assets
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
}
_ => return Err(()),
};
Ok(())
}
}
enum Arg {
Pos(usize),
Named(&'static str),
}
@@ -20,6 +20,9 @@ use rustpython_parser::{
Parse,
};
pub mod asset_parser;
pub use asset_parser::parse_assets;
const FUNCTION_CALL: &str = "<function call>";
fn filter_non_main(code: &str, main_name: &str) -> String {
@@ -19,3 +19,5 @@ windmill-parser.workspace = true
anyhow.workspace = true
lazy_static.workspace = true
serde_json.workspace = true
serde.workspace = true
nom = "8.0.0"
@@ -0,0 +1,121 @@
use windmill_parser::asset_parser::{
merge_assets, AssetKind, AssetUsageAccessType, ParseAssetsResult,
};
use AssetUsageAccessType::*;
use nom::{
branch::alt,
bytes::complete::{tag, tag_no_case, take_while},
character::complete::{char, multispace0},
IResult, Parser,
};
pub fn parse_assets<'a>(input: &str) -> anyhow::Result<Vec<ParseAssetsResult<&str>>> {
let mut assets = Vec::new();
let mut remaining = input;
while !remaining.trim().is_empty() {
if let Ok((rest, _)) = parse_comment(remaining) {
remaining = rest; // skip comment
}
if let Ok((rest, res)) = parse_asset(remaining) {
assets.push(res);
remaining = rest;
} else {
remaining = &remaining[1..]; // skip 1 char and continue
}
}
Ok(merge_assets(assets))
}
fn parse_asset(input: &str) -> IResult<&str, ParseAssetsResult<&str>> {
alt((
parse_s3_object_read.map(|path| ParseAssetsResult {
path,
kind: AssetKind::S3Object,
access_type: Some(R),
}),
parse_s3_object_write.map(|path| ParseAssetsResult {
path,
kind: AssetKind::S3Object,
access_type: Some(W),
}),
// Parse ambiguous access_types at the end if we could not find precisely read or copy
parse_s3_object_lit.map(|path| ParseAssetsResult {
path,
kind: AssetKind::S3Object,
access_type: None,
}),
parse_resource_lit.map(|path| ParseAssetsResult {
path,
kind: AssetKind::Resource,
access_type: None,
}),
))
.parse(input)
}
/// Any expression that reads an s3 asset
fn parse_s3_object_read(input: &str) -> IResult<&str, &str> {
alt((parse_s3_object_read_fn, parse_s3_object_select_from)).parse(input)
}
/// Any expression that writes to an s3 asset
fn parse_s3_object_write(input: &str) -> IResult<&str, &str> {
// COPY (...) TO 's3://...'
let (input, _) = (tag_no_case("TO"), multispace0).parse(input)?;
let (input, path) = parse_s3_object_lit(input)?;
Ok((input, path))
}
/// read_parquet('s3://...')
fn parse_s3_object_read_fn(input: &str) -> IResult<&str, &str> {
let (input, _) = alt((
tag_no_case("read_parquet"),
tag_no_case("read_csv"),
tag_no_case("read_json"),
))
.parse(input)?;
let (input, _) = multispace0(input)?;
let (input, _) = char('(')(input)?;
let (input, _) = multispace0(input)?;
let (input, path) = parse_s3_object_lit(input)?;
let (input, _) = multispace0(input)?;
let (input, _) = char(')')(input)?;
Ok((input, path))
}
/// SELECT ... FROM 's3://...'
fn parse_s3_object_select_from(input: &str) -> IResult<&str, &str> {
let (input, _) = tag_no_case("FROM").parse(input)?;
let (input, _) = multispace0(input)?;
let (input, path) = parse_s3_object_lit(input)?;
Ok((input, path))
}
/// 's3://...'
fn parse_s3_object_lit(input: &str) -> IResult<&str, &str> {
let (input, _) = quote(input)?;
let (input, _) = tag("s3://").parse(input)?;
let (input, path) = take_while(|c| c != '\'' && c != '"')(input)?;
let (input, _) = quote(input)?;
Ok((input, path))
}
fn quote(input: &str) -> IResult<&str, char> {
alt((char('\''), char('\"'))).parse(input)
}
fn parse_resource_lit(input: &str) -> IResult<&str, &str> {
let (input, _) = quote(input)?;
let (input, _) = alt((tag("$res:"), tag("res://"))).parse(input)?;
let (input, path) = take_while(|c| c != '\'' && c != '"')(input)?;
let (input, _) = quote(input)?;
Ok((input, path))
}
fn parse_comment(input: &str) -> IResult<&str, &str> {
let (input, _) = tag("--").parse(input)?;
let (input, comment) = take_while(|c| c != '\n')(input)?;
Ok((input, comment))
}
@@ -19,6 +19,9 @@ pub use windmill_parser::{Arg, MainArgSignature, Typ};
pub const SANITIZED_ENUM_STR: &str = "__sanitized_enum__";
pub const SANITIZED_RAW_STRING_STR: &str = "__sanitized_raw_string__";
mod asset_parser;
pub use asset_parser::parse_assets;
pub fn parse_mysql_sig(code: &str) -> anyhow::Result<MainArgSignature> {
let parsed = parse_mysql_file(&code)?;
if let Some(x) = parsed {
@@ -0,0 +1,104 @@
use swc_common::{sync::Lrc, FileName, SourceMap};
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, Str};
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
use swc_ecma_visit::{Visit, VisitWith};
use windmill_parser::asset_parser::{
merge_assets, parse_asset_syntax, AssetKind, AssetUsageAccessType, ParseAssetsResult,
};
use AssetUsageAccessType::*;
pub fn parse_assets(code: &str) -> anyhow::Result<Vec<ParseAssetsResult<String>>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Typescript(TsSyntax::default()),
// EsVersion defaults to es5
Default::default(),
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let mut err_s = "".to_string();
for e in parser.take_errors() {
err_s += &e.into_kind().msg().to_string();
}
let ast = parser
.parse_module()
.map_err(|e| {
anyhow::anyhow!("Error while parsing code, it is invalid TypeScript: {err_s}, {e:?}")
})?
.body;
let mut assets_finder = AssetsFinder { assets: vec![] };
assets_finder.visit_module_items(&ast);
Ok(merge_assets(assets_finder.assets))
}
struct AssetsFinder {
assets: Vec<ParseAssetsResult<String>>,
}
impl Visit for AssetsFinder {
// visit_call_expr will not recurse if it detects an asset,
// so this will only be called when no further context was found
fn visit_lit(&mut self, node: &swc_ecma_ast::Lit) {
match node {
swc_ecma_ast::Lit::Str(str) => {
if let Some((kind, path)) = parse_asset_syntax(str.value.as_str()) {
self.assets.push(ParseAssetsResult {
kind,
path: path.to_string(),
access_type: None,
});
}
}
_ => <Lit as VisitWith<Self>>::visit_children_with(node, self),
}
}
fn visit_call_expr(&mut self, node: &swc_ecma_ast::CallExpr) {
match self.visit_call_expr_inner(node) {
Ok(_) => {}
Err(_) => <CallExpr as VisitWith<Self>>::visit_children_with(node, self),
}
}
}
impl AssetsFinder {
fn visit_call_expr_inner(&mut self, node: &swc_ecma_ast::CallExpr) -> Result<(), ()> {
let ident = match node.callee.as_expr().map(AsRef::as_ref) {
Some(Expr::Ident(i)) => i.sym.as_str(),
Some(Expr::Member(MemberExpr { prop: MemberProp::Ident(i), .. })) => i.sym.as_str(),
_ => return Err(()),
};
let (kind, access_type, arg_pos) = match ident {
"loadS3File" => (AssetKind::S3Object, Some(R), 0),
"loadS3FileStream" => (AssetKind::S3Object, Some(R), 0),
"writeS3File" => (AssetKind::S3Object, Some(W), 0),
"getResource" => (AssetKind::Resource, None, 0),
"setResource" => (AssetKind::Resource, Some(W), 1),
"databaseUrlFromResource" => (AssetKind::Resource, None, 0),
"denoS3LightClientSettings" => (AssetKind::Resource, None, 0),
"duckdbConnectionSettings" => (AssetKind::Resource, None, 0),
"polarsConnectionSettings" => (AssetKind::Resource, None, 0),
"getVariable" => (AssetKind::Variable, Some(R), 0),
"setVariable" => (AssetKind::Variable, Some(W), 0),
_ => return Err(()),
};
let arg_value = node.args.get(arg_pos);
match arg_value.map(|e| e.expr.as_ref()) {
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => {
let path = parse_asset_syntax(&value).map(|(_, p)| p).unwrap_or(&value);
self.assets
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
}
_ => return Err(()),
}
Ok(())
}
}
@@ -159,6 +159,9 @@ pub fn parse_expr_for_ids(code: &str) -> anyhow::Result<Vec<(String, String)>> {
Ok(visitor.idents.into_iter().collect())
}
pub mod asset_parser;
pub use asset_parser::parse_assets;
/// skip_params is a micro optimization for when we just want to find the main
/// function without parsing all the params.
pub fn parse_deno_signature(
-6
View File
@@ -1,6 +0,0 @@
{
"name": "windmill-parser-wasm",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -1 +0,0 @@
{}
@@ -168,4 +168,34 @@ pub fn parse_java(code: &str) -> String {
wrap_sig(windmill_parser_java::parse_java_signature(code))
}
#[cfg(feature = "sql-parser")]
#[wasm_bindgen]
pub fn parse_assets_sql(code: &str) -> String {
if let Ok(r) = windmill_parser_sql::parse_assets(code) {
return serde_json::to_string(&r).unwrap();
} else {
return "Invalid".to_string();
}
}
#[cfg(feature = "ts-parser")]
#[wasm_bindgen]
pub fn parse_assets_ts(code: &str) -> String {
if let Ok(r) = windmill_parser_ts::parse_assets(code) {
return serde_json::to_string(&r).unwrap();
} else {
return "Invalid".to_string();
}
}
#[cfg(feature = "py-parser")]
#[wasm_bindgen]
pub fn parse_assets_py(code: &str) -> String {
if let Ok(r) = windmill_parser_py::parse_assets(code) {
return serde_json::to_string(&r).unwrap();
} else {
return "Invalid".to_string();
}
}
// for related places search: ADD_NEW_LANG
@@ -0,0 +1,65 @@
use serde::Serialize;
#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all(serialize = "lowercase"))]
pub enum AssetUsageAccessType {
R,
W,
RW,
}
use AssetUsageAccessType::*;
#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all(serialize = "lowercase"))]
pub enum AssetKind {
S3Object,
Resource,
Variable,
}
#[derive(Serialize)]
pub struct ParseAssetsResult<S: AsRef<str>> {
pub kind: AssetKind,
pub path: S,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_type: Option<AssetUsageAccessType>, // None in case of ambiguity
}
pub fn merge_assets<S: AsRef<str>>(assets: Vec<ParseAssetsResult<S>>) -> Vec<ParseAssetsResult<S>> {
let mut arr: Vec<ParseAssetsResult<S>> = vec![];
for asset in assets {
// Remove duplicates
if let Some(existing) = arr
.iter_mut()
.find(|x| x.path.as_ref() == asset.path.as_ref() && x.kind == asset.kind)
{
// merge access types
existing.access_type = match (asset.access_type, existing.access_type) {
(None, _) | (_, None) => None,
(Some(R), Some(W)) | (Some(W), Some(R)) => Some(RW),
(Some(RW), _) | (_, Some(RW)) => Some(RW),
(Some(R), Some(R)) => Some(R),
(Some(W), Some(W)) => Some(W),
};
} else {
arr.push(asset);
}
}
arr.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref()));
arr
}
pub fn parse_asset_syntax(s: &str) -> Option<(AssetKind, &str)> {
if s.starts_with("s3://") {
Some((AssetKind::S3Object, &s[5..]))
} else if s.starts_with("res://") {
Some((AssetKind::Resource, &s[6..]))
} else if s.starts_with("$res:") {
Some((AssetKind::Resource, &s[5..]))
} else if s.starts_with("var://") {
Some((AssetKind::Variable, &s[6..]))
} else {
None
}
}
@@ -10,6 +10,8 @@ use convert_case::{Boundary, Case, Casing};
use serde::Serialize;
use serde_json::Value;
pub mod asset_parser;
#[derive(Serialize, Debug, PartialEq, Default)]
pub struct MainArgSignature {
pub star_args: bool,
+6
View File
@@ -1147,6 +1147,7 @@ async fn test_deno_flow(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}
.into(),
stop_after_if: Default::default(),
@@ -1189,6 +1190,7 @@ async fn test_deno_flow(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}
.into(),
stop_after_if: Default::default(),
@@ -1316,6 +1318,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}.into(),
stop_after_if: Default::default(),
@@ -1369,6 +1372,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
@@ -1407,6 +1411,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}.into(),
stop_after_if: Default::default(),
@@ -1470,6 +1475,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}.into(),
stop_after_if: Default::default(),
stop_after_all_iters_if: Default::default(),
+201 -34
View File
@@ -4971,7 +4971,8 @@ paths:
/scripts_u/tokened_raw/{workspace}/{token}/{path}:
get:
summary: raw script by path with a token (mostly used by lsp to be used with
summary:
raw script by path with a token (mostly used by lsp to be used with
import maps to resolve scripts)
operationId: rawScriptByPathTokened
tags:
@@ -6772,7 +6773,8 @@ paths:
schema:
type: string
- name: branch_or_iteration_n
description: for branchall or loop, the iteration at which the flow should
description:
for branchall or loop, the iteration at which the flow should
restart
required: true
in: path
@@ -8490,7 +8492,7 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/openapi/download:
post:
summary: Download the OpenAPI v3.1 spec as a file
@@ -9845,7 +9847,6 @@ paths:
schema:
type: string
/w/{workspace}/gcp_triggers/subscriptions/delete/{path}:
delete:
summary: delete gcp trigger
@@ -9931,7 +9932,7 @@ paths:
application/json:
schema:
type: string
/w/{workspace}/postgres_triggers/is_valid_postgres_configuration/{path}:
get:
summary: check if postgres configuration is set to logical
@@ -11383,7 +11384,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
sqs_trigger
sqs_trigger,
]
responses:
"200":
@@ -11427,7 +11428,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
sqs_trigger
sqs_trigger,
]
requestBody:
description: acl to add
@@ -11482,7 +11483,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
sqs_trigger
sqs_trigger,
]
requestBody:
description: acl to add
@@ -11861,7 +11862,8 @@ paths:
/w/{workspace}/job_helpers/duckdb_connection_settings:
post:
summary: Converts an S3 resource to the set of instructions necessary to connect
summary:
Converts an S3 resource to the set of instructions necessary to connect
DuckDB to an S3 bucket
operationId: duckdbConnectionSettings
tags:
@@ -11890,7 +11892,8 @@ paths:
type: string
/w/{workspace}/job_helpers/v2/duckdb_connection_settings:
post:
summary: Converts an S3 resource to the set of instructions necessary to connect
summary:
Converts an S3 resource to the set of instructions necessary to connect
DuckDB to an S3 bucket
operationId: duckdbConnectionSettingsV2
tags:
@@ -11926,7 +11929,8 @@ paths:
/w/{workspace}/job_helpers/polars_connection_settings:
post:
summary: Converts an S3 resource to the set of arguments necessary to connect
summary:
Converts an S3 resource to the set of arguments necessary to connect
Polars to an S3 bucket
operationId: polarsConnectionSettings
tags:
@@ -11970,7 +11974,8 @@ paths:
- client_kwargs
/w/{workspace}/job_helpers/v2/polars_connection_settings:
post:
summary: Converts an S3 resource to the set of arguments necessary to connect
summary:
Converts an S3 resource to the set of arguments necessary to connect
Polars to an S3 bucket
operationId: polarsConnectionSettingsV2
tags:
@@ -12047,7 +12052,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: S3 resource path to use. If empty, the S3 resource defined in the
description:
S3 resource path to use. If empty, the S3 resource defined in the
workspace settings will be used
required: true
content:
@@ -12960,6 +12966,94 @@ paths:
schema:
type: string
/w/{workspace}/assets/list:
get:
summary: List all assets in the workspace
operationId: listAssets
tags:
- asset
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: all assets in the workspace
content:
application/json:
schema:
type: array
items:
type: object
required: [path, kind, usages]
properties:
path:
type: string
kind:
$ref: "#/components/schemas/AssetKind"
usages:
type: array
items:
type: object
required: [path, kind]
properties:
path:
type: string
kind:
$ref: "#/components/schemas/AssetUsageKind"
access_type:
$ref: "#/components/schemas/AssetUsageAccessType"
metadata:
type: object
properties:
resource_type:
type: string
/w/{workspace}/assets/list_by_usages:
post:
summary: List all assets used by given usages paths
operationId: listAssetsByUsage
tags:
- asset
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: list assets by usages
required: true
content:
application/json:
schema:
type: object
required: [usages]
properties:
usages:
type: array
items:
type: object
required: [path, kind]
properties:
path:
type: string
kind:
$ref: "#/components/schemas/AssetUsageKind"
responses:
"200":
description: all assets used by the given usage paths, in the same order
content:
application/json:
schema:
type: array
items:
type: array
items:
type: object
required: [path, kind]
properties:
path:
type: string
kind:
$ref: "#/components/schemas/AssetKind"
access_type:
$ref: "#/components/schemas/AssetUsageAccessType"
components:
securitySchemes:
bearerAuth:
@@ -13106,7 +13200,8 @@ components:
type: string
ParentJob:
name: parent_job
description: The parent job that is at the origin and responsible for the execution
description:
The parent job that is at the origin and responsible for the execution
of this script if any
in: query
schema:
@@ -13126,7 +13221,8 @@ components:
type: string
NewJobId:
name: job_id
description: The job id to assign to the created job. if missing, job is chosen
description:
The job id to assign to the created job. if missing, job is chosen
randomly using the ULID scheme. If a job id already exists in the queue
or as a completed job, the request to create one will fail (Bad Request)
in: query
@@ -13217,7 +13313,8 @@ components:
format: date-time
CreatedOrStartedAfter:
name: created_or_started_after
description: filter on created_at for non non started job and started_at otherwise
description:
filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp
in: query
schema:
@@ -13225,7 +13322,8 @@ components:
format: date-time
CreatedOrStartedAfterCompletedJob:
name: created_or_started_after_completed_jobs
description: filter on created_at for non non started job and started_at otherwise
description:
filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp but only for the completed jobs
in: query
schema:
@@ -13233,7 +13331,8 @@ components:
format: date-time
CreatedOrStartedBefore:
name: created_or_started_before
description: filter on created_at for non non started job and started_at otherwise
description:
filter on created_at for non non started job and started_at otherwise
before (inclusive) timestamp
in: query
schema:
@@ -13321,7 +13420,8 @@ components:
enum: [Create, Update, Delete, Execute]
JobKinds:
name: job_kinds
description: filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
description:
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
separated by,
in: query
schema:
@@ -13416,7 +13516,19 @@ components:
AIProvider:
type: string
enum: [openai, azure_openai, anthropic, mistral, deepseek, googleai, groq, openrouter, togetherai, customai]
enum:
[
openai,
azure_openai,
anthropic,
mistral,
deepseek,
googleai,
groq,
openrouter,
togetherai,
customai,
]
AIProviderModel:
type: object
@@ -13483,7 +13595,7 @@ components:
alerts:
type: array
items:
$ref: '#/components/schemas/Alert'
$ref: "#/components/schemas/Alert"
Script:
type: object
@@ -13657,6 +13769,21 @@ components:
type: boolean
on_behalf_of_email:
type: string
fallback_access_types:
type: array
items:
type: object
required: [path, kind, access_type]
properties:
path:
type: string
kind:
type: string
enum: [s3object, resource]
access_type:
type: string
enum: [r, w, rw]
required:
- path
- summary
@@ -13804,7 +13931,7 @@ components:
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
"appscript",
]
schedule_path:
type: string
@@ -13911,7 +14038,7 @@ components:
"singlescriptflow",
"flowscript",
"flownode",
"appscript"
"appscript",
]
schedule_path:
type: string
@@ -14363,7 +14490,7 @@ components:
"bytes",
"dict",
"datetime",
"sql"
"sql",
]
- type: object
properties:
@@ -14403,7 +14530,7 @@ components:
"bytes",
"dict",
"datetime",
"sql"
"sql",
]
- type: object
properties:
@@ -14429,7 +14556,7 @@ components:
"bytes",
"dict",
"datetime",
"sql"
"sql",
]
- type: object
properties:
@@ -14482,7 +14609,7 @@ components:
csharp,
nu,
java,
duckdb
duckdb,
# for related places search: ADD_NEW_LANG
]
@@ -14957,7 +15084,7 @@ components:
- user_or_folder_regex_value
- path
- runnable_kind
OpenapiV3Info:
type: object
properties:
@@ -15537,7 +15664,6 @@ components:
- delivery_type
- subscription_mode
SubscriptionMode:
type: string
enum:
@@ -15545,7 +15671,6 @@ components:
- create_update
description: "The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new subscription."
GcpTriggerData:
type: object
properties:
@@ -15587,7 +15712,6 @@ components:
required:
- topic_id
DeleteGcpSubscription:
type: object
properties:
@@ -16583,7 +16707,14 @@ components:
properties:
type:
type: string
enum: ["S3Storage", "AzureBlobStorage", "AzureWorkloadIdentity", "S3AwsOidc", "GoogleCloudStorage"]
enum:
[
"S3Storage",
"AzureBlobStorage",
"AzureWorkloadIdentity",
"S3AwsOidc",
"GoogleCloudStorage",
]
s3_resource_path:
type: string
azure_blob_resource_path:
@@ -16600,7 +16731,13 @@ components:
type:
type: string
enum:
["S3Storage", "AzureBlobStorage", "AzureWorkloadIdentity", "S3AwsOidc", "GoogleCloudStorage"]
[
"S3Storage",
"AzureBlobStorage",
"AzureWorkloadIdentity",
"S3AwsOidc",
"GoogleCloudStorage",
]
s3_resource_path:
type: string
azure_blob_resource_path:
@@ -16990,7 +17127,8 @@ components:
CaptureTriggerKind:
type: string
enum: [webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp]
enum:
[webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp]
Capture:
type: object
@@ -17031,6 +17169,7 @@ components:
- runs
- schedules
- resources
- assets
- variables
- triggers
- audit_logs
@@ -17050,6 +17189,9 @@ components:
variables:
type: boolean
description: Whether operators can view variables
assets:
type: boolean
description: Whether operators can view assets
audit_logs:
type: boolean
description: Whether operators can view audit logs
@@ -17188,3 +17330,28 @@ components:
type: string
description: Microsoft Teams channel name
minLength: 1
AssetUsageKind:
type: string
enum:
- script
- flow
AssetUsageAccessType:
type: string
enum:
- r
- w
- rw
AssetKind:
type: string
enum:
- s3object
- resource
- variable
Asset:
type: object
properties:
path:
type: string
kind:
$ref: "#/components/schemas/AssetKind"
required: [path, kind]
+96
View File
@@ -0,0 +1,96 @@
use axum::{
extract::Path,
routing::{get, post},
Extension, Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
use windmill_common::{assets::AssetUsageKind, db::UserDB, error::JsonResult};
use crate::db::ApiAuthed;
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_assets))
.route("/list_by_usages", post(list_assets_by_usages))
}
async fn list_assets(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<Value>> {
let assets = sqlx::query_scalar!(
r#"SELECT
jsonb_strip_nulls(jsonb_build_object(
'path', asset.path,
'kind', asset.kind,
'usages', ARRAY_AGG(jsonb_build_object(
'path', asset.usage_path,
'kind', asset.usage_kind,
'access_type', asset.usage_access_type
)),
'metadata', (CASE
WHEN asset.kind = 'resource' THEN
jsonb_build_object('resource_type', resource.resource_type)
ELSE
NULL
END
)
)) as "list!: _"
FROM asset
LEFT JOIN resource ON asset.kind = 'resource' AND asset.path = resource.path AND resource.workspace_id = $1
WHERE asset.workspace_id = $1
AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)
AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))
AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))
GROUP BY asset.path, asset.kind, resource.resource_type
ORDER BY asset.path, asset.kind"#,
w_id,
)
.fetch_all(&mut *user_db.begin(&authed).await?)
.await?;
Ok(Json(assets))
}
#[derive(Deserialize)]
pub struct ListAssetsByUsagesBodyInner {
kind: AssetUsageKind,
path: String,
}
#[derive(Deserialize)]
struct ListAssetsByUsagesBody {
usages: Vec<ListAssetsByUsagesBodyInner>,
}
async fn list_assets_by_usages(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Json(body): Json<ListAssetsByUsagesBody>,
) -> JsonResult<Vec<Vec<Value>>> {
let mut tx = user_db.begin(&authed).await?;
let mut assets_vec = vec![];
for usage in body.usages {
let assets = sqlx::query_scalar!(
r#"SELECT
jsonb_build_object(
'path', path,
'kind', kind,
'access_type', usage_access_type
) as "list!: _"
FROM asset
WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3
ORDER BY path, kind"#,
w_id,
usage.path,
usage.kind as AssetUsageKind
)
.fetch_all(&mut *tx)
.await?;
assets_vec.push(assets);
}
Ok(Json(assets_vec))
}
+9
View File
@@ -1168,6 +1168,14 @@ async fn archive_flow_by_path(
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2",
&w_id,
path
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -1370,6 +1378,7 @@ mod tests {
concurrent_limit: None,
concurrency_time_window_s: None,
is_trigger: None,
asset_fallback_access_types: None,
}),
stop_after_if: Some(StopAfterIf {
expr: "foo = 'bar'".to_string(),
+2
View File
@@ -72,6 +72,7 @@ mod agent_workers_oss;
mod ai;
mod apps;
pub mod args;
mod assets;
mod audit;
pub mod auth;
mod capture;
@@ -564,6 +565,7 @@ pub async fn run_server(
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/assets", assets::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest(
+52 -1
View File
@@ -42,7 +42,11 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_worker::process_relative_imports;
use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED};
use windmill_common::{
assets::{clear_asset_usage, insert_asset_usage, parse_assets, AssetUsageKind},
error::to_anyhow,
worker::CLOUD_HOSTED,
};
use windmill_common::{
db::UserDB,
@@ -659,6 +663,15 @@ async fn create_script_internal<'c>(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)",
&w_id,
p_hash.0
)
.execute(&mut *tx)
.await?;
r
}
}?;
@@ -908,6 +921,19 @@ async fn create_script_internal<'c>(
);
}
clear_asset_usage(&mut *tx, &w_id, &script_path, AssetUsageKind::Script).await?;
for asset in parse_assets(&ns.content, ns.language)?.iter().flatten() {
insert_asset_usage(
&mut *tx,
&w_id,
asset,
ns.fallback_access_types.as_ref().map(Vec::as_slice),
&ns.path,
AssetUsageKind::Script,
)
.await?;
}
let permissioned_as = username_to_permissioned_as(&authed.username);
if needs_lock_gen {
let tag = if ns.dedicated_worker.is_some_and(|x| x) {
@@ -1552,6 +1578,15 @@ async fn archive_script_by_path(
.fetch_one(&db)
.await
.map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?;
sqlx::query!(
"DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2",
&w_id,
path
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -1603,6 +1638,14 @@ async fn archive_script_by_hash(
.await
.map_err(|e| Error::internal_err(format!("archiving script in {w_id}: {e:#}")))?;
sqlx::query!(
"DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)",
&w_id,
&hash.0
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -1643,6 +1686,14 @@ async fn delete_script_by_hash(
.await
.map_err(|e| Error::internal_err(format!("deleting script by hash {w_id}: {e:#}")))?;
sqlx::query!(
"DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)",
&w_id,
hash.0
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
+10 -4
View File
@@ -1280,10 +1280,7 @@ async fn join_workspace<'c>(
Ok((tx, username))
}
async fn leave_instance(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> Result<String> {
async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
.execute(&mut *tx)
@@ -2643,6 +2640,15 @@ async fn update_username_in_workpsace<'c>(
).execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE flow_node SET path = REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
+1
View File
@@ -2195,6 +2195,7 @@ struct ChangeOperatorSettings {
schedules: bool,
resources: bool,
variables: bool,
assets: bool,
triggers: bool,
audit_logs: bool,
groups: bool,
@@ -198,6 +198,14 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE asset SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE flow_node SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
+1
View File
@@ -75,6 +75,7 @@ windmill-macros.workspace = true
windmill-parser-sql.workspace = true
windmill-parser-ts.workspace = true
windmill-parser-py.workspace = true
windmill-parser.workspace = true
jsonwebtoken.workspace = true
backon.workspace = true
openidconnect = { workspace = true, optional = true }
+148
View File
@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use sqlx::PgExecutor;
use windmill_parser::asset_parser::ParseAssetsResult;
use crate::{error, scripts::ScriptLang};
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type)]
#[sqlx(type_name = "ASSET_KIND", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum AssetKind {
S3Object,
Resource,
Variable,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type)]
#[sqlx(type_name = "ASSET_USAGE_KIND", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum AssetUsageKind {
Script,
Flow,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type)]
#[sqlx(type_name = "ASSET_ACCESS_TYPE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum AssetUsageAccessType {
R,
W,
RW,
}
pub struct Asset {
pub path: String,
pub kind: AssetKind,
}
pub struct AssetUsage {
pub path: String,
pub kind: AssetUsageKind,
pub access_type: AssetUsageAccessType,
}
#[derive(Serialize, Deserialize, Debug, Clone, Hash)]
pub struct AssetWithAccessType {
pub path: String,
pub kind: AssetKind,
pub access_type: AssetUsageAccessType,
}
pub fn parse_assets(
input: &str,
lang: ScriptLang,
) -> anyhow::Result<Option<Vec<ParseAssetsResult<String>>>> {
let r = match lang {
ScriptLang::Python3 => windmill_parser_py::parse_assets(input),
ScriptLang::DuckDb => windmill_parser_sql::parse_assets(input).map(|a| {
a.iter()
.map(|a| ParseAssetsResult {
path: a.path.to_string(),
access_type: a.access_type,
kind: a.kind,
})
.collect()
}),
ScriptLang::Deno | ScriptLang::Bun | ScriptLang::Nativets => {
windmill_parser_ts::parse_assets(input)
}
_ => return Ok(None),
};
return r.map(Some);
}
impl From<windmill_parser::asset_parser::AssetKind> for AssetKind {
fn from(kind: windmill_parser::asset_parser::AssetKind) -> Self {
match kind {
windmill_parser::asset_parser::AssetKind::S3Object => AssetKind::S3Object,
windmill_parser::asset_parser::AssetKind::Resource => AssetKind::Resource,
windmill_parser::asset_parser::AssetKind::Variable => AssetKind::Variable,
}
}
}
impl From<windmill_parser::asset_parser::AssetUsageAccessType> for AssetUsageAccessType {
fn from(access_type: windmill_parser::asset_parser::AssetUsageAccessType) -> Self {
match access_type {
windmill_parser::asset_parser::AssetUsageAccessType::R => AssetUsageAccessType::R,
windmill_parser::asset_parser::AssetUsageAccessType::W => AssetUsageAccessType::W,
windmill_parser::asset_parser::AssetUsageAccessType::RW => AssetUsageAccessType::RW,
}
}
}
pub async fn insert_asset_usage<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
parsed_asset: &ParseAssetsResult<String>,
fallback_access_types: Option<&[AssetWithAccessType]>,
usage_path: &str,
usage_kind: AssetUsageKind,
) -> error::Result<()> {
let kind: AssetKind = parsed_asset.kind.into();
let asset_alternative_access_type = || {
fallback_access_types
.as_ref()
.and_then(|v| {
v.iter()
.find(|a| a.kind == kind && a.path == parsed_asset.path)
})
.map(|a| a.access_type)
};
let access_type: Option<AssetUsageAccessType> = parsed_asset
.access_type
.map(Into::into)
.or_else(asset_alternative_access_type);
sqlx::query!(
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING"#,
workspace_id,
parsed_asset.path,
kind as AssetKind,
access_type as Option<AssetUsageAccessType>,
usage_path,
usage_kind as AssetUsageKind
)
.execute(executor)
.await?;
Ok(())
}
pub async fn clear_asset_usage<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
usage_path: &str,
usage_kind: AssetUsageKind,
) -> error::Result<()> {
sqlx::query!(
r#"DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3"#,
workspace_id,
usage_path,
usage_kind as AssetUsageKind
)
.execute(executor)
.await?;
Ok(())
}
+6
View File
@@ -18,6 +18,7 @@ use sqlx::types::Json;
use sqlx::types::JsonRawValue;
use crate::{
assets::AssetWithAccessType,
cache,
error::Error,
more_serde::{default_empty_string, default_id, default_null, default_true, is_default},
@@ -502,6 +503,8 @@ pub enum FlowModuleValue {
concurrency_time_window_s: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
is_trigger: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
asset_fallback_access_types: Option<Vec<AssetWithAccessType>>,
},
Identity,
// Internal only, never exposed to the frontend.
@@ -555,6 +558,7 @@ struct UntaggedFlowModuleValue {
id: Option<FlowNodeId>,
default_node: Option<FlowNodeId>,
modules_node: Option<FlowNodeId>,
asset_fallback_access_types: Option<Vec<AssetWithAccessType>>,
}
impl<'de> Deserialize<'de> for FlowModuleValue {
@@ -629,6 +633,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
concurrent_limit: untagged.concurrent_limit,
concurrency_time_window_s: untagged.concurrency_time_window_s,
is_trigger: untagged.is_trigger,
asset_fallback_access_types: untagged.asset_fallback_access_types,
}),
"flowscript" => Ok(FlowModuleValue::FlowScript {
input_transforms: untagged.input_transforms.unwrap_or_default(),
@@ -800,6 +805,7 @@ pub async fn resolve_module(
concurrent_limit,
concurrency_time_window_s,
is_trigger,
asset_fallback_access_types: None,
};
}
ForloopFlow { modules, modules_node, .. } | WhileloopFlow { modules, modules_node, .. } => {
+1
View File
@@ -26,6 +26,7 @@ use sqlx::{Pool, Postgres};
pub mod agent_workers;
pub mod apps;
pub mod assets;
pub mod auth;
#[cfg(feature = "benchmark")]
pub mod bench;
+2
View File
@@ -13,6 +13,7 @@ use std::{
};
use crate::{
assets::AssetWithAccessType,
error::{to_anyhow, Error},
utils::http_get_from_hub,
DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL,
@@ -348,6 +349,7 @@ pub struct NewScript {
pub codebase: Option<String>,
pub has_preprocessor: Option<bool>,
pub on_behalf_of_email: Option<String>,
pub fallback_access_types: Option<Vec<AssetWithAccessType>>,
}
fn lock_deserialize<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
@@ -431,14 +431,14 @@ struct ParsedAttachDbResource<'a> {
}
fn parse_attach_db_resource<'a>(query: &'a str) -> Option<ParsedAttachDbResource<'a>> {
lazy_static::lazy_static! {
static ref RE: regex::Regex = regex::Regex::new(r"ATTACH '\$res:([^']+)' AS (\S+) \(TYPE (\w+)(.*)\)").unwrap();
static ref RE: regex::Regex = regex::Regex::new(r"ATTACH '(\$res:|res://)([^']+)' AS (\S+) \(TYPE (\w+)(.*)\)").unwrap();
}
for cap in RE.captures_iter(query) {
if let (Some(resource_path), Some(name), Some(db_type)) =
(cap.get(1), cap.get(2), cap.get(3))
(cap.get(2), cap.get(3), cap.get(4))
{
let extra_args = cap.get(4).map(|m| query[m.start()..m.end()].trim());
let extra_args = cap.get(5).map(|m| query[m.start()..m.end()].trim());
return Some(ParsedAttachDbResource {
resource_path: query[resource_path.start()..resource_path.end()].trim(),
name: query[name.start()..name.end()].trim(),
@@ -11,6 +11,9 @@ use serde_json::{json, Value};
use sha2::Digest;
use sqlx::types::Json;
use uuid::Uuid;
use windmill_common::assets::{
clear_asset_usage, insert_asset_usage, parse_assets, AssetUsageKind,
};
use windmill_common::error::Error;
use windmill_common::error::Result;
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowNodeId};
@@ -718,6 +721,8 @@ pub async fn handle_flow_dependency_job(
.execute(&mut *tx)
.await?;
}
clear_asset_usage(&mut *tx, &job.workspace_id, &job_path, AssetUsageKind::Flow).await?;
let modified_ids;
let errors;
(flow.modules, tx, modified_ids, errors) = lock_modules(
@@ -929,6 +934,7 @@ async fn lock_modules<'c>(
concurrent_limit,
concurrency_time_window_s,
is_trigger,
asset_fallback_access_types,
} = e.get_value()?
else {
match e.get_value()? {
@@ -1116,6 +1122,18 @@ async fn lock_modules<'c>(
continue;
};
for asset in parse_assets(&content, language)?.iter().flatten() {
insert_asset_usage(
&mut *tx,
&job.workspace_id,
asset,
asset_fallback_access_types.as_ref().map(Vec::as_slice),
job_path,
AssetUsageKind::Flow,
)
.await?;
}
if let Some(locks_to_reload) = locks_to_reload {
if !locks_to_reload.contains(&e.id) {
new_flow_modules.push(e);
@@ -1211,6 +1229,7 @@ async fn lock_modules<'c>(
None
}
};
e.value = windmill_common::worker::to_raw_value(&FlowModuleValue::RawScript {
lock,
path,
@@ -1222,8 +1241,10 @@ async fn lock_modules<'c>(
concurrent_limit,
concurrency_time_window_s,
is_trigger,
asset_fallback_access_types,
});
new_flow_modules.push(e);
continue;
}
+12 -12
View File
@@ -78,10 +78,10 @@
"windmill-parser-wasm-java": "^1.478.1",
"windmill-parser-wasm-nu": "^1.474.1",
"windmill-parser-wasm-php": "^1.429.0",
"windmill-parser-wasm-py": "^1.499.0",
"windmill-parser-wasm-regex": "^1.492.1",
"windmill-parser-wasm-py": "^1.504.0",
"windmill-parser-wasm-regex": "^1.504.0",
"windmill-parser-wasm-rust": "^1.429.0",
"windmill-parser-wasm-ts": "^1.486.1",
"windmill-parser-wasm-ts": "^1.504.0",
"windmill-parser-wasm-yaml": "^1.429.0",
"windmill-sql-datatype-parser-wasm": "^1.318.0",
"xterm": "^5.3.0",
@@ -12963,14 +12963,14 @@
"integrity": "sha512-SGJAtNpfdRZftkGboxWsm/yQDnJBJodwPQUbX2cWk/aoNook6ULesZwsYtBC9WN1VH6TIskLiVPohMmu6jtXmw=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.499.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.499.0.tgz",
"integrity": "sha512-ur5SU+YTuYm1Hgo+SWohYRsmkVxIZPME7GzzRivPhkK5XBQcc7PsxlMjYkIvFJwufz+jX2t6p0aYNIdDXpMNbA=="
"version": "1.504.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.504.0.tgz",
"integrity": "sha512-LHMqrR6zQXFCHez6XExztoMe00v8x4HCDws05m9RN8+XZdAqqzGNWu0pmNG+c/JDOAPtXwGYADrU/DxECwTf6Q=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.492.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.492.1.tgz",
"integrity": "sha512-CBdjz3x00z2xA4BRZv8/hsuHcpZgL28UKTjYVSj5O9XMYlY9g0LNsEQ5TTl/oVyFBs62/CPyvWwGvW4oSAObzg=="
"version": "1.504.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.504.0.tgz",
"integrity": "sha512-WY9XR8GBSeR3xPm9Dw1F0Kk2wvebZdUeS9obo4roEjsrltguZh10xDC7b5hrvksl9YFMvoWXmgFt9PX4FV/tYg=="
},
"node_modules/windmill-parser-wasm-rust": {
"version": "1.429.0",
@@ -12978,9 +12978,9 @@
"integrity": "sha512-c8mjpiw8RxoaBDtecb+sKeWM/IOjNr4Y06nHudGu8sMM48MNO1LhgcISLv8wl6Z9zWd7OzQrECJ6RLorpii5Uw=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.486.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.486.1.tgz",
"integrity": "sha512-nv7nPpZA5O0Zsve6W2Sm3mxCSxMqg2rfyYHNqozIpBaGhbe8SpgnbyrSaXKsGKSc/bpwpANHE7nVCp23pQUK6Q=="
"version": "1.504.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.504.0.tgz",
"integrity": "sha512-JsNw5I/Kxk4fCh8ScaDQDOqAFaH7r40vuo6rHrD4mqN46fozsUcWMEgecz4JxAtrYlYtogTj+1sP43guY/bf8A=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.429.0",
+4 -4
View File
@@ -145,10 +145,10 @@
"windmill-parser-wasm-java": "^1.478.1",
"windmill-parser-wasm-nu": "^1.474.1",
"windmill-parser-wasm-php": "^1.429.0",
"windmill-parser-wasm-py": "^1.499.0",
"windmill-parser-wasm-regex": "^1.492.1",
"windmill-parser-wasm-py": "^1.504.0",
"windmill-parser-wasm-regex": "^1.504.0",
"windmill-parser-wasm-rust": "^1.429.0",
"windmill-parser-wasm-ts": "^1.486.1",
"windmill-parser-wasm-ts": "^1.504.0",
"windmill-parser-wasm-yaml": "^1.429.0",
"windmill-sql-datatype-parser-wasm": "^1.318.0",
"xterm": "^5.3.0",
@@ -541,4 +541,4 @@
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
"fsevents": "^2.3.3"
}
}
}
@@ -4,7 +4,7 @@
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 { ArrowLeft, Expand, Loader2, Minimize, RefreshCcw } from 'lucide-svelte'
import {
dbSupportsSchemas,
getDbSchemas,
@@ -15,7 +15,6 @@
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 { runScriptAndPollResult } from './jobs/utils'
@@ -24,22 +23,24 @@
import SimpleAgTable from './SimpleAgTable.svelte'
import { untrack } from 'svelte'
type Props = {
resourceType: DbType
resourcePath: string
class?: string
let resourceType: DbType | undefined = $state(undefined)
let resourcePath: string | undefined = $state(undefined)
let open = $derived(resourcePath && resourceType)
export function openDrawer(_resourceType: DbType, _resourcePath: string) {
resourceType = _resourceType
resourcePath = _resourcePath
getSchema()
}
export function closeDrawer() {
resourceType = undefined
resourcePath = undefined
refreshCount = 0
refreshing = false
}
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]
resourcePath && resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
)
// `refreshCount` is a derived state. `refreshing` is the source of truth
@@ -56,11 +57,11 @@
let expand = $state(false)
$effect(() => {
if (!isDrawerOpen) expand = false
if (!open) expand = false
})
async function getSchema() {
if ($dbSchemas[resourcePath] && !refreshing) return
if (!resourcePath || !resourceType || ($dbSchemas[resourcePath] && !refreshing)) return
try {
const oldDbSchema = $dbSchemas[resourcePath]
await getDbSchemas(
@@ -69,9 +70,7 @@
$workspaceStore,
$dbSchemas,
(message: string) => {
if (isDrawerOpen) {
sendUserToast(message, true)
}
if (open) sendUserToast(message, true)
}
)
// avoid infinite loop on error due to the way getDbSchemas is implemented
@@ -95,6 +94,8 @@
let cachedLastRefreshCount = 0
async function getColDefs(tableKey: string) {
if (!resourcePath || !resourceType) return []
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
cachedLastRefreshCount = refreshCount
if (cachedColDefs[tableKey]) {
@@ -130,37 +131,26 @@
}}
/>
{#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>
<Drawer
bind:open
size={expand ? `${windowWidth}px` : '1200px'}
preventEscape
on:close={closeDrawer}
>
{#key [resourceType, resourcePath, dbSchema]}
<DrawerContent
title={replResultData ? 'Query Result' : 'Database Manager'}
on:close={() => {
if (replResultData) {
replResultData = undefined
} else {
isDrawerOpen = false
closeDrawer()
}
}}
CloseIcon={replResultData ? ArrowLeft : undefined}
noPadding
>
{#if dbSchema && $workspaceStore}
{#if dbSchema && $workspaceStore && resourceType && resourcePath}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -191,8 +181,8 @@
dbTableOpsWithPreviewScripts({
colDefs,
tableKey,
resourcePath,
resourceType,
resourcePath: resourcePath!,
resourceType: resourceType!,
workspace: $workspaceStore
})}
dbTableActionsFactory={[
@@ -204,16 +194,16 @@
]}
{refresh}
dbTableEditorPropsFactory={({ selectedSchemaKey }) => ({
resourceType,
resourceType: resourceType!,
previewSql: (values) =>
makeCreateTableQuery(values, resourceType, selectedSchemaKey),
makeCreateTableQuery(values, resourceType!, selectedSchemaKey),
async onConfirm(values) {
await runScriptAndPollResult({
workspace: $workspaceStore,
requestBody: {
args: { database: '$res:' + resourcePath },
content: makeCreateTableQuery(values, resourceType, selectedSchemaKey),
language: getLanguageByResourceType(resourceType)
content: makeCreateTableQuery(values, resourceType!, selectedSchemaKey),
language: getLanguageByResourceType(resourceType!)
}
})
refresh()
@@ -268,5 +258,5 @@
/>
{/snippet}
</DrawerContent>
</Drawer>
{/if}
{/key}
</Drawer>
@@ -1,30 +1,49 @@
<script module lang="ts">
import type { StateStore } from '$lib/utils'
export function useIsDarkMode({
onChange
}: {
onChange?: (newDarkMode: boolean) => void
} = {}): StateStore<boolean> {
let isDarkMode: StateStore<boolean> = $state({ val: false })
let observer: MutationObserver | undefined = undefined
onMount(() => {
isDarkMode.val = document.documentElement.classList.contains('dark')
observer = new MutationObserver((mutationsList) => {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const newDarkMode = document.documentElement.classList.contains('dark')
onChange?.(newDarkMode)
isDarkMode.val = newDarkMode
}
}
})
observer.observe(document.documentElement, { attributes: true })
})
onDestroy(() => {
observer?.disconnect()
})
return isDarkMode
}
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { createEventDispatcher } from 'svelte'
export let darkMode: boolean = false
let { darkMode = $bindable(false) }: { darkMode?: boolean } = $props()
const dispatch = createEventDispatcher()
let observer: MutationObserver | undefined = undefined
onMount(() => {
darkMode = document.documentElement.classList.contains('dark')
observer = new MutationObserver((mutationsList, observer) => {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const newDarkMode = document.documentElement.classList.contains('dark')
dispatch('change', newDarkMode)
darkMode = newDarkMode
}
}
})
observer.observe(document.documentElement, { attributes: true })
let isDarkMode = useIsDarkMode({
onChange: (newDarkMode) => dispatch('change', newDarkMode)
})
onDestroy(() => {
observer?.disconnect()
$effect(() => {
if (darkMode !== isDarkMode.val) {
darkMode = isDarkMode.val
}
})
</script>
+76 -20
View File
@@ -29,6 +29,7 @@
import {
DiffIcon,
DollarSign,
File,
History,
Library,
Link,
@@ -38,7 +39,7 @@
Save,
Users
} from 'lucide-svelte'
import { capitalize, toCamel } from '$lib/utils'
import { capitalize, formatS3Object, toCamel } from '$lib/utils'
import type { Schema, SchemaProperty, SupportedLanguage } from '$lib/common'
import ScriptVersionHistory from './ScriptVersionHistory.svelte'
import ScriptGen from './copilot/ScriptGen.svelte'
@@ -48,6 +49,7 @@
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
import type { EditorBarUi } from './custom_ui'
import EditorSettings from './EditorSettings.svelte'
import S3FilePicker from './S3FilePicker.svelte'
interface Props {
lang: SupportedLanguage | 'bunnative' | undefined
@@ -109,13 +111,10 @@
let resourceTypePicker: ItemPicker | undefined = $state()
let variableEditor: VariableEditor | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let showContextVarPicker = $state(false)
let showVarPicker = $state(false)
let showResourcePicker = $state(false)
let showResourceTypePicker = $state(false)
let s3FilePicker: S3FilePicker | undefined = $state()
run(() => {
showContextVarPicker = [
let showContextVarPicker = $derived(
[
'python3',
'bash',
'powershell',
@@ -131,9 +130,10 @@
'java'
// for related places search: ADD_NEW_LANG
].includes(lang ?? '')
})
run(() => {
showVarPicker = [
)
let showVarPicker = $derived(
[
'python3',
'bash',
'powershell',
@@ -149,9 +149,10 @@
'java'
// for related places search: ADD_NEW_LANG
].includes(lang ?? '')
})
run(() => {
showResourcePicker = [
)
let showResourcePicker = $derived(
[
'python3',
'bash',
'powershell',
@@ -164,16 +165,22 @@
'rust',
'csharp',
'nu',
'java'
'java',
'duckdb'
// for related places search: ADD_NEW_LANG
].includes(lang ?? '')
})
run(() => {
showResourceTypePicker =
['typescript', 'javascript'].includes(scriptLangToEditorLang(lang)) ||
)
let showS3Picker = $derived(
['duckdb', 'python3'].includes(lang ?? '') ||
['typescript', 'javascript'].includes(scriptLangToEditorLang(lang))
)
let showResourceTypePicker = $derived(
['typescript', 'javascript'].includes(scriptLangToEditorLang(lang)) ||
lang === 'python3' ||
lang === 'php'
})
)
let codeViewer: Drawer | undefined = $state()
let codeObj: { language: SupportedLanguage; content: string } | undefined = $state(undefined)
@@ -522,7 +529,7 @@ string ${windmillPathToCamelCaseName(path)} = await client.GetStringAsync(uri);
<ItemPicker
bind:this={resourcePicker}
pickCallback={(path, _) => {
pickCallback={(path, _, resType) => {
if (!editor) return
if (lang == 'deno') {
if (!editor.getCode().includes('import * as wmill from')) {
@@ -582,6 +589,15 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
} else if (lang == 'java') {
editor.insertAtCursor(`(Wmill.getResource("${path}"))`)
// for related places search: ADD_NEW_LANG
} else if (lang == 'duckdb') {
let t = { postgresql: 'postgres', mysql: 'mysql', bigquery: 'bigquery' }[resType]
if (!t) {
sendUserToast(`Resource type ${resType} is not supported in DuckDB`, true)
editor.insertAtCursor(`'$res:${path}'`)
return
} else {
editor.insertAtCursor(`ATTACH '$res:${path}' AS db (TYPE ${t});`)
}
}
sendUserToast(`${path} inserted at cursor`)
@@ -628,6 +644,30 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
<ResourceEditorDrawer bind:this={resourceEditor} on:refresh={resourcePicker.openDrawer} />
<VariableEditor bind:this={variableEditor} on:create={variablePicker.openDrawer} />
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:selectAndClose={(s3obj) => {
let s = `'${formatS3Object(s3obj.detail)}'`
if (lang === 'duckdb') {
if (s3obj.detail?.s3.endsWith('.json')) s = `read_json(${s})`
if (s3obj.detail?.s3.endsWith('.csv')) s = `read_csv(${s})`
if (s3obj.detail?.s3.endsWith('.parquet')) s = `read_parquet(${s})`
editor?.insertAtCursor(s)
} else if (lang === 'python3') {
if (!editor?.getCode().includes('import wmill')) {
editor?.insertAtBeginning('import wmill\n')
}
editor?.insertAtCursor(`wmill.load_s3_file(${s})`)
} else if (['javascript', 'typescript'].includes(scriptLangToEditorLang(lang))) {
if (!editor?.getCode().includes('import * as wmill from')) {
editor?.insertAtBeginning(`import * as wmill from "npm:windmill-client@1"\n`)
}
editor?.insertAtCursor(`wmill.loadS3File(${s})`)
}
}}
/>
<div class="flex justify-between items-center overflow-y-auto w-full p-0.5">
<div class="flex items-center">
<div
@@ -667,6 +707,22 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
</Button>
{/if}
{#if showS3Picker && customUi?.s3object != false}
<Button
aiId="editor-bar-add-s3-object"
aiDescription="Add S3 Object"
title="Add S3 object"
color="light"
on:click={() => s3FilePicker?.open()}
size="xs"
btnClasses="!font-medium text-tertiary"
spacingSize="md"
startIcon={{ icon: File }}
{iconOnly}
>+S3 Object
</Button>
{/if}
{#if showResourcePicker && customUi?.resource != false}
<Button
aiId="editor-bar-add-resource"
@@ -5,7 +5,8 @@
JobService,
type FlowStatus,
type FlowModuleValue,
type FlowModule
type FlowModule,
type ScriptArgs
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
@@ -29,6 +30,7 @@
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { buildPrefix } from './graph/graphBuilder.svelte'
import { parseAssetFromString, type AssetWithAccessType } from './assets/lib'
import FlowPreviewResult from './FlowPreviewResult.svelte'
const dispatch = createEventDispatcher()
@@ -89,6 +91,23 @@
export let localDurationStatuses: Writable<Record<string, DurationStatus>> = writable({})
let recursiveRefresh: Record<string, (clear, root) => Promise<void>> = {}
$: inputAssets = parseInputAssets(job?.args ?? {})
function parseInputAssets(args: ScriptArgs): AssetWithAccessType[] {
const arr: AssetWithAccessType[] = []
for (const v of Object.values(args)) {
if (typeof v === 'string') {
const asset = parseAssetFromString(v)
if (asset) arr.push(asset)
} else if (v && typeof v === 'object' && typeof v['s3'] === 'string') {
const s3 = v['s3']
const storage = typeof v['storage'] == 'string' ? v['storage'] : undefined
arr.push({ kind: 's3object', path: `${storage ?? ''}/${s3}` })
}
}
return arr
}
let jobResults: any[] =
flowJobIds?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? []
@@ -1193,6 +1212,7 @@
</div>
<FlowGraphV2
{inputAssets}
{selectedId}
triggerNode={true}
download={!hideDownloadInGraph}
@@ -9,7 +9,7 @@
type Item = Record<string, any>
interface Props {
pickCallback: (path: string, f: string) => void
pickCallback: (path: string, extraField: string, extraField2: string) => void
loadItems: () => Promise<Item[] | undefined>
extraField?: string
extraField2?: string | undefined
@@ -132,7 +132,7 @@
if (closeOnClick) {
drawer?.closeDrawer()
}
pickCallback(obj['path'], obj[extraField])
pickCallback(obj['path'], obj[extraField], extraField2 ? obj[extraField2] : '')
}}
>
{#if `app` in obj}
@@ -6,11 +6,13 @@
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
import { Button } from './common'
import DBManagerDrawerButton from './DBManagerDrawerButton.svelte'
import { Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { isDbType } from './apps/components/display/dbtable/utils'
import Select from './select/Select.svelte'
import DbManagerDrawer from './DBManagerDrawer.svelte'
import ExploreAssetButton, {
assetCanBeExplored
} from '../../routes/(root)/(logged)/assets/ExploreAssetButton.svelte'
interface Props {
initialValue?: string | undefined
@@ -142,6 +144,7 @@
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
</script>
<AppConnect
@@ -248,7 +251,13 @@
iconOnly
/>
</div>
{#if showSchemaExplorer && isDbType(resourceType) && value}
<DBManagerDrawerButton {resourceType} resourcePath={value} />
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'resource', path: value }, { resource_type: resourceType })}
<ExploreAssetButton
_resourceMetadata={{ resource_type: resourceType }}
asset={{ kind: 'resource', path: value }}
{dbManagerDrawer}
/>
{/if}
</div>
<DbManagerDrawer bind:this={dbManagerDrawer} />
@@ -16,7 +16,14 @@
import { workspaceStore } from '$lib/stores'
import { HelpersService, SettingService } from '$lib/gen'
import { base } from '$lib/base'
import { displayDate, displaySize, emptyString, sendUserToast } from '$lib/utils'
import {
displayDate,
displaySize,
emptyString,
parseS3Object,
sendUserToast,
type S3Object
} from '$lib/utils'
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Section from './Section.svelte'
@@ -66,6 +73,7 @@
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
selectAndClose: { s3: string; storage: string | undefined }
}>()
let drawer: Drawer | undefined = $state()
@@ -119,9 +127,13 @@
let timeout: NodeJS.Timeout | undefined = undefined
let firstLoad = true
let secondaryStorageNames = usePromise(() =>
SettingService.getSecondaryStorageNames({ workspace: $workspaceStore! })
let secondaryStorageNames = usePromise(
() => SettingService.getSecondaryStorageNames({ workspace: $workspaceStore! }),
{ loadInit: false }
)
$effect(() => {
$workspaceStore && untrack(() => secondaryStorageNames.refresh())
})
function onFilterChange() {
if (!firstLoad) {
@@ -382,9 +394,8 @@
}
let storage: string | undefined = $state(undefined)
export async function open(
preSelectedFileKey: { s3: string; storage?: string } | undefined = undefined
) {
export async function open(_preSelectedFileKey: S3Object | undefined = undefined) {
const preSelectedFileKey = _preSelectedFileKey && parseS3Object(_preSelectedFileKey)
storage = preSelectedFileKey?.storage
if (preSelectedFileKey !== undefined) {
initialFileKey = { ...preSelectedFileKey }
@@ -422,6 +433,9 @@
}
async function selectAndClose() {
if (selectedFileKey?.s3) {
dispatch('selectAndClose', { s3: selectedFileKey.s3, storage })
}
drawer?.closeDrawer?.()
}
@@ -524,7 +524,8 @@
no_main_func: script.no_main_func,
has_preprocessor: script.has_preprocessor,
deployment_message: deploymentMsg || undefined,
on_behalf_of_email: script.on_behalf_of_email
on_behalf_of_email: script.on_behalf_of_email,
fallback_access_types: script.fallback_access_types
}
})
@@ -1775,6 +1776,7 @@
kind={script.kind}
{template}
tag={script.tag}
bind:fallbackAccessTypes={script.fallback_access_types}
lastSavedCode={savedScript?.draft?.content}
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
bind:args
@@ -2,11 +2,18 @@
import { BROWSER } from 'esm-env'
import type { Schema, SupportedLanguage } from '$lib/common'
import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen'
import {
AssetService,
type CompletedJob,
type Job,
JobService,
type Preview,
type ScriptLang
} from '$lib/gen'
import { copilotInfo, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
import Editor from './Editor.svelte'
import { inferArgs } from '$lib/infer'
import { inferArgs, inferAssets } from '$lib/infer'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SchemaForm from './SchemaForm.svelte'
import LogPanel from './scriptEditor/LogPanel.svelte'
@@ -46,6 +53,9 @@
import type { ScriptOptions } from './copilot/chat/ContextManager.svelte'
import { aiChatManager, AIMode } from './copilot/chat/AIChatManager.svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import AssetsDropdownButton from './assets/AssetsDropdownButton.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { assetEq, type AssetWithAccessType } from './assets/lib'
interface Props {
// Exported
@@ -77,6 +87,7 @@
lastDeployedCode?: string | undefined
disableAi?: boolean
editor_bar_right?: import('svelte').Snippet
fallbackAccessTypes?: AssetWithAccessType[]
}
let {
@@ -106,7 +117,8 @@
lastSavedCode = undefined,
lastDeployedCode = undefined,
disableAi = false,
editor_bar_right
editor_bar_right,
fallbackAccessTypes = $bindable()
}: Props = $props()
$effect.pre(() => {
@@ -135,6 +147,29 @@
dispatch('change', { code, schema })
})
let parsedAssets = usePromise(() => inferAssets(lang, code), { clearValueOnRefresh: false })
$effect(() => {
untrack(() => parsedAssets.refresh()), [lang, code]
})
// Load initial fallbackAccessTypes
if (edit && path) {
AssetService.listAssetsByUsage({
workspace: $workspaceStore!,
requestBody: { usages: [{ path, kind: 'script' }] }
}).then((arr) => {
const v = arr[0]
setTimeout(() => {
for (const a of parsedAssets.value ?? []) {
const fallback = v.find((a2) => assetEq(a2, a))?.access_type
if (!a.access_type && fallback) {
fallbackAccessTypes = [...(fallbackAccessTypes ?? []), { ...a, access_type: fallback }]
}
}
}, 200)
})
}
let width = $state(1200)
let testJobLoader: TestJobLoader | undefined = $state(undefined)
@@ -506,6 +541,9 @@
<Pane bind:size={codePanelSize} minSize={10} class="!overflow-visible">
<div class="h-full !overflow-visible bg-gray-50 dark:bg-[#272D38] relative">
<div class="absolute top-2 right-4 z-10 flex flex-row gap-2">
{#if parsedAssets.value?.length}
<AssetsDropdownButton assets={parsedAssets.value} bind:fallbackAccessTypes />
{/if}
{#if testPanelSize === 0}
<HideButton
hidden={true}
@@ -555,6 +593,7 @@
{/if}
{/if}
</div>
{#key lang}
<Editor
lineNumbersMinChars={4}
@@ -0,0 +1,207 @@
<script lang="ts">
import { clone, pluralize } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import { AlertTriangle, Edit2, Pyramid } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Popover } from '../meltComponents'
import S3FilePicker from '../S3FilePicker.svelte'
import ExploreAssetButton, {
assetCanBeExplored
} from '../../../routes/(root)/(logged)/assets/ExploreAssetButton.svelte'
import { assetEq, formatAssetKind, type Asset, type AssetWithAccessType } from './lib'
import DbManagerDrawer from '../DBManagerDrawer.svelte'
import { tick, untrack } from 'svelte'
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import Tooltip from '../meltComponents/Tooltip.svelte'
import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
import type { Placement } from '@floating-ui/core'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
let {
assets,
enableChangeAnimation = true,
size = 'xs',
noBtnText = false,
popoverPlacement = 'bottom-end',
disableLiTooltip = false,
fallbackAccessTypes = $bindable(),
onHoverLi,
liSubtitle
}: {
assets: AssetWithAccessType[]
enableChangeAnimation?: boolean
size?: 'xs' | '3xs'
noBtnText?: boolean
popoverPlacement?: Placement
disableLiTooltip?: boolean
fallbackAccessTypes?: AssetWithAccessType[]
onHoverLi?: (asset: Asset, eventType: 'enter' | 'leave') => void
liSubtitle?: (asset: Asset) => string
} = $props()
let prevAssets = $state<typeof assets>([])
let blueBgDiv: HTMLDivElement | undefined = $state()
let s3FilePicker: S3FilePicker | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
let isOpen = $state(false)
let resourceDataCache: Record<string, string | undefined> = $state({})
$effect(() => {
if (!enableChangeAnimation) {
if (blueBgDiv) {
blueBgDiv.classList.remove('animate-fade-out')
}
}
})
$effect(() => {
assets
untrack(() => {
if (deepEqual(assets, prevAssets)) return
prevAssets = clone(assets)
// Replay animation
if (blueBgDiv && enableChangeAnimation) {
blueBgDiv.classList.add('animate-fade-out')
blueBgDiv.style.animation = 'none'
blueBgDiv.offsetHeight /* trigger reflow */
blueBgDiv.style.animation = ''
}
for (const asset of assets) {
if (asset.kind !== 'resource' || asset.path in resourceDataCache) continue
ResourceService.getResource({ path: asset.path, workspace: $workspaceStore! })
.then((resource) => (resourceDataCache[asset.path] = resource.resource_type))
.catch((err) => (resourceDataCache[asset.path] = undefined))
}
})
})
</script>
<Popover
floatingConfig={{ strategy: 'absolute', placement: popoverPlacement }}
usePointerDownOutside
closeOnOtherPopoverOpen
bind:isOpen
escapeBehavior="ignore"
>
<svelte:fragment slot="trigger">
<div
class={twMerge(
size === '3xs' ? 'h-[1.6rem]' : 'py-1.5',
'text-xs flex items-center gap-1.5 px-2 rounded-md relative',
'border border-tertiary/30',
'bg-surface hover:bg-surface-hover active:bg-surface',
'transition-all hover:text-primary cursor-pointer'
)}
>
<div
bind:this={blueBgDiv}
class="absolute pointer-events-none bg-slate-300 dark:bg-[#576278] inset-0 rounded-md opacity-0"
></div>
<Pyramid size={size === '3xs' ? 13 : 16} class="z-10" />
<span
class={twMerge('z-10 font-normal', size === '3xs' ? 'text-3xs mt-[0.08rem]' : 'text-xs')}
>
{noBtnText ? assets.length : pluralize(assets.length, 'asset')}
</span>
</div>
</svelte:fragment>
<svelte:fragment slot="content">
<ul class="divide-y rounded-md">
{#each assets as asset}
{@const fallbackAccessType = fallbackAccessTypes?.find((a) =>
assetEq(a, asset)
)?.access_type}
{@const hasWarning = !asset.access_type && !fallbackAccessType}
<li
class="text-sm px-4 h-12 flex gap-4 items-center justify-between hover:bg-surface-hover"
onmouseenter={() => onHoverLi?.(asset, 'enter')}
onmouseleave={() => onHoverLi?.(asset, 'leave')}
>
<div class="flex flex-col">
<Tooltip class="select-none max-w-48 truncate" disablePopup={disableLiTooltip}>
{asset.path}
<svelte:fragment slot="text">
{asset.path}
</svelte:fragment>
</Tooltip>
<span class="text-xs text-tertiary select-none">
{liSubtitle?.(asset) ??
formatAssetKind({
...asset,
metadata: { resource_type: resourceDataCache[asset.path] }
})}
</span>
</div>
<div class="flex gap-2 items-center">
{#if asset.kind === 'resource' && resourceDataCache[asset.path] !== undefined}
<Button
startIcon={{ icon: Edit2 }}
size="xs"
variant="border"
spacingSize="xs2"
iconOnly
on:click={() => (resourceEditorDrawer?.initEdit(asset.path), (isOpen = false))}
/>
{/if}
{#if asset.kind === 'resource' && resourceDataCache[asset.path] === undefined}
<Tooltip class="mr-2.5">
<AlertTriangle size={16} class="text-orange-600 dark:text-orange-500" />
<svelte:fragment slot="text">Could not find resource</svelte:fragment>
</Tooltip>
{/if}
{#if assetCanBeExplored(asset, { resource_type: resourceDataCache[asset.path] })}
<ExploreAssetButton
{asset}
{s3FilePicker}
{dbManagerDrawer}
onClick={() => (isOpen = false)}
noText
_resourceMetadata={{ resource_type: resourceDataCache[asset.path] }}
/>
{/if}
<ToggleButtonGroup
disabled={!!asset.access_type}
tabListClass={hasWarning ? 'bg-red-200 dark:bg-red-300' : ''}
bind:selected={
() => asset.access_type ?? fallbackAccessType,
async (access_type) => {
fallbackAccessTypes ??= []
await tick()
let val = fallbackAccessTypes?.filter((a) => !assetEq(a, asset))
val.push({ ...asset, access_type })
fallbackAccessTypes = val
}
}
>
{#snippet children({ item })}
{#each ['r', 'w', 'rw'] as v}
<ToggleButton
class={hasWarning
? 'bg-transparent hover:bg-red-100 dark:text-primary-inverse'
: ''}
value={v}
label={v}
{item}
tooltip={'Could not infer access type from code, please select manually'}
/>
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
</li>
{/each}
</ul>
</svelte:fragment>
</Popover>
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />
<ResourceEditorDrawer bind:this={resourceEditorDrawer} />
@@ -0,0 +1,58 @@
<script lang="ts">
import type { AssetUsageAccessType, AssetUsageKind } from '$lib/gen'
import { Drawer, DrawerContent } from '../common'
import RowIcon from '../common/table/RowIcon.svelte'
import {
assetDisplaysAsInputInFlowGraph,
assetDisplaysAsOutputInFlowGraph
} from '../graph/renderers/nodes/AssetNode.svelte'
import { getAssetUsagePageUri } from './lib'
let usagesDrawerData:
| {
usages: {
path: string
kind: AssetUsageKind
access_type?: AssetUsageAccessType
}[]
}
| undefined = $state()
export function open(data: typeof usagesDrawerData) {
usagesDrawerData = data
}
</script>
<Drawer
open={usagesDrawerData !== undefined}
size="900px"
on:close={() => (usagesDrawerData = undefined)}
>
<DrawerContent title="Asset usage" on:close={() => (usagesDrawerData = undefined)}>
<ul class="flex flex-col border rounded-md divide-y">
{#each usagesDrawerData?.usages ?? [] as u}
<li>
<a
href={getAssetUsagePageUri(u)}
aria-label={`${u.kind}/${u.path}`}
class="text-sm text-primary flex items-center py-3 px-4 gap-3 hover:bg-surface-hover cursor-pointer"
>
<RowIcon kind={u.kind} />
<div class="flex flex-col justify-center flex-1">
<span class="font-semibold">{u.path}</span>
<span class="text-xs text-tertiary">{u.kind}</span>
</div>
<div class="flex gap-2">
{#if assetDisplaysAsInputInFlowGraph(u)}
<div class="text-xs border text-tertiary max-w-fit p-1 rounded-md">Read</div>
{/if}
{#if assetDisplaysAsOutputInFlowGraph(u)}
<div class="text-xs border text-tertiary max-w-fit p-1 rounded-md">Write</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</DrawerContent>
</Drawer>
+69
View File
@@ -0,0 +1,69 @@
import type {
AssetKind as _AssetKind,
Asset as _Asset,
ListAssetsResponse,
AssetUsageAccessType
} from '$lib/gen'
import { capitalize } from '$lib/utils'
export type Asset = _Asset
export type AssetKind = _AssetKind
export type AssetWithAccessType = Asset & { access_type?: AssetUsageAccessType }
export function formatAsset(asset: Asset): string {
switch (asset.kind) {
case 'resource':
return `res://${asset.path}`
case 's3object':
return `s3://${asset.path}`
case 'variable':
return `var://${asset.path}`
}
}
export function getAssetUsagePageUri(usage: ListAssetsResponse[number]['usages'][number]) {
if (usage.kind === 'script') {
return `/scripts/get/${usage.path}`
} else if (usage.kind === 'flow') {
return `/flows/get/${usage.path}`
}
}
export function assetEq(a: Asset | undefined, b: Asset | undefined): boolean {
if (!a || !b) return a === b
return a.kind === b.kind && a.path === b.path
}
export function parseAssetFromString(s: string): Asset | undefined {
if (s.startsWith('res://')) {
return { kind: 'resource', path: s.slice(6) }
} else if (s.startsWith('$res:')) {
return { kind: 'resource', path: s.slice(5) }
} else if (s.startsWith('s3://')) {
return { kind: 's3object', path: s.slice(5) }
} else if (s.startsWith('var://')) {
return { kind: 'variable', path: s.slice(6) }
}
return undefined
}
export function formatAssetKind(asset: {
kind: AssetKind
metadata?: { resource_type?: string }
}): string {
switch (asset.kind) {
case 'resource':
if (asset.metadata?.resource_type) {
if (asset.metadata.resource_type === 'state') return 'State'
if (asset.metadata.resource_type === 'cache') return 'Cache'
if (asset.metadata.resource_type === 'app_theme') return 'App Theme'
return `${capitalize(asset.metadata.resource_type)} resource`
} else {
return 'metadata' in asset ? 'Invalid resource' : 'Resource'
}
case 's3object':
return 'S3 Object'
case 'variable':
return 'Variable'
}
}
@@ -0,0 +1,28 @@
<!--
Useful to listen to changes on a list without recomputing the whole list
when a single item changes
-->
<script lang="ts" generics="T">
import { untrack } from 'svelte'
let {
key,
onChange,
runFirstEffect = false
}: {
key: T
onChange: () => void
runFirstEffect?: boolean
} = $props()
let isFirstRun = true
$effect(() => {
key
if (isFirstRun) {
isFirstRun = false
if (!runFirstEffect) return
}
untrack(() => onChange())
})
</script>
@@ -83,8 +83,8 @@ wmill.runScriptAsync(path: string, args?: Record<string, any>): Promise<string>
wmill.waitJob(jobId: string): Promise<any> // Wait for job completion and get result
// S3 file operations (if S3 is configured)
wmill.loadS3File(s3object: S3Object): Promise<Uint8Array> // Load file content from S3
wmill.writeS3File(s3object: S3Object, content: string | Blob): Promise<S3Object> // Write file to S3
wmill.loadS3File(s3object: S3Object | string): Promise<Uint8Array> // Load file content from S3
wmill.writeS3File(s3object: S3Object | string, content: string | Blob): Promise<S3Object> // Write file to S3
// Flow operations
wmill.setFlowUserState(key: string, value: any): Promise<void> // Set flow user state
@@ -119,8 +119,8 @@ wmill.run_script_async(path: str, args: dict = None, scheduled_in_secs: int = No
wmill.wait_job(job_id: str, timeout = None) -> Any // Wait for job completion and get result
// S3 file operations (if S3 is configured)
wmill.load_s3_file(s3object: S3Object, s3_resource_path: str = None) -> bytes // Load file content from S3
wmill.write_s3_file(s3object: S3Object, file_content: bytes, s3_resource_path: str = None) -> S3Object // Write file to S3
wmill.load_s3_file(s3object: S3Object | str, s3_resource_path: str = None) -> bytes // Load file content from S3
wmill.write_s3_file(s3object: S3Object | str, file_content: bytes, s3_resource_path: str = None) -> S3Object // Write file to S3
// Flow operations
wmill.run_flow_async(path: str, args: dict = None) -> str // Run flow asynchronously
+1
View File
@@ -68,6 +68,7 @@ export type EditorBarUi = {
library?: boolean
useVsCode?: boolean
diffMode?: boolean
s3object?: boolean
}
export type EditableSchemaFormUi = {
@@ -51,8 +51,10 @@
import { workspaceStore } from '$lib/stores'
import { checkIfParentLoop } from '../utils'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { refreshStateStore, usePromise } from '$lib/svelte5Utils.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import AssetsDropdownButton from '$lib/components/assets/AssetsDropdownButton.svelte'
import { inferAssets } from '$lib/infer'
const {
selectedId,
@@ -297,6 +299,19 @@
}
})
let assets = usePromise(
async () =>
flowModule.value.type === 'rawscript'
? await inferAssets(flowModule.value.language, flowModule.value.content)
: undefined,
{ clearValueOnRefresh: false, loadInit: false }
)
$effect(() => {
if (flowModule.value.type !== 'rawscript') return
;[flowModule.value.content, flowModule.value.language]
untrack(() => assets.refresh())
})
let rawScriptLang = $derived(
flowModule.value.type == 'rawscript' ? flowModule.value.language : undefined
)
@@ -406,6 +421,14 @@
{#if flowModule.value.type === 'rawscript'}
{#if !noEditor}
{#key flowModule.id}
<div class="absolute top-2 right-4 z-10 flex flex-row gap-2">
{#if assets.value?.length}
<AssetsDropdownButton
assets={assets.value}
bind:fallbackAccessTypes={flowModule.value.asset_fallback_access_types}
/>
{/if}
</div>
<Editor
loadAsync
folding
@@ -8,6 +8,10 @@ import type Editor from '../Editor.svelte'
import type SimpleEditor from '../SimpleEditor.svelte'
import type { StateStore } from '$lib/utils'
import type { TestSteps } from './testSteps.svelte'
import type { Asset, AssetWithAccessType } from '../assets/lib'
import type S3FilePicker from '../S3FilePicker.svelte'
import type DbManagerDrawer from '../DBManagerDrawer.svelte'
import type ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
export type FlowInput = Record<
@@ -82,3 +86,13 @@ export type FlowEditorContext = {
modulesTestStates: ModulesTestStates
outputPickerOpenFns: Record<string, () => void>
}
export type FlowGraphAssetContext = StateStore<{
selectedAsset: Asset | undefined
assetsMap: Record<string, AssetWithAccessType[]> // Maps module ids to their assets
s3FilePicker: S3FilePicker | undefined
dbManagerDrawer: DbManagerDrawer | undefined
resourceEditorDrawer: ResourceEditorDrawer | undefined
// Maps resource paths to their metadata. undefined is for error
resourceMetadataCache: Record<string, { resource_type?: string } | undefined>
}>
@@ -1,5 +1,12 @@
<script lang="ts">
import { FlowService, type FlowModule, type Job } from '../../gen'
import {
AssetService,
FlowService,
ResourceService,
type AssetUsageKind,
type FlowModule,
type Job
} from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, setContext, tick, untrack } from 'svelte'
@@ -36,7 +43,7 @@
import { Expand } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import { encodeState } from '$lib/utils'
import { encodeState, readFieldsRecursively } from '$lib/utils'
import BranchOneStart from './renderers/nodes/BranchOneStart.svelte'
import NoBranchNode from './renderers/nodes/NoBranchNode.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
@@ -50,6 +57,16 @@
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
import { deepEqual } from 'fast-equals'
import ViewportResizer from './ViewportResizer.svelte'
import AssetNode, { computeAssetNodes } from './renderers/nodes/AssetNode.svelte'
import type { FlowGraphAssetContext } from '../flows/types'
import { getAllModules } from '../flows/flowExplorer'
import { inferAssets } from '$lib/infer'
import OnChange from '../common/OnChange.svelte'
import S3FilePicker from '../S3FilePicker.svelte'
import DbManagerDrawer from '../DBManagerDrawer.svelte'
import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
import { assetEq, type AssetWithAccessType } from '../assets/lib'
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
@@ -86,6 +103,7 @@
editMode?: boolean
allowSimplifiedPoll?: boolean
expandedSubflows?: Record<string, FlowModule[]>
inputAssets?: AssetWithAccessType[]
isOwner?: boolean
isRunning?: boolean
individualStepTests?: boolean
@@ -156,6 +174,7 @@
editMode = false,
allowSimplifiedPoll = true,
expandedSubflows = $bindable({}),
inputAssets,
onTestUpTo = undefined,
onEditInput = undefined,
isOwner = false,
@@ -185,6 +204,67 @@
})
}
const flowGraphAssetsCtx: FlowGraphAssetContext = $state({
val: {
assetsMap: inputAssets ? ({ Input: inputAssets } as any) : {},
selectedAsset: undefined,
dbManagerDrawer: undefined,
s3FilePicker: undefined,
resourceEditorDrawer: undefined,
resourceMetadataCache: {}
}
})
setContext<FlowGraphAssetContext>('FlowGraphAssetContext', flowGraphAssetsCtx)
const assetsMap = $derived(flowGraphAssetsCtx.val.assetsMap)
$effect(() => {
if (inputAssets) flowGraphAssetsCtx.val.assetsMap.Input = inputAssets
})
// Fetch resource metadata for the ExploreAssetButton
const resMetadataCache = $derived(flowGraphAssetsCtx.val.resourceMetadataCache)
$effect(() => {
for (const asset of Object.values(assetsMap ?? []).flatMap((x) => x)) {
if (asset.kind !== 'resource' || asset.path in resMetadataCache) continue
resMetadataCache[asset.path] = undefined // avoid fetching multiple times because of async
ResourceService.getResource({ path: asset.path, workspace: $workspaceStore! }).then(
(r) => (resMetadataCache[asset.path] = { resource_type: r.resource_type })
)
}
})
// Fetch transitive assets (path scripts and flows)
$effect(() => {
if (!$workspaceStore) return
let usages: { path: string; kind: AssetUsageKind }[] = []
let modIds: string[] = []
for (const mod of getAllModules(modules)) {
if (mod.id in assetsMap) continue
assetsMap[mod.id] = [] // avoid fetching multiple times because of async
if (mod.value.type === 'flow' || mod.value.type === 'script') {
usages.push({ path: mod.value.path, kind: mod.value.type })
modIds.push(mod.id)
}
}
if (usages.length) {
AssetService.listAssetsByUsage({
workspace: $workspaceStore,
requestBody: { usages }
}).then((result) => {
result.forEach((assets, idx) => {
assetsMap[modIds[idx]] = assets
})
})
}
})
// Prune assetsMap to only contain assets that are actually used
$effect(() => {
const allModules = new Set(getAllModules(modules).map((mod) => mod.id))
for (const modId in assetsMap) {
if (modId !== 'Input' && !allModules.has(modId)) delete assetsMap[modId]
}
})
function computeSimplifiableFlow(modules: FlowModule[], simplifiedFlow: boolean) {
const isSimplif = isSimplifiable(modules)
simplifiableFlow = isSimplif ? { simplifiedFlow } : undefined
@@ -206,7 +286,7 @@
let lastNodes: [NodeLayout[], Node[]] | undefined = undefined
function layoutNodes(nodes: NodeLayout[]): Node[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
if (lastResult && nodes === lastNodes?.[0]) {
return lastResult
}
let seenId: string[] = []
@@ -377,9 +457,11 @@
}
let newGraph = graph
newGraph.nodes.sort((a, b) => b.id.localeCompare(a.id))
nodes = layoutNodes(newGraph.nodes)
edges = newGraph.edges
;[nodes, edges] = computeAssetNodes(layoutNodes(newGraph.nodes), newGraph.edges, assetsMap, {
moving,
eventHandlers: eventHandler,
disableAi
})
await tick()
height = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100), minHeight)
}
@@ -398,7 +480,9 @@
branchOneEnd: BranchOneEndNode,
subflowBound: SubflowBound,
noBranch: NoBranchNode,
trigger: TriggersNode
trigger: TriggersNode,
asset: AssetNode,
assetsOverflowed: AssetsOverflowedNode
} as any
const edgeTypes = {
@@ -456,8 +540,11 @@
)
})
$effect(() => {
;(graph || allowSimplifiedPoll) && untrack(() => updateStores())
;[graph, allowSimplifiedPoll]
readFieldsRecursively(assetsMap)
untrack(() => updateStores())
})
let showDataflow = $derived(
$selectedId != undefined &&
!$selectedId.startsWith('constants') &&
@@ -583,6 +670,33 @@
{/if}
</div>
{#each getAllModules(modules) as mod (mod.id)}
{#if mod.value.type === 'rawscript'}
{@const v = mod.value}
<OnChange
key={[v.content, v.asset_fallback_access_types]}
runFirstEffect
onChange={() =>
inferAssets(v.language, v.content)
.then((assets) => {
for (const override of v.asset_fallback_access_types ?? []) {
assets = assets.map((asset) => {
if (assetEq(asset, override) && !asset.access_type)
return { ...asset, access_type: override.access_type }
return asset
})
}
if (assetsMap && !deepEqual(assetsMap[mod.id], assets)) assetsMap[mod.id] = assets
})
.catch((e) => {})}
/>
{/if}
{/each}
<S3FilePicker bind:this={flowGraphAssetsCtx.val.s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={flowGraphAssetsCtx.val.dbManagerDrawer} />
<ResourceEditorDrawer bind:this={flowGraphAssetsCtx.val.resourceEditorDrawer} />
<style lang="postcss">
:global(.svelte-flow__handle) {
opacity: 0;
@@ -4,6 +4,7 @@ import { getDependeeAndDependentComponents } from '../flows/flowExplorer'
import { dfsByModule } from '../flows/previousResults'
import { defaultIfEmptyString } from '$lib/utils'
import type { GraphModuleState } from './model'
import type { AssetWithAccessType } from '../assets/lib'
import type { Writable } from 'svelte/store'
export type InsertKind =
@@ -95,6 +96,8 @@ export type FlowNode =
| SubflowBoundN
| NoBranchN
| TriggerN
| AssetN
| AssetsOverflowedN
export type InputN = {
type: 'input2'
@@ -271,6 +274,20 @@ export type TriggerN = {
}
}
export type AssetN = {
type: 'asset'
data: {
asset: AssetWithAccessType
}
}
export type AssetsOverflowedN = {
type: 'assetsOverflowed'
data: {
overflowedAssets: AssetWithAccessType[]
}
}
// input2: InputNode,
// module: ModuleNode,
// branchAllStart: BranchAllStart,
@@ -7,6 +7,11 @@
import type { GraphEventHandlers } from '../../graphBuilder.svelte'
import { getStraightLinePath } from '../utils'
import { twMerge } from 'tailwind-merge'
import { type FlowGraphAssetContext } from '$lib/components/flows/types'
import {
assetDisplaysAsOutputInFlowGraph,
NODE_WITH_WRITE_ASSET_Y_OFFSET
} from '../nodes/AssetNode.svelte'
import { workspaceStore } from '$lib/stores'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import type { Job } from '$lib/gen'
@@ -16,6 +21,8 @@
useDataflow: Writable<boolean | undefined>
}>('FlowGraphContext')
const flowGraphAssetCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
let {
// id,
sourceX,
@@ -46,6 +53,10 @@
}
} = $props()
const shouldOffsetInsertButtonDueToAssetNode = flowGraphAssetCtx?.val.assetsMap?.[
data.sourceId
]?.some(assetDisplaysAsOutputInFlowGraph)
let [edgePath] = $derived(
getBezierPath({
sourceX,
@@ -76,7 +87,12 @@
)
</script>
<EdgeLabel x={sourceX} y={sourceY + 28} class="base-edge" style="">
<EdgeLabel
x={sourceX}
y={sourceY + 28 + (shouldOffsetInsertButtonDueToAssetNode ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)}
class="base-edge"
style=""
>
{#if data?.insertable && !$useDataflow && !data?.moving && !waitingForEvents}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
@@ -2,6 +2,7 @@
import { getBezierPath, BaseEdge, type Position } from '@xyflow/svelte'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
export let sourceX: number
export let sourceY: number
@@ -10,6 +11,7 @@
export let targetY: number
export let targetPosition: Position
export let markerEnd: string | undefined = undefined
export let data: { class?: string } = {}
const { useDataflow } = getContext<{
useDataflow: Writable<boolean | undefined>
@@ -26,4 +28,8 @@
})
</script>
<BaseEdge path={edgePath} {markerEnd} class={$useDataflow ? 'hidden' : ''} />
<BaseEdge
path={edgePath}
{markerEnd}
class={twMerge($useDataflow ? 'hidden' : '', data.class ?? '')}
/>
@@ -0,0 +1,306 @@
<script module lang="ts">
export const NODE_WITH_READ_ASSET_Y_OFFSET = 45
export const NODE_WITH_WRITE_ASSET_Y_OFFSET = 45
export const READ_ASSET_Y_OFFSET = -45
export const WRITE_ASSET_Y_OFFSET = 64
export const assetDisplaysAsInputInFlowGraph = (a: { access_type?: AssetUsageAccessType }) =>
!a.access_type || a.access_type === 'r' || a.access_type === 'rw'
export const assetDisplaysAsOutputInFlowGraph = (a: { access_type?: AssetUsageAccessType }) =>
a.access_type === 'w' || a.access_type === 'rw'
let computeAssetNodesCache:
| [Node[], Record<string, AssetWithAccessType[]>, ReturnType<typeof computeAssetNodes>]
| undefined
export function computeAssetNodes(
nodes: Node[],
edges: Edge[],
assetsMap: Record<string, AssetWithAccessType[]>,
extraData: any
): [Node[], Edge[]] {
if (nodes === computeAssetNodesCache?.[0] && deepEqual(assetsMap, computeAssetNodesCache?.[1]))
return computeAssetNodesCache[2]
const MAX_ASSET_ROW_WIDTH = 300
const ASSETS_OVERFLOWED_NODE_WIDTH = 25
const allAssetNodes: Node[] = []
const allAssetEdges: Edge[] = []
const yPosMap: Record<number, { r?: true; w?: true }> = {}
for (const node of nodes) {
const assets = assetsMap?.[node.id] ?? []
// Each asset can be displayed at the top and bottom
// i.e once (R or W) or twice (RW)
const inputAssets = assets.filter(assetDisplaysAsInputInFlowGraph)
const outputAssets = assets.filter(assetDisplaysAsOutputInFlowGraph)
const displayedInputAssets = inputAssets.slice(0, 3)
const displayedOutputAssets = outputAssets.slice(0, 3)
const overflowedInputAssets = inputAssets.slice(3)
const overflowedOutputAssets = outputAssets.slice(3)
// This allows calculating which nodes to offset on the y axis to
// make space for the asset nodes
if (inputAssets.length || outputAssets.length)
yPosMap[node.position.y] = yPosMap[node.position.y] ?? {}
if (inputAssets.length) yPosMap[node.position.y].r = true
if (outputAssets.length) yPosMap[node.position.y].w = true
// All asset nodes displayed on top
const inputAssetNodes: (Node & AssetN)[] = displayedInputAssets.map((asset, i) => {
let inputAssetXGap = 12
let inputAssetWidth = 150
const targetRowW =
MAX_ASSET_ROW_WIDTH -
(overflowedInputAssets.length ? ASSETS_OVERFLOWED_NODE_WIDTH + inputAssetXGap / 2 : 0)
let totalInputRowWidth = () =>
inputAssetWidth * displayedInputAssets.length +
inputAssetXGap * (displayedInputAssets.length - 1)
if (totalInputRowWidth() > MAX_ASSET_ROW_WIDTH) {
const mult = targetRowW / totalInputRowWidth()
inputAssetWidth = inputAssetWidth * mult
inputAssetXGap = inputAssetXGap * mult
}
return {
type: 'asset' as const,
parentId: node.id,
data: { asset },
id: `${node.id}-asset-in-${asset.kind}-${asset.path}`,
width: inputAssetWidth,
position: {
x:
displayedInputAssets.length === 1
? (NODE.width - inputAssetWidth) / 2 - 10 // Ensure we see the edge
: (inputAssetWidth + inputAssetXGap) * (i - displayedInputAssets.length / 2) +
(NODE.width + inputAssetXGap) / 2 +
(overflowedInputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
: 0),
y: READ_ASSET_Y_OFFSET
}
}
})
// All asset nodes displayed on the bottom
const outputAssetNodes: (Node & AssetN)[] = displayedOutputAssets.map((asset, i) => {
let outputAssetXGap = 12
let outputAssetWidth = 150
const targetRowW =
MAX_ASSET_ROW_WIDTH -
(overflowedOutputAssets.length ? ASSETS_OVERFLOWED_NODE_WIDTH + outputAssetXGap / 2 : 0)
let totalOutputRowWidth = () =>
outputAssetWidth * displayedOutputAssets.length +
outputAssetXGap * (displayedOutputAssets.length - 1)
if (totalOutputRowWidth() > MAX_ASSET_ROW_WIDTH) {
const mult = targetRowW / totalOutputRowWidth()
outputAssetWidth = outputAssetWidth * mult
outputAssetXGap = outputAssetXGap * mult
}
return {
type: 'asset' as const,
parentId: node.id,
data: { asset },
id: `${node.id}-asset-out-${asset.kind}-${asset.path}`,
width: outputAssetWidth,
position: {
x:
displayedOutputAssets.length === 1
? (NODE.width - outputAssetWidth) / 2 - 10 // Ensure we see the edge
: (outputAssetWidth + outputAssetXGap) * (i - displayedOutputAssets.length / 2) +
(NODE.width + outputAssetXGap) / 2 +
(overflowedOutputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
: 0),
y: WRITE_ASSET_Y_OFFSET
}
}
})
const inputAssetEdges: Edge[] = inputAssetNodes?.map((n) => ({
id: `${n.id}-edge`,
source: n.id ?? '',
target: n.parentId ?? '',
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
}))
const outputAssetEdges: Edge[] = outputAssetNodes?.map((n) => ({
id: `${n.id}-edge`,
source: n.parentId ?? '',
target: n.id ?? '',
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
}))
allAssetEdges.push(...(outputAssetEdges ?? []), ...(inputAssetEdges ?? []))
allAssetNodes.push(...(inputAssetNodes ?? []), ...(outputAssetNodes ?? []))
// If there are more than 3 assets, we create an overflow node
if (overflowedInputAssets.length)
allAssetNodes.push({
type: 'assetsOverflowed',
data: { overflowedAssets: overflowedInputAssets },
id: `${node.id}-assets-overflowed-in`,
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: READ_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
allAssetEdges.push({
id: `${node.id}-assets-overflowed-in-edge`,
source: `${node.id}-assets-overflowed-in`,
target: node.id,
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
})
if (overflowedOutputAssets.length)
allAssetNodes.push({
type: 'assetsOverflowed',
data: { overflowedAssets: overflowedOutputAssets },
id: `${node.id}-assets-overflowed-out`,
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: WRITE_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
allAssetEdges.push({
id: `${node.id}-assets-overflowed-out-edge`,
source: node.id,
target: `${node.id}-assets-overflowed-out`,
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-25' }
})
}
// Shift all nodes to make space for the new asset nodes
const sortedNewNodes = clone(nodes.sort((a, b) => a.position.y - b.position.y))
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
if (yPosMap[prevYPos]?.w) currentYOffset += NODE_WITH_WRITE_ASSET_Y_OFFSET
if (yPosMap[node.position.y]?.r) currentYOffset += NODE_WITH_READ_ASSET_Y_OFFSET
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAssetNodes> = [
[...sortedNewNodes, ...allAssetNodes],
[...edges, ...allAssetEdges]
]
computeAssetNodesCache = [nodes, clone(assetsMap), ret]
return ret
}
</script>
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import type { AssetN, AssetsOverflowedN } from '../../graphBuilder.svelte'
import { AlertTriangle } from 'lucide-svelte'
import { assetEq, formatAssetKind, type AssetWithAccessType } from '$lib/components/assets/lib'
import { twMerge } from 'tailwind-merge'
import type { FlowGraphAssetContext } from '$lib/components/flows/types'
import { getContext } from 'svelte'
import ExploreAssetButton, {
assetCanBeExplored
} from '../../../../../routes/(root)/(logged)/assets/ExploreAssetButton.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { clone, pluralize } from '$lib/utils'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import type { Edge, Node } from '@xyflow/svelte'
import { deepEqual } from 'fast-equals'
import { NODE } from '../../util'
import type { AssetUsageAccessType } from '$lib/gen'
import { userStore } from '$lib/stores'
interface Props {
data: AssetN['data']
}
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext>('FlowGraphAssetContext')
const usageCount = $derived(
Object.values(flowGraphAssetsCtx.val.assetsMap ?? {})
.flat()
.filter((asset) => assetEq(asset, data.asset)).length
)
let { data }: Props = $props()
const isSelected = $derived(assetEq(flowGraphAssetsCtx.val.selectedAsset, data.asset))
const cachedResourceMetadata = $derived(
flowGraphAssetsCtx.val.resourceMetadataCache[data.asset.path]
)
</script>
<NodeWrapper>
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Tooltip>
<div
class={twMerge(
'bg-surface h-6 flex items-center gap-1.5 rounded-sm text-tertiary border overflow-clip',
isSelected ? 'bg-surface-secondary !border-surface-inverse' : 'border-transparent'
)}
onmouseenter={() => (flowGraphAssetsCtx.val.selectedAsset = data.asset)}
onmouseleave={() => (flowGraphAssetsCtx.val.selectedAsset = undefined)}
>
<AssetGenericIcon
assetKind={data.asset.kind}
fill={''}
class="shrink-0 ml-1 fill-tertiary stroke-tertiary"
size="16px"
/>
<span class="text-3xs truncate flex-1">
{data.asset.path}
</span>
{#if data.asset.kind === 'resource' && cachedResourceMetadata === undefined}
<Tooltip class={'pr-1 flex items-center justify-center'}>
<AlertTriangle size={16} class="text-orange-500" />
<svelte:fragment slot="text">Could not find resource</svelte:fragment>
</Tooltip>
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
<ExploreAssetButton
btnClasses="rounded-none"
asset={data.asset}
noText
buttonVariant="contained"
s3FilePicker={flowGraphAssetsCtx.val.s3FilePicker}
dbManagerDrawer={flowGraphAssetsCtx.val.dbManagerDrawer}
_resourceMetadata={cachedResourceMetadata}
/>
{/if}
</div>
<svelte:fragment slot="text">
Used in {pluralize(usageCount, 'step')}<br />
<a
href={undefined}
class={twMerge(
'text-xs',
data.asset.kind === 'resource'
? 'text-blue-400 cursor-pointer'
: 'dark:text-tertiary text-tertiary-inverse'
)}
onclick={() => {
if (data.asset.kind === 'resource')
flowGraphAssetsCtx.val.resourceEditorDrawer?.initEdit(data.asset.path)
}}
>
{data.asset.path}
</a><br />
<span class="dark:text-tertiary text-tertiary-inverse text-xs"
>{formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })}</span
>
</svelte:fragment>
</Tooltip>
{/snippet}
</NodeWrapper>
@@ -0,0 +1,66 @@
<!-- Displays as +n node instead of AssetNode when there are too many of themOverflowedAssetsNode -->
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { type AssetsOverflowedN } from '../../graphBuilder.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AssetNode from './AssetNode.svelte'
import type { FlowGraphAssetContext } from '$lib/components/flows/types'
import { getContext } from 'svelte'
import { assetEq } from '$lib/components/assets/lib'
interface Props {
data: AssetsOverflowedN['data']
}
let { data }: Props = $props()
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext>('FlowGraphAssetContext')
let isOpen = $state(false)
let includesSelected = $derived(
data.overflowedAssets.some((asset) => assetEq(flowGraphAssetsCtx.val.selectedAsset, asset))
)
let wasOpenedBecauseOfExternalSelected = false
$effect(() => {
if (includesSelected && !isOpen) {
isOpen = true
wasOpenedBecauseOfExternalSelected = true
}
if (wasOpenedBecauseOfExternalSelected && !includesSelected) {
isOpen = false
wasOpenedBecauseOfExternalSelected = false
}
})
</script>
<NodeWrapper>
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Popover
portal={null}
usePointerDownOutside
bind:isOpen
class={twMerge(
'!w-full text-2xs font-normal bg-surface h-6 pr-0.5 flex justify-center items-center rounded-sm text-tertiary border',
includesSelected ? 'bg-surface-secondary border-surface-inverse' : 'border-transparent',
'hover:bg-surface-secondary hover:border-surface-inverse active:opacity-55'
)}
placement="top"
>
<svelte:fragment slot="trigger">
+{data.overflowedAssets.length}
</svelte:fragment>
<svelte:fragment slot="content">
<ul>
{#each data.overflowedAssets as asset}
<li class="w-48">
<AssetNode data={{ asset }} />
</li>
{/each}
</ul>
</svelte:fragment>
</Popover>
{/snippet}
</NodeWrapper>
@@ -0,0 +1,26 @@
<script lang="ts">
import { Pyramid } from 'lucide-svelte'
import type { AssetKind } from '../assets/lib'
import AssetResIcon from './AssetResIcon.svelte'
import AssetS3Icon from './AssetS3Icon.svelte'
import AssetVarIcon from './AssetVarIcon.svelte'
interface Props {
size?: string
fill?: string
assetKind: AssetKind
class?: string
}
let { assetKind, fill, size, class: className = '' }: Props = $props()
</script>
{#if assetKind == 's3object'}
<AssetS3Icon {fill} width={size} height={size} class={className} />
{:else if assetKind == 'resource'}
<AssetResIcon {fill} width={size} height={size} class={className} />
{:else if assetKind == 'variable'}
<AssetVarIcon {fill} width={size} height={size} class={className} />
{:else}
<Pyramid {size} color={fill} class={'!fill-none ' + className} />
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
height?: string
width?: string
fill?: string
class?: string
}
let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props()
</script>
<svg
{width}
{height}
class={className}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_2)">
<path
d="M11.0586 0.306641C11.3106 0.315989 11.5582 0.378277 11.7822 0.495117C12.0247 0.621655 12.2331 0.805033 12.3897 1.0293L12.3916 1.03223L16.5889 7.10059L15.2285 7.59473L11.6963 2.4873V8.87695L10.2959 9.38574V2.4873L1.75196 14.8457C1.72867 14.8796 1.71294 14.9186 1.70508 14.959C1.69725 14.9995 1.69803 15.0418 1.70704 15.082C1.71605 15.1222 1.73351 15.1601 1.75782 15.1934C1.78213 15.2266 1.81309 15.2546 1.84864 15.2754H1.84766L7.75001 18.6445V20.2568L1.14844 16.4883L1.14258 16.4844C0.940872 16.3666 0.765826 16.2081 0.627936 16.0195C0.489969 15.8308 0.391887 15.6158 0.340827 15.3877C0.289772 15.1595 0.286682 14.9229 0.331061 14.6934C0.375447 14.4641 0.466395 14.2462 0.598639 14.0537L0.599616 14.0518L9.59962 1.03223L9.60157 1.0293C9.75823 0.804889 9.9673 0.62167 10.21 0.495117C10.4336 0.378564 10.6802 0.316161 10.9316 0.306641C10.9528 0.304686 10.9744 0.299805 10.9961 0.299805C11.0172 0.299841 11.038 0.304797 11.0586 0.306641Z"
{fill}
stroke="none"
/>
<path
d="M15 19.343L12.618 20.82C12.4314 20.936 12.2177 20.9973 12 20.9973C11.7823 20.9973 11.5686 20.936 11.382 20.82L9.582 19.7029C9.40485 19.5928 9.2582 19.4373 9.15623 19.2514C9.05427 19.0654 9.00045 18.8554 9 18.6417V16.6309C9.00045 16.4171 9.05427 16.2071 9.15623 16.0212C9.2582 15.8352 9.40485 15.6797 9.582 15.5696L12 14.0677M15 19.343V15.9296M15 19.343L17.382 20.82C17.5686 20.936 17.7823 20.9973 18 20.9973C18.2177 20.9973 18.4314 20.936 18.618 20.82L20.418 19.7029C20.5951 19.5928 20.7418 19.4373 20.8438 19.2514C20.9457 19.0654 20.9995 18.8554 21 18.6417V16.6309C20.9995 16.4171 20.9457 16.2071 20.8438 16.0212C20.7418 15.8352 20.5951 15.6797 20.418 15.5696L18 14.0677M15 15.9296L12 14.0677M15 15.9296L12 17.7914M15 15.9296L18 14.0677M15 15.9296L18 17.7914M15 15.9296V12.5162M12 14.0677V11.3556C12.0005 11.1419 12.0543 10.9319 12.1562 10.7459C12.2582 10.56 12.4049 10.4045 12.582 10.2944L14.382 9.17726C14.5686 9.06128 14.7823 9 15 9C15.2177 9 15.4314 9.06128 15.618 9.17726L17.418 10.2944C17.5951 10.4045 17.7418 10.56 17.8438 10.7459C17.9457 10.9319 17.9995 11.1419 18 11.3556V14.0677M12 17.7914L9.156 16.0227M12 17.7914V21M18 17.7914L20.844 16.0227M18 17.7914V21M15 12.5162L12.156 10.7474M15 12.5162L17.844 10.7474"
fill="none"
stroke={fill}
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1_2">
<rect width="22" height="22" fill="white" />
</clipPath>
</defs>
</svg>
@@ -0,0 +1,37 @@
<script lang="ts">
interface Props {
height?: string
width?: string
fill?: string
class?: string
}
let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props()
</script>
<svg
{width}
{height}
class={className}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_2)">
<path
d="M11.0586 0.306641C11.3106 0.315965 11.5581 0.378249 11.7822 0.495117C12.0248 0.621658 12.2331 0.804989 12.3897 1.0293L12.3916 1.03223L19.9785 12H18.2774L11.6963 2.4873V12H10.2959V2.4873L1.75196 14.8457C1.72866 14.8796 1.71294 14.9186 1.70508 14.959C1.69725 14.9995 1.69803 15.0418 1.70704 15.082C1.71605 15.1222 1.73351 15.1601 1.75782 15.1934C1.78214 15.2266 1.81306 15.2546 1.84864 15.2754H1.84766L7.75001 18.6445V20.2568L1.14844 16.4883L1.14258 16.4844C0.940841 16.3665 0.765835 16.2081 0.627936 16.0195C0.489961 15.8308 0.391884 15.6158 0.340827 15.3877C0.289772 15.1595 0.286682 14.9229 0.331061 14.6934C0.375444 14.464 0.466389 14.2462 0.598639 14.0537L0.599616 14.0518L9.59962 1.03223L9.60157 1.0293C9.75823 0.80485 9.96727 0.621686 10.21 0.495117C10.4336 0.378516 10.6802 0.316163 10.9316 0.306641C10.9529 0.304697 10.9743 0.299805 10.9961 0.299805C11.0173 0.299812 11.0379 0.304792 11.0586 0.306641Z"
{fill}
stroke="none"
/>
<path
d="M11.72 22.084C11.136 22.084 10.608 21.984 10.136 21.784C9.672 21.584 9.304 21.296 9.032 20.92C8.76 20.544 8.62 20.1 8.612 19.588H10.412C10.436 19.932 10.556 20.204 10.772 20.404C10.996 20.604 11.3 20.704 11.684 20.704C12.076 20.704 12.384 20.612 12.608 20.428C12.832 20.236 12.944 19.988 12.944 19.684C12.944 19.436 12.868 19.232 12.716 19.072C12.564 18.912 12.372 18.788 12.14 18.7C11.916 18.604 11.604 18.5 11.204 18.388C10.66 18.228 10.216 18.072 9.872 17.92C9.536 17.76 9.244 17.524 8.996 17.212C8.756 16.892 8.636 16.468 8.636 15.94C8.636 15.444 8.76 15.012 9.008 14.644C9.256 14.276 9.604 13.996 10.052 13.804C10.5 13.604 11.012 13.504 11.588 13.504C12.452 13.504 13.152 13.716 13.688 14.14C14.232 14.556 14.532 15.14 14.588 15.892H12.74C12.724 15.604 12.6 15.368 12.368 15.184C12.144 14.992 11.844 14.896 11.468 14.896C11.14 14.896 10.876 14.98 10.676 15.148C10.484 15.316 10.388 15.56 10.388 15.88C10.388 16.104 10.46 16.292 10.604 16.444C10.756 16.588 10.94 16.708 11.156 16.804C11.38 16.892 11.692 16.996 12.092 17.116C12.636 17.276 13.08 17.436 13.424 17.596C13.768 17.756 14.064 17.996 14.312 18.316C14.56 18.636 14.684 19.056 14.684 19.576C14.684 20.024 14.568 20.44 14.336 20.824C14.104 21.208 13.764 21.516 13.316 21.748C12.868 21.972 12.336 22.084 11.72 22.084ZM15.1325 15.556C15.1725 14.756 15.4525 14.14 15.9725 13.708C16.5005 13.268 17.1925 13.048 18.0485 13.048C18.6325 13.048 19.1325 13.152 19.5485 13.36C19.9645 13.56 20.2765 13.836 20.4845 14.188C20.7005 14.532 20.8085 14.924 20.8085 15.364C20.8085 15.868 20.6765 16.296 20.4125 16.648C20.1565 16.992 19.8485 17.224 19.4885 17.344V17.392C19.9525 17.536 20.3125 17.792 20.5685 18.16C20.8325 18.528 20.9645 19 20.9645 19.576C20.9645 20.056 20.8525 20.484 20.6285 20.86C20.4125 21.236 20.0885 21.532 19.6565 21.748C19.2325 21.956 18.7205 22.06 18.1205 22.06C17.2165 22.06 16.4805 21.832 15.9125 21.376C15.3445 20.92 15.0445 20.248 15.0125 19.36H16.6445C16.6605 19.752 16.7925 20.068 17.0405 20.308C17.2965 20.54 17.6445 20.656 18.0845 20.656C18.4925 20.656 18.8045 20.544 19.0205 20.32C19.2445 20.088 19.3565 19.792 19.3565 19.432C19.3565 18.952 19.2045 18.608 18.9005 18.4C18.5965 18.192 18.1245 18.088 17.4845 18.088H17.1365V16.708H17.4845C18.6205 16.708 19.1885 16.328 19.1885 15.568C19.1885 15.224 19.0845 14.956 18.8765 14.764C18.6765 14.572 18.3845 14.476 18.0005 14.476C17.6245 14.476 17.3325 14.58 17.1245 14.788C16.9245 14.988 16.8085 15.244 16.7765 15.556H15.1325Z"
{fill}
stroke="none"
/>
</g>
<defs>
<clipPath id="clip0_1_2">
<rect width="22" height="22" fill="white" />
</clipPath>
</defs>
</svg>
@@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
height?: string
width?: string
fill?: string
class?: string
}
let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props()
</script>
<svg
{width}
{height}
class={className}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_2)">
<path
d="M11.0586 0.306641C11.3106 0.315989 11.5582 0.378277 11.7822 0.495117C12.0247 0.621655 12.2331 0.805033 12.3897 1.0293L12.3916 1.03223L17.9033 9H16.2022L11.6963 2.4873V20.0986L12.75 19.4961V21.1094L12.333 21.3477L12.332 21.3486C11.925 21.5804 11.4645 21.7021 10.9961 21.7021C10.5275 21.7021 10.0664 21.5806 9.65919 21.3486V21.3477L1.14844 16.4883L1.14258 16.4844C0.940872 16.3666 0.765826 16.2081 0.627936 16.0195C0.489969 15.8308 0.391887 15.6158 0.340827 15.3877C0.289772 15.1595 0.286682 14.9229 0.331061 14.6934C0.375447 14.4641 0.466395 14.2462 0.598639 14.0537L0.599616 14.0518L9.59962 1.03223L9.60157 1.0293C9.75823 0.804889 9.9673 0.62167 10.21 0.495117C10.4336 0.378564 10.6802 0.316161 10.9316 0.306641C10.9528 0.304686 10.9744 0.299805 10.9961 0.299805C11.0172 0.299841 11.038 0.304797 11.0586 0.306641ZM1.75196 14.8457C1.72867 14.8796 1.71294 14.9186 1.70508 14.959C1.69725 14.9995 1.69803 15.0418 1.70704 15.082C1.71605 15.1222 1.73351 15.1601 1.75782 15.1934C1.78213 15.2266 1.81309 15.2546 1.84864 15.2754H1.84766L10.2959 20.0986V2.4873L1.75196 14.8457Z"
{fill}
stroke="none"
/>
<path
d="M18 10.8496C18.1472 10.8496 18.2858 10.9138 18.3857 11.0225C18.4851 11.1306 18.539 11.2749 18.5391 11.4229V11.9785H20.5928C20.7397 11.9786 20.8777 12.042 20.9775 12.1504C21.077 12.2586 21.1318 12.4037 21.1318 12.5518C21.1317 12.6997 21.0769 12.844 20.9775 12.9521C20.8777 13.0606 20.7397 13.124 20.5928 13.124H18.5391V15.9268H19.2959C19.9244 15.9268 20.5246 16.1988 20.9648 16.6777C21.4047 17.1563 21.6504 17.8029 21.6504 18.4746C21.6503 19.1463 21.4046 19.793 20.9648 20.2715C20.5246 20.7503 19.9243 21.0215 19.2959 21.0215H18.5391V21.5771C18.539 21.7251 18.4851 21.8694 18.3857 21.9775C18.2858 22.0862 18.1472 22.1504 18 22.1504C17.8528 22.1504 17.7142 22.0862 17.6143 21.9775C17.5149 21.8694 17.461 21.7251 17.4609 21.5771V21.0215H14.8887C14.7417 21.0214 14.6037 20.958 14.5039 20.8496C14.4044 20.7414 14.3496 20.5963 14.3496 20.4482C14.3497 20.3003 14.4045 20.156 14.5039 20.0479C14.6037 19.9394 14.7417 19.876 14.8887 19.876H17.4609V17.0732H16.7041C16.0756 17.0732 15.4754 16.8012 15.0352 16.3223C14.5953 15.8437 14.3496 15.1971 14.3496 14.5254C14.3497 13.8537 14.5954 13.207 15.0352 12.7285C15.4754 12.2497 16.0757 11.9785 16.7041 11.9785H17.4609V11.4229C17.461 11.2749 17.5149 11.1306 17.6143 11.0225C17.7142 10.9138 17.8528 10.8496 18 10.8496ZM18.5391 19.876H19.2959C19.63 19.876 19.9539 19.7313 20.1943 19.4697C20.4351 19.2078 20.5722 18.85 20.5723 18.4746C20.5723 18.0991 20.4352 17.7405 20.1943 17.4785C19.9539 17.217 19.63 17.0732 19.2959 17.0732H18.5391V19.876ZM16.7041 13.124C16.37 13.124 16.0461 13.2687 15.8057 13.5303C15.5649 13.7922 15.4278 14.15 15.4277 14.5254C15.4277 14.9009 15.5648 15.2595 15.8057 15.5215C16.0461 15.783 16.37 15.9268 16.7041 15.9268H17.4609V13.124H16.7041Z"
{fill}
stroke={fill}
stroke-width="0.3"
/>
</g>
<defs>
<clipPath id="clip0_1_2">
<rect width="22" height="22" fill="white" />
</clipPath>
</defs>
</svg>
@@ -17,6 +17,7 @@
import { Button } from '$lib/components/common'
import DocLink from '$lib/components/apps/editor/settingsPanel/DocLink.svelte'
import type { FloatingConfig } from '@melt-ui/svelte/internal/actions/floating'
import type { EscapeBehaviorType } from '@melt-ui/svelte/internal/actions'
export let closeButton: boolean = false
export let displayArrow: boolean = false
@@ -35,6 +36,7 @@
export let disabled: boolean = false
export let documentationLink: string | undefined = undefined
export let disableFocusTrap: boolean = false
export let escapeBehavior: EscapeBehaviorType = 'close'
let fullScreen = false
const dispatch = createEventDispatcher()
@@ -48,6 +50,7 @@
forceVisible: true,
portal,
disableFocusTrap,
escapeBehavior,
onOpenChange: ({ curr, next }) => {
if (curr != next) {
dispatch('openChange', next)
+31 -27
View File
@@ -1,4 +1,5 @@
import type { NewScript } from '$lib/gen'
import type { AssetWithAccessType } from './assets/lib'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import type { DiffDrawerI } from './diff_drawer'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
@@ -6,30 +7,33 @@ import type { ScheduleTrigger } from './triggers'
import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
export interface ScriptBuilderProps {
script: NewScript & { draft_triggers?: Trigger[] }
disableAi?: boolean
fullyLoaded?: boolean
initialPath?: string
template?: 'docker' | 'bunnative' | 'script'
initialArgs?: Record<string, any>
lockedLanguage?: boolean
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
}
script: NewScript & {
draft_triggers?: Trigger[]
fallback_access_types?: AssetWithAccessType[]
}
disableAi?: boolean
fullyLoaded?: boolean
initialPath?: string
template?: 'docker' | 'bunnative' | 'script'
initialArgs?: Record<string, any>
lockedLanguage?: boolean
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
}
@@ -17,6 +17,7 @@
schedules: true,
resources: true,
variables: true,
assets: false,
triggers: true,
audit_logs: true,
groups: true,
@@ -29,6 +30,7 @@
let currentWorkspace: string | null = $state(null)
async function saveSettings() {
console.log('Saving operator settings:', operatorWorkspaceSettings)
try {
await WorkspaceService.updateOperatorSettings({
workspace: $workspaceStore!,
@@ -48,6 +50,7 @@
schedules: { title: 'Schedules', description: 'View schedules' },
resources: { title: 'Resources', description: 'View resources' },
variables: { title: 'Variables', description: 'View variables' },
assets: { title: 'Assets', description: 'View assets' },
triggers: { title: 'Triggers', description: 'View all triggers (HTTP, Websocket, Kafka)' },
audit_logs: { title: 'Audit Logs', description: 'View audit logs' },
groups: { title: 'Groups', description: 'View groups and group members' },
@@ -63,7 +66,10 @@
workspace: $workspaceStore
})
if (settings.operator_settings !== null) {
operatorWorkspaceSettings = settings.operator_settings ?? operatorWorkspaceSettings
operatorWorkspaceSettings = {
...operatorWorkspaceSettings,
...(settings.operator_settings ?? {})
}
originalSettings = { ...operatorWorkspaceSettings }
}
})()
@@ -69,6 +69,11 @@
id: 'variables',
href: `${base}/variables`
},
{
label: 'Assets',
id: 'assets',
href: `${base}/assets`
},
{
label: 'Custom HTTP routes',
id: 'triggers',
@@ -33,7 +33,8 @@
Plus,
Unplug,
AlertCircle,
Database
Database,
Pyramid
} from 'lucide-svelte'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
@@ -178,6 +179,14 @@
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-resources',
aiDescription: 'Button to navigate to resources'
},
{
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
}
])
let defaultExtraTriggerLinks = $derived([
+40 -5
View File
@@ -4,7 +4,7 @@ import type { Schema, SupportedLanguage } from './common.js'
import { emptySchema, sortObject } from './utils.js'
import { tick } from 'svelte'
import initTsParser, { parse_deno, parse_outputs } from 'windmill-parser-wasm-ts'
import initTsParser, { parse_assets_ts, parse_deno, parse_outputs } from 'windmill-parser-wasm-ts'
import initRegexParsers, {
parse_sql,
parse_mysql,
@@ -16,9 +16,10 @@ import initRegexParsers, {
parse_mssql,
parse_db_resource,
parse_bash,
parse_powershell
parse_powershell,
parse_assets_sql
} from 'windmill-parser-wasm-regex'
import initPythonParser, { parse_python } from 'windmill-parser-wasm-py'
import initPythonParser, { parse_assets_py, parse_python } from 'windmill-parser-wasm-py'
import initGoParser, { parse_go } from 'windmill-parser-wasm-go'
import initPhpParser, { parse_php } from 'windmill-parser-wasm-php'
import initRustParser, { parse_rust } from 'windmill-parser-wasm-rust'
@@ -39,6 +40,7 @@ import wasmUrlNu from 'windmill-parser-wasm-nu/windmill_parser_wasm_bg.wasm?url'
import wasmUrlJava from 'windmill-parser-wasm-java/windmill_parser_wasm_bg.wasm?url'
import { workspaceStore } from './stores.js'
import { argSigToJsonSchemaType } from './inferArgSig.js'
import { type AssetWithAccessType } from './components/assets/lib.js'
const loadSchemaLastRun =
writable<[string | undefined, MainArgSignature | undefined, string | undefined]>(undefined)
@@ -78,6 +80,35 @@ async function initWasmJava() {
await initJavaParser(wasmUrlJava)
}
export async function inferAssets(
language: SupportedLanguage | undefined,
code: string
): Promise<AssetWithAccessType[]> {
if (language === 'duckdb') {
await initWasmRegex()
return JSON.parse(parse_assets_sql(code))
}
if (language === 'deno' || language === 'nativets' || language === 'bun') {
await initWasmTs()
return JSON.parse(parse_assets_ts(code))
}
if (language === 'python3') {
await initWasmPython()
return JSON.parse(parse_assets_py(code))
}
return []
}
const SQL_LANGUAGES = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'oracledb',
'duckdb'
]
export async function inferArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
@@ -97,12 +128,17 @@ export async function inferArgs(
}
let inlineDBResource: string | undefined = undefined
if (language && SQL_LANGUAGES.includes(language)) {
await initWasmRegex()
}
if (
['postgresql', 'mysql', 'bigquery', 'snowflake', 'mssql', 'oracledb'].includes(language ?? '')
) {
await initWasmRegex()
inlineDBResource = parse_db_resource(code)
}
if (language == 'python3') {
await initWasmPython()
inferedSchema = JSON.parse(parse_python(code, mainOverride))
@@ -151,7 +187,6 @@ export async function inferArgs(
]
}
} else if (language == 'duckdb') {
await initWasmRegex()
inferedSchema = JSON.parse(parse_duckdb(code))
} else if (language == 'snowflake') {
inferedSchema = JSON.parse(parse_snowflake(code))
+3 -2
View File
@@ -27,11 +27,12 @@ export type UsePromiseResult<T> = (
export type UsePromiseOptions = {
loadInit?: boolean
clearValueOnRefresh?: boolean
}
export function usePromise<T>(
createPromise: () => Promise<T>,
{ loadInit = true }: UsePromiseOptions = {}
{ loadInit = true, clearValueOnRefresh = true }: UsePromiseOptions = {}
): UsePromiseResult<T> {
const ret: any = $state({
status: 'loading',
@@ -40,7 +41,7 @@ export function usePromise<T>(
let promise = createPromise()
ret.__promise = promise
ret.status = 'loading'
ret.value = undefined
if (clearValueOnRefresh) ret.value = undefined
ret.error = undefined
promise
+35 -5
View File
@@ -165,9 +165,9 @@ export function displayDate(
}
const dateChoices: Intl.DateTimeFormatOptions = displayDate
? {
day: 'numeric',
month: 'numeric'
}
day: 'numeric',
month: 'numeric'
}
: {}
return date.toLocaleString(undefined, {
...timeChoices,
@@ -974,7 +974,7 @@ export async function tryEvery({
try {
await tryCode()
break
} catch (err) { }
} catch (err) {}
i++
}
if (i >= times) {
@@ -1241,7 +1241,7 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
if (meltItem) {
return meltItem(node)
}
return { destroy: () => { } }
return { destroy: () => {} }
}
export type Item = {
@@ -1433,4 +1433,34 @@ export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeede
return observer // return for testing
}
export function clone<T>(t: T): T {
if (typeof t === 'function') throw new Error('Cannot clone a function')
if (typeof t === 'object') return stateSnapshot(t) as T
return t
}
export const editorPositionMap: Record<string, IPosition> = {}
export type S3Uri = `s3://${string}/${string}`
export type S3Object =
| S3Uri
| {
s3: string
storage?: string
}
export function parseS3Object(s3Object: S3Object): { s3: string; storage?: string } {
if (typeof s3Object === 'object') return s3Object
const match = s3Object.match(/^s3:\/\/([^/]*)\/(.*)$/)
return { storage: match?.[1] || undefined, s3: match?.[2] ?? '' }
}
export function formatS3Object(s3Object: S3Object): S3Uri {
if (typeof s3Object === 'object') return `s3://${s3Object.storage ?? ''}/${s3Object.s3}`
return s3Object
}
export function isS3Uri(uri: string): uri is S3Uri {
const match = uri.match(/^s3:\/\/([^/]*)\/(.*)$/)
return !!match && match.length === 3
}
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Assets' }
}
}
@@ -0,0 +1,121 @@
<script lang="ts">
import { formatAsset, formatAssetKind } from '$lib/components/assets/lib'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, ClearableInput } from '$lib/components/common'
import DbManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { Cell, DataTable } from '$lib/components/table'
import Head from '$lib/components/table/Head.svelte'
import { AssetService } from '$lib/gen'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { pluralize, truncate } from '$lib/utils'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import AssetsUsageDrawer from '$lib/components/assets/AssetsUsageDrawer.svelte'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { AlertTriangle } from 'lucide-svelte'
import { untrack } from 'svelte'
let assets = usePromise(() => AssetService.listAssets({ workspace: $workspaceStore ?? '' }), {
loadInit: false
})
$effect(() => {
$workspaceStore && untrack(() => assets.refresh())
})
let filterText: string = $state('')
let filteredAssets = $derived(
assets.value?.filter((asset) =>
formatAsset(asset).toLowerCase().includes(filterText.toLowerCase())
) ?? []
)
let s3FilePicker: S3FilePicker | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
let assetsUsageDropdown: AssetsUsageDrawer | undefined = $state()
</script>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.assets}
<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="Assets"
tooltip="Assets show up here whenever you use them in Windmill."
documentationLink="https://www.windmill.dev/docs/core_concepts/assets"
/>
<Alert title="Assets may be missing from this page" type="info" class="mb-4">
Assets are not detected for old scripts and flows that were deployed before the assets feature
was introduced. Re-deploy them to trigger asset detection.
</Alert>
<ClearableInput bind:value={filterText} placeholder="Search assets" class="mb-4" />
<DataTable>
<Head>
<tr>
<Cell head first></Cell>
<Cell head>Asset name</Cell>
<Cell head></Cell>
<Cell head></Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#if assets.status == 'ok' && filteredAssets.length === 0}
<tr class="h-14">
<Cell colspan="3" class="text-center text-tertiary">No assets found</Cell>
</tr>
{/if}
{#each filteredAssets as asset}
{@const assetUri = formatAsset(asset)}
<tr class="h-14">
<Cell first class="w-16">
<Tooltip>
<AssetGenericIcon
assetKind={asset.kind}
size="24px"
fill=""
class="!fill-secondary !stroke-secondary"
/>
<svelte:fragment slot="text">{formatAssetKind(asset)}</svelte:fragment>
</Tooltip>
</Cell>
<Cell class="w-[75%] flex flex-col">
<span>{truncate(asset.path, 92)}</span>
<span class="text-2xs text-tertiary">{formatAssetKind(asset)}</span>
</Cell>
<Cell>
<a href={`#${assetUri}`} onclick={() => assetsUsageDropdown?.open(asset)}>
{pluralize(asset.usages.length, 'usage')}
</a>
</Cell>
<Cell>
{#if assetCanBeExplored(asset, asset.metadata) && !$userStore?.operator}
<ExploreAssetButton
{asset}
{s3FilePicker}
{dbManagerDrawer}
_resourceMetadata={asset.metadata}
class="w-24"
/>
{/if}
{#if asset.kind === 'resource' && asset.metadata === undefined}
<Tooltip class={'w-24 flex items-center justify-center'}>
<AlertTriangle size={20} class="text-orange-600 dark:text-orange-500" />
<svelte:fragment slot="text">Could not find resource</svelte:fragment>
</Tooltip>
{/if}
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</CenteredPage>
{/if}
<AssetsUsageDrawer bind:this={assetsUsageDropdown} />
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />
@@ -0,0 +1,68 @@
<script module lang="ts">
export function assetCanBeExplored(
asset: Asset,
_resourceMetadata?: { resource_type?: string }
): boolean {
return (
asset.kind === 's3object' ||
(asset.kind === 'resource' && isDbType(_resourceMetadata?.resource_type))
)
}
</script>
<script lang="ts">
import { isDbType } from '$lib/components/apps/components/display/dbtable/utils'
import { formatAsset, type Asset } from '$lib/components/assets/lib'
import { Button, ButtonType } from '$lib/components/common'
import DbManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { userStore } from '$lib/stores'
import { isS3Uri } from '$lib/utils'
import { Database, File } from 'lucide-svelte'
const {
asset,
_resourceMetadata,
s3FilePicker,
dbManagerDrawer,
onClick,
class: className = '',
noText = false,
buttonVariant = 'border',
btnClasses = ''
}: {
asset: Asset
_resourceMetadata?: { resource_type?: string }
s3FilePicker?: S3FilePicker
dbManagerDrawer?: DbManagerDrawer
onClick?: () => void
class?: string
noText?: boolean
buttonVariant?: ButtonType.Variant
btnClasses?: string
} = $props()
const assetUri = $derived(formatAsset(asset))
</script>
<Button
disabled={$userStore?.operator}
size="xs"
variant={buttonVariant}
spacingSize="xs2"
wrapperClasses={className}
{btnClasses}
on:click={async () => {
if (asset.kind === 'resource' && isDbType(_resourceMetadata?.resource_type)) {
dbManagerDrawer?.openDrawer(_resourceMetadata.resource_type, asset.path)
} else if (asset.kind === 's3object' && isS3Uri(assetUri)) {
s3FilePicker?.open(assetUri)
}
onClick?.()
}}
>
{#if asset.kind === 's3object'}
<span class:hidden={noText}>Explore</span> <File size={18} />
{:else if asset.kind === 'resource'}
<span class:hidden={noText}>Manage</span> <Database size={18} />
{/if}
</Button>
@@ -60,8 +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'
import ExploreAssetButton, { assetCanBeExplored } from '../assets/ExploreAssetButton.svelte'
import DbManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
type ResourceTypeW = ResourceType & { canWrite: boolean }
@@ -414,6 +414,8 @@
})
}
})
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
</script>
<ConfirmationModal
@@ -900,11 +902,12 @@
</div>
</Cell>
<Cell class="flex justify-end">
{#if path && isDbType(resource_type)}
<DbManagerDrawerButton
resourcePath={path}
resourceType={resource_type}
class="mr-8"
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator}
<ExploreAssetButton
asset={{ kind: 'resource', path }}
{dbManagerDrawer}
_resourceMetadata={{ resource_type }}
class="w-24"
/>
{/if}
<Dropdown
@@ -1083,6 +1086,7 @@
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<ResourceEditorDrawer bind:this={resourceEditor} on:refresh={loadResources} />
<DbManagerDrawer bind:this={dbManagerDrawer} />
<ShareModal
bind:this={shareModal}
+9 -2
View File
@@ -1,5 +1,7 @@
const plugin = require('tailwindcss/plugin')
const { tailwindClasses } = require('./src/lib/components/apps/editor/componentsPanel/tailwindUtils')
const {
tailwindClasses
} = require('./src/lib/components/apps/editor/componentsPanel/tailwindUtils')
const { zIndexes } = require('./src/lib/zIndexes')
const lightTheme = {
@@ -477,7 +479,8 @@ const config = {
},
animation: {
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite',
'zoom-in': 'zoom-in 0.25s ease-in-out'
'zoom-in': 'zoom-in 0.25s ease-in-out',
'fade-out': 'fade-out 1s ease-in-out'
},
keyframes: {
'spin-counter-clockwise': {
@@ -486,6 +489,10 @@ const config = {
'zoom-in': {
'0%': { transform: 'scale(0.95)' },
'100%': { transform: 'scale(1)' }
},
'fade-out': {
'0%': { opacity: '1' },
'100%': { opacity: '0' }
}
}
}
+22 -1
View File
@@ -100,7 +100,7 @@ components:
type: string
required:
- expr
FlowModule:
type: object
properties:
@@ -270,6 +270,27 @@ components:
type: string
is_trigger:
type: boolean
asset_fallback_access_types:
type: array
items:
type: object
required:
- path
- kind
properties:
path:
type: string
kind:
type: string
enum:
- s3object
- resource
access_type:
type: string
enum:
- r
- w
- rw
required:
- type
- content
+51 -12
View File
@@ -11,7 +11,8 @@ import time
import warnings
import json
from json import JSONDecodeError
from typing import Dict, Any, Union, Literal
from typing import Dict, Any, Union, Literal, Optional
import re
import httpx
@@ -312,6 +313,7 @@ class Windmill:
return result_text
def get_variable(self, path: str) -> str:
path = parse_variable_syntax(path) or path
if self.mocked_api is not None:
variables = self.mocked_api["variables"]
try:
@@ -326,6 +328,7 @@ class Windmill:
return self.get(f"/w/{self.workspace}/variables/get_value/{path}").json()
def set_variable(self, path: str, value: str, is_secret: bool = False) -> None:
path = parse_variable_syntax(path) or path
if self.mocked_api is not None:
self.mocked_api["variables"][path] = value
return
@@ -358,6 +361,7 @@ class Windmill:
path: str,
none_if_undefined: bool = False,
) -> dict | None:
path = parse_resource_syntax(path) or path
if self.mocked_api is not None:
resources = self.mocked_api["resources"]
try:
@@ -391,6 +395,7 @@ class Windmill:
path: str,
resource_type: str,
):
path = parse_resource_syntax(path) or path
if self.mocked_api is not None:
self.mocked_api["resources"][path] = value
return
@@ -485,6 +490,7 @@ class Windmill:
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from DuckDB
"""
s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path
try:
raw_obj = self.post(
f"/w/{self.workspace}/job_helpers/v2/duckdb_connection_settings",
@@ -506,6 +512,7 @@ class Windmill:
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from Polars
"""
s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path
try:
raw_obj = self.post(
f"/w/{self.workspace}/job_helpers/v2/polars_connection_settings",
@@ -527,6 +534,7 @@ class Windmill:
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection using boto3
"""
s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path
try:
s3_resource = self.post(
f"/w/{self.workspace}/job_helpers/v2/s3_resource_info",
@@ -540,7 +548,7 @@ class Windmill:
"Could not generate Boto3 S3 connection settings from the provided resource"
) from e
def load_s3_file(self, s3object: S3Object, s3_resource_path: str | None) -> bytes:
def load_s3_file(self, s3object: S3Object | str, s3_resource_path: str | None) -> bytes:
"""
Load a file from the workspace s3 bucket and returns its content as bytes.
@@ -552,11 +560,12 @@ class Windmill:
file_content = my_obj_content.decode("utf-8")
'''
"""
s3object = parse_s3_object(s3object)
with self.load_s3_file_reader(s3object, s3_resource_path) as file_reader:
return file_reader.read()
def load_s3_file_reader(
self, s3object: S3Object, s3_resource_path: str | None
self, s3object: S3Object | str, s3_resource_path: str | None
) -> BufferedReader:
"""
Load a file from the workspace s3 bucket and returns the bytes stream.
@@ -569,6 +578,7 @@ class Windmill:
print(file_reader.read())
'''
"""
s3object = parse_s3_object(s3object)
reader = S3BufferedReader(
f"{self.workspace}",
self.client,
@@ -580,7 +590,7 @@ class Windmill:
def write_s3_file(
self,
s3object: S3Object | None,
s3object: S3Object | str | None,
file_content: BufferedReader | bytes,
s3_resource_path: str | None,
content_type: str | None = None,
@@ -603,6 +613,7 @@ class Windmill:
client.write_s3_file(s3_obj, my_file)
'''
"""
s3object = parse_s3_object(s3object)
# httpx accepts either bytes or "a bytes generator" as content. If it's a BufferedReader, we need to convert it to a generator
if isinstance(file_content, BufferedReader):
content_payload = bytes_generator(file_content)
@@ -644,12 +655,12 @@ class Windmill:
raise Exception("Could not write file to S3") from e
return S3Object(s3=response["file_key"])
def sign_s3_objects(self, s3_objects: list[S3Object]) -> list[S3Object]:
def sign_s3_objects(self, s3_objects: list[S3Object | str]) -> list[S3Object]:
return self.post(
f"/w/{self.workspace}/apps/sign_s3_objects", json={"s3_objects": s3_objects}
f"/w/{self.workspace}/apps/sign_s3_objects", json={"s3_objects": list(map(parse_s3_object, s3_objects))}
).json()
def sign_s3_object(self, s3_object: S3Object) -> S3Object:
def sign_s3_object(self, s3_object: S3Object | str) -> S3Object:
return self.post(
f"/w/{self.workspace}/apps/sign_s3_objects",
json={"s3_objects": [s3_object]},
@@ -1027,7 +1038,7 @@ def boto3_connection_settings(s3_resource_path: str = "") -> Boto3ConnectionSett
@init_global_client
def load_s3_file(s3object: S3Object, s3_resource_path: str | None = None) -> bytes:
def load_s3_file(s3object: S3Object | str, s3_resource_path: str | None = None) -> bytes:
"""
Load the entire content of a file stored in S3 as bytes
"""
@@ -1038,7 +1049,7 @@ def load_s3_file(s3object: S3Object, s3_resource_path: str | None = None) -> byt
@init_global_client
def load_s3_file_reader(
s3object: S3Object, s3_resource_path: str | None = None
s3object: S3Object | str, s3_resource_path: str | None = None
) -> BufferedReader:
"""
Load the content of a file stored in S3
@@ -1050,7 +1061,7 @@ def load_s3_file_reader(
@init_global_client
def write_s3_file(
s3object: S3Object | None,
s3object: S3Object | str | None,
file_content: BufferedReader | bytes,
s3_resource_path: str | None = None,
content_type: str | None = None,
@@ -1075,7 +1086,7 @@ def write_s3_file(
@init_global_client
def sign_s3_objects(s3_objects: list[S3Object]) -> list[S3Object]:
def sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]:
"""
Sign S3 objects to be used by anonymous users in public apps
Returns a list of signed s3 tokens
@@ -1084,7 +1095,7 @@ def sign_s3_objects(s3_objects: list[S3Object]) -> list[S3Object]:
@init_global_client
def sign_s3_object(s3_object: S3Object) -> S3Object:
def sign_s3_object(s3_object: S3Object| str) -> S3Object:
"""
Sign S3 object to be used by anonymous users in public apps
Returns a signed s3 object
@@ -1336,3 +1347,31 @@ def task(*args, **kwargs):
return f(args[0], None)
else:
return lambda x: f(x, kwargs.get("tag"))
def parse_resource_syntax(s: str) -> Optional[str]:
"""Parse resource syntax from string."""
if s is None:
return None
if s.startswith("$res:"):
return s[5:]
if s.startswith("res://"):
return s[6:]
return None
def parse_s3_object(s3_object: S3Object | str) -> S3Object:
"""Parse S3 object from string or S3Object format."""
if isinstance(s3_object, str):
match = re.match(r'^s3://([^/]*)/(.*)$', s3_object)
if match:
return S3Object(s3=match.group(2) or "", storage=match.group(1) or None)
return S3Object(s3="")
else:
return s3_object
def parse_variable_syntax(s: str) -> Optional[str]:
"""Parse variable syntax from string."""
if s.startswith("var://"):
return s[6:]
return None
+35 -10
View File
@@ -11,7 +11,11 @@ import {
} from "./index";
import { OpenAPI } from "./index";
// import type { DenoS3LightClientSettings } from "./index";
import { DenoS3LightClientSettings, type S3Object } from "./s3Types";
import {
DenoS3LightClientSettings,
S3ObjectRecord,
type S3Object,
} from "./s3Types";
export {
AdminService,
@@ -81,7 +85,7 @@ export async function getResource(
path?: string,
undefinedIfEmpty?: boolean
): Promise<any> {
path = path ?? getStatePath();
path = parseResourceSyntax(path) ?? path ?? getStatePath();
const mockedApi = await getMockedApi();
if (mockedApi) {
if (mockedApi.resources[path]) {
@@ -367,7 +371,7 @@ export async function setResource(
path?: string,
initializeToTypeIfNotExist?: string
): Promise<void> {
path = path ?? getStatePath();
path = parseResourceSyntax(path) ?? path ?? getStatePath();
const mockedApi = await getMockedApi();
if (mockedApi) {
mockedApi.resources[path] = value;
@@ -551,6 +555,7 @@ export async function getState(): Promise<any> {
* @returns variable value
*/
export async function getVariable(path: string): Promise<string> {
path = parseVariableSyntax(path) ?? path;
const mockedApi = await getMockedApi();
if (mockedApi) {
if (mockedApi.variables[path]) {
@@ -584,6 +589,7 @@ export async function setVariable(
isSecretIfNotExist?: boolean,
descriptionIfNotExist?: string
): Promise<void> {
path = parseVariableSyntax(path) ?? path;
const mockedApi = await getMockedApi();
if (mockedApi) {
mockedApi.variables[path] = value;
@@ -642,7 +648,8 @@ export async function denoS3LightClientSettings(
const s3Resource = await HelpersService.s3ResourceInfo({
workspace: workspace,
requestBody: {
s3_resource_path: s3_resource_path,
s3_resource_path:
parseResourceSyntax(s3_resource_path) ?? s3_resource_path,
},
});
let settings: DenoS3LightClientSettings = {
@@ -706,13 +713,14 @@ export async function loadS3FileStream(
s3object: S3Object,
s3ResourcePath: string | undefined = undefined
): Promise<Blob | undefined> {
let s3Obj = s3object && parseS3Object(s3object);
let params: Record<string, string> = {};
params["file_key"] = s3object.s3;
params["file_key"] = s3Obj.s3;
if (s3ResourcePath !== undefined) {
params["s3_resource_path"] = s3ResourcePath;
}
if (s3object.storage !== undefined) {
params["storage"] = s3object.storage;
if (s3Obj.storage !== undefined) {
params["storage"] = s3Obj.storage;
}
const queryParams = new URLSearchParams(params);
@@ -765,13 +773,15 @@ export async function writeS3File(
fileContentBlob = fileContent as Blob;
}
let s3Obj = s3object && parseS3Object(s3object);
const response = await HelpersService.fileUpload({
workspace: getWorkspace(),
fileKey: s3object?.s3,
fileKey: s3Obj?.s3,
fileExtension: undefined,
s3ResourcePath: s3ResourcePath,
requestBody: fileContentBlob,
storage: s3object?.storage,
storage: s3Obj?.storage,
contentType,
contentDisposition,
});
@@ -791,7 +801,7 @@ export async function signS3Objects(
const signedKeys = await AppService.signS3Objects({
workspace: getWorkspace(),
requestBody: {
s3_objects: s3objects,
s3_objects: s3objects.map(parseS3Object),
},
});
return signedKeys;
@@ -1169,3 +1179,18 @@ interface MockedApi {
variables: Record<string, string>;
resources: Record<string, any>;
}
function parseResourceSyntax(s: string | undefined) {
if (s?.startsWith("$res:")) return s.substring(5);
if (s?.startsWith("res://")) return s.substring(6);
}
export function parseS3Object(s3Object: S3Object): S3ObjectRecord {
if (typeof s3Object === "object") return s3Object;
const match = s3Object.match(/^s3:\/\/([^/]*)\/(.*)$/);
return { storage: match?.[1] || undefined, s3: match?.[2] ?? "" };
}
function parseVariableSyntax(s: string) {
if (s.startsWith("var://")) return s.substring(6);
}
+4 -1
View File
@@ -1,4 +1,7 @@
export type S3Object = {
export type S3Object = S3ObjectURI | S3ObjectRecord;
export type S3ObjectURI = `s3://${string}/${string}`;
export type S3ObjectRecord = {
s3: string;
storage?: string;
};
+4 -1
View File
@@ -1,4 +1,7 @@
export type S3Object = {
export type S3Object = S3ObjectURI | S3ObjectRecord;
export type S3ObjectURI = `s3://${string}/${string}`;
export type S3ObjectRecord = {
s3: string;
storage?: string;
presigned?: string;