Compare commits

...

44 Commits

Author SHA1 Message Date
Diego Imbert
97dec88e26 R/W/RW selection and changes node pos in flow 2025-07-02 23:45:46 +02:00
Diego Imbert
50bd32496d AssetsDropdownButton in flow script editor 2025-07-02 19:07:04 +02:00
Diego Imbert
696223a51e Refactor to allow for custom R/W modes 2025-07-02 18:43:44 +02:00
Diego Imbert
e154431f5f refactor + remove linkAssets 2025-07-02 16:38:01 +02:00
Diego Imbert
9283bfae30 listAssetsByUsage and asset nodes on transitive usages 2025-07-02 16:17:01 +02:00
Diego Imbert
f18b051ce0 Better layout fix algorithm 2025-07-02 15:20:36 +02:00
Diego Imbert
b91801c4ab right positioning when mixing R and W assets 2025-07-02 14:01:59 +02:00
Diego Imbert
977ee7f199 right positioning when mixing read and write nodes 2025-07-02 13:52:58 +02:00
Diego Imbert
dc86baada6 accessType in ctx + fix insert button positioning 2025-07-02 13:03:11 +02:00
Diego Imbert
4d57605193 write mode node (ui) 2025-07-02 12:46:25 +02:00
Diego Imbert
5221dd6cf3 resource editor 2025-07-02 11:41:52 +02:00
Diego Imbert
42e2eadaf3 Fix y positioning of nodes with assets 2025-07-02 11:14:05 +02:00
Diego Imbert
b9ad78eed5 single computeAssetNodes function 2025-07-01 17:38:30 +02:00
Diego Imbert
e1bfa5fbef correct offset 2025-07-01 17:29:03 +02:00
Diego Imbert
ad88f4c7ef explore asset btn in flow asset node 2025-07-01 16:38:53 +02:00
Diego Imbert
80526e3169 fix layoutNodes not handling first assetsMap 2025-07-01 15:38:31 +02:00
Diego Imbert
7f00989030 Merge branch 'main' into di/assets-xyflownodes 2025-07-01 14:36:24 +02:00
Diego Imbert
4ca275a26c move to dedicated Asset ctx 2025-07-01 14:33:51 +02:00
Diego Imbert
2b9a397236 Asset nodes 2025-07-01 14:11:58 +02:00
Diego Imbert
c44e49ddbb nit 2025-07-01 12:02:59 +02:00
Diego Imbert
8f2b883d55 Better UX, avoid moving parts 2025-07-01 11:42:59 +02:00
Diego Imbert
d3846c5858 Show occurence count in flow 2025-07-01 11:18:41 +02:00
Diego Imbert
c6c908c3c8 Highlight assets used in modules 2025-07-01 11:03:34 +02:00
Diego Imbert
a05b8bc56c More Generic OnChange 2025-07-01 10:04:47 +02:00
Diego Imbert
f57c5141db flow asset badge 2025-07-01 09:56:58 +02:00
Diego Imbert
29610cab09 nit fix rename 2025-06-27 17:05:37 +02:00
Diego Imbert
2f13eb4eb8 +S3 Object editor bar 2025-06-27 14:06:32 +02:00
Diego Imbert
b9cd037a9b +Resource in DuckDB 2025-06-27 13:37:25 +02:00
Diego Imbert
031b7d48ca Merge branch 'main' into di/assets 2025-06-26 18:55:35 +02:00
Diego Imbert
89defe354a warning when incorrect resource 2025-06-26 17:47:27 +02:00
Diego Imbert
ee3df14473 edit resource btn 2025-06-26 17:15:08 +02:00
Diego Imbert
c57f96a79e improve DbManagerButton + asset dropdown button 2025-06-26 16:57:30 +02:00
Diego Imbert
f816436614 AssetsDetectedBadge 2025-06-26 12:26:37 +02:00
Diego Imbert
4f5d41fab6 clone impl 2025-06-26 11:44:00 +02:00
Diego Imbert
209bef7d9f Merge branch 'main' into di/assets 2025-06-26 11:24:06 +02:00
Diego Imbert
d0b03533b9 assets in JobPreview 2025-06-25 20:41:08 +02:00
Diego Imbert
4e2a1b8ac9 tweak 2025-06-25 20:18:47 +02:00
Diego Imbert
677b196295 better occurence list 2025-06-25 20:00:02 +02:00
Diego Imbert
3e481b9eb1 filterText 2025-06-25 19:14:25 +02:00
Diego Imbert
7ed21aba73 remove pagination 2025-06-25 19:08:49 +02:00
Diego Imbert
761891f0c1 S3 object Preview 2025-06-25 18:59:25 +02:00
Diego Imbert
7f9a4dee60 iterate on assets 2025-06-25 18:34:26 +02:00
Diego Imbert
3241924c18 parse assets (duckdb) 2025-06-25 14:30:41 +02:00
Diego Imbert
310c243035 assets migration 2025-06-25 12:34:58 +02:00
39 changed files with 1492 additions and 108 deletions

