Compare commits
44 Commits
wmill-scri
...
di/assets-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97dec88e26 | ||
|
|
50bd32496d | ||
|
|
696223a51e | ||
|
|
e154431f5f | ||
|
|
9283bfae30 | ||
|
|
f18b051ce0 | ||
|
|
b91801c4ab | ||
|
|
977ee7f199 | ||
|
|
dc86baada6 | ||
|
|
4d57605193 | ||
|
|
5221dd6cf3 | ||
|
|
42e2eadaf3 | ||
|
|
b9ad78eed5 | ||
|
|
e1bfa5fbef | ||
|
|
ad88f4c7ef | ||
|
|
80526e3169 | ||
|
|
7f00989030 | ||
|
|
4ca275a26c | ||
|
|
2b9a397236 | ||
|
|
c44e49ddbb | ||
|
|
8f2b883d55 | ||
|
|
d3846c5858 | ||
|
|
c6c908c3c8 | ||
|
|
a05b8bc56c | ||
|
|
f57c5141db | ||
|
|
29610cab09 | ||
|
|
2f13eb4eb8 | ||
|
|
b9cd037a9b | ||
|
|
031b7d48ca | ||
|
|
89defe354a | ||
|
|
ee3df14473 | ||
|
|
c57f96a79e | ||
|
|
f816436614 | ||
|
|
4f5d41fab6 | ||
|
|
209bef7d9f | ||
|
|
d0b03533b9 | ||
|
|
4e2a1b8ac9 | ||
|
|
677b196295 | ||
|
|
3e481b9eb1 | ||
|
|
7ed21aba73 | ||
|
|
761891f0c1 | ||
|
|
7f9a4dee60 | ||
|
|
3241924c18 | ||
|
|
310c243035 |
1
backend/Cargo.lock
generated
1
backend/Cargo.lock
generated
@@ -15108,6 +15108,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"windmill-parser",
|
||||
]
|
||||
|
||||
4
backend/migrations/20250625100238_assets.down.sql
Normal file
4
backend/migrations/20250625100238_assets.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
DROP TABLE asset;
|
||||
DROP TYPE ASSET_USAGE_KIND;
|
||||
DROP TYPE ASSET_USAGE_ACCESS_TYPE;
|
||||
DROP TYPE ASSET_KIND;
|
||||
12
backend/migrations/20250625100238_assets.up.sql
Normal file
12
backend/migrations/20250625100238_assets.up.sql
Normal 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
|
||||
);
|
||||
@@ -19,3 +19,4 @@ windmill-parser.workspace = true
|
||||
anyhow.workspace = true
|
||||
lazy_static.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
82
backend/windmill-api/src/assets.rs
Normal file
82
backend/windmill-api/src/assets.rs
Normal 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))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
37
backend/windmill-common/src/assets.rs
Normal file
37
backend/windmill-common/src/assets.rs
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
186
frontend/src/lib/components/assets/AssetsDropdownButton.svelte
Normal file
186
frontend/src/lib/components/assets/AssetsDropdownButton.svelte
Normal 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} />
|
||||
32
frontend/src/lib/components/assets/lib.ts
Normal file
32
frontend/src/lib/components/assets/lib.ts
Normal 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
|
||||
}
|
||||
28
frontend/src/lib/components/common/OnChange.svelte
Normal file
28
frontend/src/lib/components/common/OnChange.svelte
Normal 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>
|
||||
@@ -66,6 +66,7 @@ export type EditorBarUi = {
|
||||
library?: boolean
|
||||
useVsCode?: boolean
|
||||
diffMode?: boolean
|
||||
s3object?: boolean
|
||||
}
|
||||
|
||||
export type EditableSchemaFormUi = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
5
frontend/src/routes/(root)/(logged)/assets/+page.js
Normal file
5
frontend/src/routes/(root)/(logged)/assets/+page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Assets' }
|
||||
}
|
||||
}
|
||||
124
frontend/src/routes/(root)/(logged)/assets/+page.svelte
Normal file
124
frontend/src/routes/(root)/(logged)/assets/+page.svelte
Normal 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} />
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user