1
backend/Cargo.lock generated
View File

@@ -15108,6 +15108,7 @@ dependencies = [
"lazy_static",
"regex",
"regex-lite",
"serde",
"serde_json",
"windmill-parser",
]

View File

@@ -0,0 +1,4 @@
DROP TABLE asset;
DROP TYPE ASSET_USAGE_KIND;
DROP TYPE ASSET_USAGE_ACCESS_TYPE;
DROP TYPE ASSET_KIND;

View File

@@ -0,0 +1,12 @@
CREATE TYPE ASSET_USAGE_KIND AS ENUM ('script', 'flow');
CREATE TYPE ASSET_USAGE_ACCESS_TYPE AS ENUM ('r', 'w', 'rw');
CREATE TYPE ASSET_KIND AS ENUM ('s3object', 'resource');
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_USAGE_ACCESS_TYPE NOT NULL,
usage_path VARCHAR(255) NOT NULL,
usage_kind ASSET_USAGE_KIND NOT NULL
);

View File

@@ -19,3 +19,4 @@ windmill-parser.workspace = true
anyhow.workspace = true
lazy_static.workspace = true
serde_json.workspace = true
serde.workspace = true

View File

@@ -7,6 +7,7 @@ use regex::Regex;
#[cfg(target_arch = "wasm32")]
use regex_lite::Regex;
use serde::Serialize;
use serde_json::json;
use std::{
@@ -19,6 +20,81 @@ 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__";
#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all(serialize = "lowercase"))]
pub enum AssetUsageAccessType {
R,
W,
RW,
}
#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all(serialize = "lowercase"))]
pub enum AssetKind {
S3Object,
Resource,
}
#[derive(Serialize)]
pub struct ParseAssetsResult<'a> {
pub kind: AssetKind,
pub path: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_type: Option<AssetUsageAccessType>, // None in case of ambiguity
}
pub fn parse_assets<'a>(code: &'a str) -> anyhow::Result<Vec<ParseAssetsResult<'a>>> {
use AssetUsageAccessType::*;
let mut assets = vec![];
lazy_static::lazy_static! {
static ref RE_S3_PATH: Regex = Regex::new(r#"['"]s3://([^\s'"]+)['"]"#).unwrap();
static ref RE_RESOURCE_PATH: Regex = Regex::new(r#"ATTACH +['"]\$res:([^\s'"]+)['"]"#).unwrap();
}
for cap in RE_S3_PATH.captures_iter(code) {
assets.push(ParseAssetsResult {
kind: AssetKind::S3Object,
path: cap.get(1).unwrap().as_str(),
access_type: None,
});
}
for cap in RE_RESOURCE_PATH.captures_iter(code) {
assets.push(ParseAssetsResult {
kind: AssetKind::Resource,
path: cap.get(1).unwrap().as_str(),
access_type: None,
});
}
let assets = {
let mut arr: Vec<ParseAssetsResult<'a>> = vec![];
for asset in assets {
// Remove duplicates
if let Some(existing) = arr
.iter_mut()
.find(|x| x.path == asset.path && 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_key(|a| a.path);
arr
};
Ok(assets)
}
pub fn parse_mysql_sig(code: &str) -> anyhow::Result<MainArgSignature> {
let parsed = parse_mysql_file(&code)?;
if let Some(x) = parsed {

View File

@@ -168,4 +168,14 @@ 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();
}
}
// for related places search: ADD_NEW_LANG

View File

@@ -12942,6 +12942,89 @@ 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'
/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:
@@ -17168,3 +17251,27 @@ 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
Asset:
type: object
properties:
path:
type: string
kind:
$ref: '#/components/schemas/AssetKind'
required: [path, kind]

View File

@@ -0,0 +1,82 @@
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_build_object(
'path', path,
'kind', kind,
'usages', ARRAY_AGG(jsonb_build_object(
'path', usage_path,
'kind', usage_kind
))
) as "list!: _"
FROM asset
WHERE workspace_id = $1
GROUP BY path, 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"#,
w_id,
usage.path,
usage.kind as AssetUsageKind
)
.fetch_all(&mut *tx)
.await?;
assets_vec.push(assets);
}
Ok(Json(assets_vec))
}

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(

View File

@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[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,
}
#[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,
}

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;

View File

@@ -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>

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,19 @@
'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'].includes(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 +526,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 +586,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 +641,20 @@ 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) => {
if (lang === 'duckdb') {
let s = `'${formatS3Object(s3obj.detail)}'`
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)
}
}}
/>
<div class="flex justify-between items-center overflow-y-auto w-full p-0.5">
<div class="flex items-center">
<div
@@ -684,6 +711,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 showResourceTypePicker && customUi?.type != false}
<Button
aiId="editor-bar-add-resource-type"

View File

@@ -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}

View File

@@ -6,11 +6,12 @@
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 from '../../routes/(root)/(logged)/assets/ExploreAssetButton.svelte'
interface Props {
initialValue?: string | undefined
@@ -140,6 +141,7 @@
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
</script>
<AppConnect
@@ -247,6 +249,12 @@
/>
</div>
{#if showSchemaExplorer && isDbType(resourceType) && value}
<DBManagerDrawerButton {resourceType} resourcePath={value} />
<ExploreAssetButton
_resourceMetadata={{ resource_type: resourceType }}
asset={{ kind: 'resource', path: value }}
{dbManagerDrawer}
/>
{/if}
</div>
<DbManagerDrawer bind:this={dbManagerDrawer} />

View File

@@ -16,7 +16,14 @@
import { workspaceStore } from '$lib/stores'
import { HelpersService } 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'
@@ -64,6 +71,7 @@
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
selectAndClose: { s3: string; storage: string | undefined }
}>()
let drawer: Drawer | undefined = $state()
@@ -376,9 +384,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 }
@@ -416,6 +423,9 @@
}
async function selectAndClose() {
if (selectedFileKey?.s3) {
dispatch('selectAndClose', { s3: selectedFileKey.s3, storage })
}
drawer?.closeDrawer?.()
}

View File

@@ -6,7 +6,7 @@
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 +46,8 @@
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'
interface Props {
// Exported
@@ -133,6 +135,11 @@
dispatch('change', { code, schema })
})
let assets = usePromise(() => inferAssets(lang, code))
$effect(() => {
untrack(() => assets.refresh()), [lang, code]
})
let width = $state(1200)
let testJobLoader: TestJobLoader | undefined = $state(undefined)
@@ -504,6 +511,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 assets.status === 'ok' && assets.value.length > 0}
<AssetsDropdownButton assets={assets.value} />
{/if}
{#if testPanelSize === 0}
<HideButton
hidden={true}
@@ -553,6 +563,7 @@
{/if}
{/if}
</div>
{#key lang}
<Editor
lineNumbersMinChars={4}

View File

@@ -0,0 +1,186 @@
<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, formatAsset, type Asset, type AssetWithAccessType } from './lib'
import DbManagerDrawer from '../DBManagerDrawer.svelte'
import { untrack } from 'svelte'
import { ResourceService, type AssetUsageAccessType } 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 Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
let {
assets,
enableChangeAnimation = true,
size = 'xs',
noBtnText = false,
popoverPlacement = 'bottom-end',
disableLiTooltip = false,
accessTypeOverrides,
onHoverLi,
liSubtitle,
onAccessTypeChanged
}: {
assets: AssetWithAccessType[]
enableChangeAnimation?: boolean
size?: 'xs' | '3xs'
noBtnText?: boolean
popoverPlacement?: Placement
disableLiTooltip?: boolean
accessTypeOverrides?: AssetWithAccessType[]
onHoverLi?: (asset: Asset, eventType: 'enter' | 'leave') => void
liSubtitle?: (asset: Asset) => string
onAccessTypeChanged?: (asset: AssetWithAccessType, accessType: AssetUsageAccessType) => void
} = $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}
<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}>
{formatAsset(asset)}
<svelte:fragment slot="text">
{formatAsset(asset)}
</svelte:fragment>
</Tooltip>
<span class="text-xs text-tertiary select-none">
{liSubtitle?.(asset)}
</span>
</div>
<div class="flex gap-2">
{#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-500" />
<svelte:fragment slot="text">Could not fetch 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}
{#if onAccessTypeChanged}
<Select
disablePortal
items={safeSelectItems(['r', 'w', 'rw'])}
bind:value={
() =>
accessTypeOverrides?.find((a) => assetEq(a, asset))?.access_type ??
asset.access_type,
(a) => {
a && onAccessTypeChanged?.(asset, a)
}
}
/>
{/if}
</div>
</li>
{/each}
</ul>
</svelte:fragment>
</Popover>
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />
<ResourceEditorDrawer bind:this={resourceEditorDrawer} />

View File

@@ -0,0 +1,32 @@
import type {
AssetKind as _AssetKind,
Asset as _Asset,
ListAssetsResponse,
AssetUsageAccessType
} from '$lib/gen'
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}`
}
}
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
}

View File

@@ -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>

View File

@@ -66,6 +66,7 @@ export type EditorBarUi = {
library?: boolean
useVsCode?: boolean
diffMode?: boolean
s3object?: boolean
}
export type EditableSchemaFormUi = {

View File

@@ -52,8 +52,11 @@
import { checkIfParentLoop } from '../utils'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.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'
import { assetEq } from '$lib/components/assets/lib'
const {
selectedId,
@@ -294,6 +297,17 @@
}, 100)
}
})
let assets = usePromise(async () =>
flowModule.value.type === 'rawscript'
? await inferAssets(flowModule.value.language, flowModule.value.content)
: undefined
)
$effect(() => {
if (flowModule.value.type !== 'rawscript') return
;[flowModule.value.content, flowModule.value.language]
untrack(() => assets.refresh())
})
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -400,6 +414,27 @@
{#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}
accessTypeOverrides={flowModule.value.asset_access_type_overrides}
onAccessTypeChanged={async (asset, access_type) => {
if (flowModule.value.type !== 'rawscript') return
flowModule.value.asset_access_type_overrides =
flowModule.value.asset_access_type_overrides?.filter(
(a) => !assetEq(a, asset)
)
flowModule.value.asset_access_type_overrides ??= []
await tick()
flowModule.value.asset_access_type_overrides.push({
...asset,
access_type
})
}}
/>
{/if}
</div>
<Editor
on:addSelectedLinesToAiChat={(e) => {
const { lines, startLine, endLine } = e.detail

View File

@@ -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'
export type FlowInput = Record<
string,
@@ -79,3 +83,13 @@ export type FlowEditorContext = {
insertButtonOpen: Writable<boolean>
executionCount: Writable<number>
}
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>
}>

View File

@@ -5,7 +5,8 @@ import {
type InputTransform,
type Job,
type RestartedFrom,
type OpenFlow
type OpenFlow,
type AssetUsageAccessType
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanExpr, emptySchema } from '$lib/utils'
@@ -195,3 +196,12 @@ export function checkIfParentLoop(
}
return undefined
}
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'

View File

@@ -1,5 +1,11 @@
<script lang="ts">
import { FlowService, type FlowModule } from '../../gen'
import {
AssetService,
FlowService,
ResourceService,
type AssetUsageKind,
type FlowModule
} from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, setContext, tick, untrack } from 'svelte'
@@ -17,6 +23,7 @@
import {
graphBuilder,
isTriggerStep,
type AssetN,
type InlineScript,
type InsertKind,
type NodeLayout,
@@ -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 { clone, 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,23 @@
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
import { deepEqual } from 'fast-equals'
import ViewportResizer from './ViewportResizer.svelte'
import AssetNode 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 {
assetDisplaysAsInputInFlowGraph,
assetDisplaysAsOutputInFlowGraph,
NODE_WITH_READ_ASSET_Y_OFFSET,
NODE_WITH_WRITE_ASSET_Y_OFFSET,
READ_ASSET_Y_OFFSET,
WRITE_ASSET_Y_OFFSET
} from '../flows/utils'
import { assetEq, formatAsset } from '../assets/lib'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
@@ -163,6 +187,66 @@
})
}
const flowGraphAssetsCtx: FlowGraphAssetContext = $state({
val: {
assetsMap: {},
selectedAsset: undefined,
dbManagerDrawer: undefined,
s3FilePicker: undefined,
resourceEditorDrawer: undefined,
resourceMetadataCache: {}
}
})
setContext<FlowGraphAssetContext>('FlowGraphAssetContext', flowGraphAssetsCtx)
const assetsMap = $derived(flowGraphAssetsCtx.val.assetsMap)
// 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
ResourceService.getResource({ path: asset.path, workspace: $workspaceStore! })
.then((r) => (resMetadataCache[asset.path] = { resource_type: r.resource_type }))
.catch((err) => (resMetadataCache[asset.path] = undefined))
}
})
// Fetch transitive assets (path scripts and flows)
const _currentlyFetchingAssets = new Set<string>()
$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
if (_currentlyFetchingAssets.has(mod.id)) continue
_currentlyFetchingAssets.add(mod.id)
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
_currentlyFetchingAssets.delete(modIds[idx])
})
})
}
})
// Prune assetsMap to only contain assets that are actually used
$effect(() => {
const allModules = new Set(getAllModules(modules).map((mod) => mod.id))
for (const asset in assetsMap) {
if (!allModules.has(asset)) delete assetsMap[asset]
}
})
function computeSimplifiableFlow(modules: FlowModule[], simplifiedFlow: boolean) {
const isSimplif = isSimplifiable(modules)
simplifiableFlow = isSimplif ? { simplifiedFlow } : undefined
@@ -181,10 +265,10 @@
)
}
let lastNodes: [NodeLayout[], Node[]] | undefined = undefined
let lastNodes: [NodeLayout[], Node[], typeof assetsMap] | undefined = undefined
function layoutNodes(nodes: NodeLayout[]): Node[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
if (lastResult && nodes === lastNodes?.[0] && deepEqual(assetsMap, lastNodes?.[2])) {
return lastResult
}
let seenId: string[] = []
@@ -219,12 +303,13 @@
const layout = sugiyama()
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
.coord(coordCenter())
.nodeSize((d) => {
return [
(nodeWidths[d?.data?.['id'] ?? ''] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
NODE.height + NODE.gap.vertical
] as readonly [number, number]
})
.nodeSize(
(d) =>
[
(nodeWidths[d?.data?.['id'] ?? ''] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
NODE.height + NODE.gap.vertical
] as readonly [number, number]
)
boxSize = layout(dag as any)
} catch {
const layout = sugiyama()
@@ -253,10 +338,123 @@
}
}))
lastNodes = [nodes, newNodes]
lastNodes = [nodes, newNodes, clone(assetsMap)]
return newNodes
}
let computeAssetNodesCache: [Node[], ReturnType<typeof computeAssetNodes>] | undefined
function computeAssetNodes(nodes: Node[], edges: Edge[]): [Node[], Edge[]] {
if (nodes === computeAssetNodesCache?.[0]) return computeAssetNodesCache[1]
const ASSET_X_GAP = 20
const ASSET_WIDTH = 180
const allAssetNodes: (Node & AssetN)[] = []
const allAssetEdges: Edge[] = []
const yPosMap: Record<number, { r?: true; w?: true }> = {}
for (const node of nodes) {
const assets = assetsMap?.[node.id]
let [inputAssetIdx, outputAssetIdx] = [-1, -1]
let [inputAssetCount, outputAssetCount] = [
assets?.filter(assetDisplaysAsInputInFlowGraph).length ?? 0,
assets?.filter(assetDisplaysAsOutputInFlowGraph).length ?? 0
]
if (inputAssetCount || outputAssetCount)
yPosMap[node.position.y] = yPosMap[node.position.y] ?? {}
if (inputAssetCount) yPosMap[node.position.y].r = true
if (outputAssetCount) yPosMap[node.position.y].w = true
const assetNodes: (Node & AssetN)[] | undefined = assets?.flatMap((asset) => {
const displayAsInput = assetDisplaysAsInputInFlowGraph(asset)
const displayAsOutput = assetDisplaysAsOutputInFlowGraph(asset)
if (displayAsInput) inputAssetIdx++
if (displayAsOutput) outputAssetIdx++
const base = { type: 'asset' as const, parentId: node.id, width: ASSET_WIDTH }
return [
...(displayAsInput
? [
{
...base,
data: { asset, displayedAs: 'input' as const },
id: `${node.id}-asset-in-${formatAsset(asset)}`,
position: {
x:
(ASSET_WIDTH + ASSET_X_GAP) * (inputAssetIdx - inputAssetCount / 2) +
(NODE.width + ASSET_X_GAP) / 2,
y: READ_ASSET_Y_OFFSET
}
}
]
: []),
...(displayAsOutput
? [
{
...base,
data: { asset, displayedAs: 'output' as const },
id: `${node.id}-asset-out-${formatAsset(asset)}`,
position: {
x:
(ASSET_WIDTH + ASSET_X_GAP) * (outputAssetIdx - outputAssetCount / 2) +
(NODE.width + ASSET_X_GAP) / 2,
y: WRITE_ASSET_Y_OFFSET
}
}
]
: [])
]
})
const assetEdges = assetNodes?.map((n) => {
const source = (n.data.displayedAs === 'output' ? n.parentId : n.id) ?? ''
const target = (n.data.displayedAs === 'output' ? n.id : n.parentId) ?? ''
return {
id: `${n.id}-edge`,
source,
target,
type: 'empty',
data: {
insertable: false,
sourceId: source,
targetId: target,
moving: moving,
eventHandlers: eventHandler,
index: 0,
enableTrigger: false,
disableAi: disableAi,
disableMoveIds: []
}
} satisfies Edge
})
allAssetEdges.push(...(assetEdges ?? []))
allAssetNodes.push(...(assetNodes ?? []))
}
// 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, ret]
return ret
}
let eventHandler = {
deleteBranch: (detail, label) => {
$selectedId = label
@@ -343,9 +541,7 @@
return
}
let newGraph = graph
nodes = layoutNodes(newGraph.nodes)
edges = newGraph.edges
;[nodes, edges] = computeAssetNodes(layoutNodes(newGraph.nodes), newGraph.edges)
await tick()
height = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100), minHeight)
}
@@ -364,7 +560,8 @@
branchOneEnd: BranchOneEndNode,
subflowBound: SubflowBound,
noBranch: NoBranchNode,
trigger: TriggersNode
trigger: TriggersNode,
asset: AssetNode
} as any
const edgeTypes = {
@@ -415,8 +612,11 @@
)
})
$effect(() => {
;(graph || allowSimplifiedPoll) && untrack(() => updateStores())
;[graph, allowSimplifiedPoll]
readFieldsRecursively(assetsMap)
untrack(() => updateStores())
})
let showDataflow = $derived(
$selectedId != undefined &&
!$selectedId.startsWith('constants') &&
@@ -537,6 +737,30 @@
{/if}
</div>
{#each getAllModules(modules) as mod (mod.id)}
{#if mod.value.type === 'rawscript'}
{@const v = mod.value}
<OnChange
key={[v.content, v.asset_access_type_overrides]}
runFirstEffect
onChange={() =>
inferAssets(v.language, v.content).then((assets) => {
for (const override of v.asset_access_type_overrides ?? []) {
assets = assets.map((asset) => {
if (assetEq(asset, override)) return { ...asset, access_type: override.access_type }
return asset
})
}
if (assetsMap && !deepEqual(assetsMap[mod.id], assets)) assetsMap[mod.id] = assets
})}
/>
{/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;

View File

@@ -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'
export type InsertKind =
| 'script'
@@ -90,6 +91,7 @@ export type FlowNode =
| SubflowBoundN
| NoBranchN
| TriggerN
| AssetN
export type InputN = {
type: 'input2'
@@ -256,6 +258,14 @@ export type TriggerN = {
}
}
export type AssetN = {
type: 'asset'
data: {
asset: AssetWithAccessType
displayedAs: 'input' | 'output'
}
}
// input2: InputNode,
// module: ModuleNode,
// branchAllStart: BranchAllStart,

View File

@@ -7,11 +7,18 @@
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 '$lib/components/flows/utils'
const { useDataflow } = getContext<{
useDataflow: Writable<boolean | undefined>
}>('FlowGraphContext')
const flowGraphAssetCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
let {
// id,
sourceX,
@@ -38,6 +45,10 @@
}
} = $props()
const shouldOffsetInsertButtonDueToAssetNode = flowGraphAssetCtx?.val.assetsMap?.[
data.sourceId
]?.some(assetDisplaysAsOutputInFlowGraph)
let [edgePath] = $derived(
getBezierPath({
sourceX,
@@ -57,7 +68,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}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import type { AssetN } from '../../graphBuilder.svelte'
import { AlertTriangle, Pyramid } from 'lucide-svelte'
import { assetEq, formatAsset } 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 { pluralize } from '$lib/utils'
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))
</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)}
>
<Pyramid size={16} class="shrink-0 ml-1" />
<span class="text-3xs truncate">
{formatAsset(data.asset)}
</span>
{#if isSelected}
{#if data.asset.kind === 'resource' && flowGraphAssetsCtx.val.resourceMetadataCache[data.asset.path] === undefined}
<Tooltip class="mr-2.5">
<AlertTriangle size={16} class="text-orange-500" />
<svelte:fragment slot="text">Could not fetch resource</svelte:fragment>
</Tooltip>
{/if}
{#if assetCanBeExplored(data.asset, flowGraphAssetsCtx.val.resourceMetadataCache[data.asset.path])}
<ExploreAssetButton
btnClasses="rounded-none"
asset={data.asset}
noText
buttonVariant="contained"
s3FilePicker={flowGraphAssetsCtx.val.s3FilePicker}
dbManagerDrawer={flowGraphAssetsCtx.val.dbManagerDrawer}
_resourceMetadata={flowGraphAssetsCtx.val.resourceMetadataCache[data.asset.path]}
/>
{/if}
{/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)
}}
>
{formatAsset(data.asset)}
</a>
</svelte:fragment>
</Tooltip>
{/snippet}
</NodeWrapper>

View File

@@ -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)

View File

@@ -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([

View File

@@ -16,7 +16,8 @@ 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 initGoParser, { parse_go } from 'windmill-parser-wasm-go'
@@ -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,30 @@ 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)) as AssetWithAccessType[]).map((a) => ({
...a,
accessType: 'rw'
}))
}
return []
}
const SQL_LANGUAGES = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'oracledb',
'duckdb'
]
export async function inferArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
@@ -97,12 +123,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 +182,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))

View File

@@ -1,5 +1,6 @@
// https://github.com/sveltejs/svelte/issues/14600
import { untrack } from 'svelte'
import type { StateStore } from './utils'
export function withProps<Component, Props>(component: Component, props: Props) {
@@ -60,3 +61,34 @@ export function usePromise<T>(
return ret
}
export type UsePaginatedResult<T> = {
items: T[]
status: 'loading' | 'error' | 'ok'
currentPage: number
loadMore: () => void
}
export function usePaginated<T>(query: (page: number) => Promise<{ items: T[] }>) {
let s: UsePaginatedResult<T> = $state({
items: [],
status: 'loading',
currentPage: 1,
loadMore: () => {
s.currentPage++
s.status = 'loading'
promise.refresh()
}
})
const promise = usePromise(() => query(s.currentPage))
$effect(() => {
if (promise.status === 'ok') {
untrack(() => {
s.status = promise.status
s.items = [...s.items, ...promise.value.items]
})
}
})
return s
}

View File

@@ -1433,4 +1433,46 @@ 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
}
export function transpose<T>(matrix: T[][]): T[][] {
const maxW = Math.max(...matrix.map((row) => row.length))
const maxH = matrix.length
const transposed: T[][] = Array.from({ length: maxW }, () => Array(maxH).fill(undefined))
for (let i = 0; i < maxH; i++) {
for (let j = 0; j < matrix[i].length; j++) {
transposed[j][i] = matrix[i][j]
}
}
return transposed
}

View File

@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Assets' }
}
}

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { formatAsset, getAssetUsagePageUri } from '$lib/components/assets/lib'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { ClearableInput, DrawerContent } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
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, ResourceService, type ListAssetsResponse } from '$lib/gen'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { pluralize, truncate } from '$lib/utils'
import { untrack } from 'svelte'
import ExploreAssetButton from './ExploreAssetButton.svelte'
let assets = usePromise(() => AssetService.listAssets({ workspace: $workspaceStore ?? '' }))
let filterText: string = $state('')
let filteredAssets = $derived(
assets.value?.filter((asset) =>
formatAsset(asset).toLowerCase().includes(filterText.toLowerCase())
) ?? []
)
let resourceTypesCache: Record<string, string | undefined> = $state({})
$effect(() => {
if (!assets.value) return
untrack(() => {
for (const asset of assets.value) {
if (asset.kind !== 'resource' || asset.path in resourceTypesCache) continue
ResourceService.getResource({ path: asset.path, workspace: $workspaceStore! })
.then((resource) => (resourceTypesCache[asset.path] = resource.resource_type))
.catch((err) => (resourceTypesCache[asset.path] = undefined))
}
})
})
let viewOccurences: ListAssetsResponse[number] | undefined = $state()
let s3FilePicker: S3FilePicker | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
</script>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Assets"
tooltip="Assets show up here whenever you use them in Windmill."
documentationLink="https://www.windmill.dev/docs/core_concepts/assets"
/>
<ClearableInput bind:value={filterText} placeholder="Search assets" class="mb-4" />
<DataTable>
<Head>
<tr>
<Cell head first>Asset name</Cell>
<Cell head></Cell>
<Cell head></Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each filteredAssets as asset}
{@const assetUri = formatAsset(asset)}
<tr class="h-14">
<Cell first>{truncate(assetUri, 92)}</Cell>
<Cell>
<a href={`#${assetUri}`} onclick={() => (viewOccurences = asset)}>
{pluralize(asset.usages.length, 'occurrence')}
</a>
</Cell>
<Cell>
<ExploreAssetButton
{asset}
{s3FilePicker}
{dbManagerDrawer}
_resourceMetadata={{ resource_type: resourceTypesCache[asset.path] }}
class="w-24"
/>
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</CenteredPage>
{/if}
<Drawer
open={viewOccurences !== undefined}
size="900px"
on:close={() => (viewOccurences = undefined)}
>
<DrawerContent title="Asset occurrences" on:close={() => (viewOccurences = undefined)}>
<ul class="flex flex-col border rounded-md divide-y">
{#each viewOccurences?.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">
<div class="text-xs border text-tertiary max-w-fit p-1 rounded-md">Read</div>
<div class="text-xs border text-tertiary max-w-fit p-1 rounded-md">Write</div>
<div class="text-xs border text-tertiary max-w-fit p-1 rounded-md">Todo</div>
</div>
</a>
</li>
{/each}
</ul>
</DrawerContent>
</Drawer>
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
<DbManagerDrawer bind:this={dbManagerDrawer} />

View File

@@ -0,0 +1,67 @@
<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 { 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
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?.()
}}
c
>
{#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>

View File

@@ -60,8 +60,9 @@
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 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 +415,8 @@
})
}
})
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
</script>
<ConfirmationModal
@@ -901,10 +904,11 @@
</Cell>
<Cell class="flex justify-end">
{#if path && isDbType(resource_type)}
<DbManagerDrawerButton
resourcePath={path}
resourceType={resource_type}
class="mr-8"
<ExploreAssetButton
asset={{ kind: 'resource', path: path }}
{dbManagerDrawer}
_resourceMetadata={{ resource_type }}
class="w-24"
/>
{/if}
<Dropdown
@@ -1083,6 +1087,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}

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' }
}
}
}

View File

@@ -100,7 +100,7 @@ components:
type: string
required:
- expr
FlowModule:
type: object
properties:
@@ -270,6 +270,28 @@ components:
type: string
is_trigger:
type: boolean
asset_access_type_overrides:
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:
- type
- content