feat: add support for full fleged apps (react, svelte, vue) (#1536)

* add react support

* use whoami

* update

* update

* update

* create react app

* all

* sqlx
This commit is contained in:
Ruben Fiszel
2023-05-08 19:10:45 +02:00
committed by GitHub
parent 686f5bbe18
commit 13242abff1
32 changed files with 1138 additions and 89 deletions
+1
View File
@@ -5184,6 +5184,7 @@ dependencies = [
"async_zip",
"axum",
"base64 0.21.0",
"bytes",
"chrono",
"chrono-tz",
"cookie",
+1 -1
View File
@@ -162,5 +162,5 @@ async_zip = { version = "0.0.11", features = ["full"] }
once_cell = "1.17.1"
rsmq_async = { version = "5.1.5" }
gosyn = "0.2.2"
bytes = "1.4.0"
[patch.crates-io]
@@ -0,0 +1 @@
-- Add down migration script here
@@ -0,0 +1,33 @@
-- Add up migration script here
CREATE TABLE raw_app (
path varchar(255) PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 0,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
summary VARCHAR(1000) NOT NULL DEFAULT '',
edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
data TEXT NOT NULL,
extra_perms JSONB NOT NULL DEFAULT '{}'
);
CREATE POLICY see_own ON raw_app FOR ALL
USING (SPLIT_PART(raw_app.path, '/', 1) = 'u' AND SPLIT_PART(raw_app.path, '/', 2) = current_setting('session.user'));
CREATE POLICY see_member ON raw_app FOR ALL
USING (SPLIT_PART(raw_app.path, '/', 1) = 'g' AND SPLIT_PART(raw_app.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
CREATE POLICY see_extra_perms_user ON raw_app FOR ALL
USING (extra_perms ? CONCAT('u/', current_setting('session.user')))
WITH CHECK ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_groups ON raw_app FOR ALL
USING (extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
WITH CHECK (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
CREATE POLICY see_folder_extra_perms_user ON raw_app FOR ALL
USING (SPLIT_PART(raw_app.path, '/', 1) = 'f' AND SPLIT_PART(raw_app.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]))
WITH CHECK (SPLIT_PART(raw_app.path, '/', 1) = 'f' AND SPLIT_PART(raw_app.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
ALTER TYPE FAVORITE_KIND ADD VALUE 'raw_app';
+74 -2
View File
@@ -1101,7 +1101,8 @@
"Enum": [
"app",
"script",
"flow"
"flow",
"raw_app"
]
},
"name": "favorite_kind"
@@ -2308,6 +2309,27 @@
},
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)"
},
"5a0333e29280c0814d62473b5e5e7b187c4d8cbf2caeb645bfef82f6880467a7": {
"describe": {
"columns": [
{
"name": "data",
"ordinal": 0,
"type_info": "Text"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT data FROM raw_app\n WHERE path = $1 AND workspace_id = $2"
},
"5b9b58612ca0f703a5d154a76fab82ac2329aef965fa937bfab2810b6e1336a4": {
"describe": {
"columns": [],
@@ -2489,7 +2511,8 @@
"Enum": [
"app",
"script",
"flow"
"flow",
"raw_app"
]
},
"name": "favorite_kind"
@@ -3212,6 +3235,21 @@
},
"query": "UPDATE group_ SET summary = $1 WHERE name = $2 AND workspace_id = $3"
},
"84bbd11f7bb0c65dcfb2e12038b8a9cb51d303480ac3c5f8a5e833a53c40ed9b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Text"
]
}
},
"query": "INSERT INTO raw_app\n (workspace_id, path, summary, extra_perms, data)\n VALUES ($1, $2, $3, '{}', $4)"
},
"8543f029d9784234e4c6a6dcd7b03e62d544b98be261334ee210594e0bb839f2": {
"describe": {
"columns": [
@@ -4722,6 +4760,19 @@
},
"query": "UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3"
},
"b2b2b7251be2b80207f47b10eeff78d61c84161caaf16b8fd25d82b97aac1186": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "DELETE FROM raw_app WHERE path = $1 AND workspace_id = $2"
},
"b3b80de52d0931a2fdb5d38b7603a2d69cc25ab1cda413228c363a5ffd777113": {
"describe": {
"columns": [
@@ -5232,6 +5283,27 @@
},
"query": "\n UPDATE queue\n SET flow_status = JSONB_SET(\n JSONB_SET(flow_status, ARRAY['failure_module'], $1),\n ARRAY['step'], $2)\n WHERE id = $3\n "
},
"c4b3ab3d87f158b20f0557f78c6307c50620425aeb43636e19931c142b5ce0d7": {
"describe": {
"columns": [
{
"name": "exists",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
null
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT EXISTS(SELECT 1 FROM raw_app WHERE path = $1 AND workspace_id = $2)"
},
"c4f1c14c3aae145b52ff39bdecd779cd3eba27a869fda68516dc68dc2abefd38": {
"describe": {
"columns": [],
+1
View File
@@ -72,3 +72,4 @@ prometheus.workspace = true
async_zip.workspace = true
rsmq_async.workspace = true
regex.workspace = true
bytes.workspace = true
+207 -17
View File
@@ -2888,6 +2888,80 @@ paths:
items:
$ref: "#/components/schemas/Input"
/w/{workspace}/raw_apps/list:
get:
summary: list all available raw apps
operationId: listRawApps
tags:
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- $ref: "#/components/parameters/OrderDesc"
- $ref: "#/components/parameters/CreatedBy"
- name: path_start
description: mask to filter matching starting path
in: query
schema:
type: string
- name: path_exact
description: mask to filter exact matching path
in: query
schema:
type: string
- name: starred_only
description: |
(default false)
show only the starred items
in: query
schema:
type: boolean
responses:
"200":
description: All available raw apps
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ListableRawApp"
/w/{workspace}/raw_apps/exists/{path}:
get:
summary: does an app exisst at path
operationId: existsRawApp
tags:
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: app exists
content:
application/json:
schema:
type: boolean
/w/{workspace}/apps/get_data/{version}/{path}:
get:
summary: get app by path
operationId: getRawAppData
tags:
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/VersionId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: app details
content:
text/javascript:
schema:
type: string
/w/{workspace}/apps/list:
get:
summary: list all available apps
@@ -2927,6 +3001,44 @@ paths:
items:
$ref: "#/components/schemas/ListableApp"
/w/{workspace}/apps/create:
post:
summary: create app
operationId: createApp
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: new app
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
value: {}
summary:
type: string
policy:
$ref: "#/components/schemas/Policy"
draft_only:
type: boolean
required:
- path
- value
- summary
- policy
responses:
"201":
description: app created
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/exists/{path}:
get:
summary: does an app exisst at path
@@ -3030,16 +3142,16 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps/create:
/w/{workspace}/raw_apps/create:
post:
summary: create app
operationId: createApp
summary: create raw app
operationId: createRawApp
tags:
- app
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: new app
description: new raw app
required: true
content:
application/json:
@@ -3048,21 +3160,65 @@ paths:
properties:
path:
type: string
value: {}
value:
type: string
summary:
type: string
policy:
$ref: "#/components/schemas/Policy"
draft_only:
type: boolean
required:
- path
- value
- summary
- policy
responses:
"201":
description: app created
description: raw app created
content:
text/plain:
schema:
type: string
/w/{workspace}/raw_apps/update/{path}:
post:
summary: update app
operationId: updateRawApp
tags:
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
requestBody:
description: updateraw app
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
summary:
type: string
value:
type: string
responses:
"200":
description: app updated
content:
text/plain:
schema:
type: string
/w/{workspace}/raw_apps/delete/{path}:
delete:
summary: delete raw app
operationId: deleteRawApp
tags:
- raw_app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: app deleted
content:
text/plain:
schema:
@@ -4499,7 +4655,7 @@ paths:
schema:
type: string
enum:
[script, group_, resource, schedule, variable, flow, folder, app]
[script, group_, resource, schedule, variable, flow, folder, app, raw_app]
responses:
"200":
description: acls
@@ -4525,7 +4681,7 @@ paths:
schema:
type: string
enum:
[script, group_, resource, schedule, variable, flow, folder, app]
[script, group_, resource, schedule, variable, flow, folder, app, raw_app]
requestBody:
description: acl to add
required: true
@@ -4562,7 +4718,7 @@ paths:
schema:
type: string
enum:
[script, group_, resource, schedule, variable, flow, folder, app]
[script, group_, resource, schedule, variable, flow, folder, app, raw_app]
requestBody:
description: acl to add
required: true
@@ -4642,7 +4798,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script]
enum: [flow, app, script, raw_app]
responses:
"200":
description: star item
@@ -4665,7 +4821,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script]
enum: [flow, app, script, raw_app]
responses:
"200":
description: unstar item
@@ -4799,6 +4955,12 @@ components:
required: true
schema:
type: string
VersionId:
name: version
in: path
required: true
schema:
type: number
Token:
name: token
in: path
@@ -6269,6 +6431,34 @@ components:
- edited_at
- execution_mode
ListableRawApp:
type: object
properties:
workspace_id:
type: string
path:
type: string
summary:
type: string
extra_perms:
type: object
additionalProperties:
type: boolean
starred:
type: boolean
version:
type: number
edited_at:
type: string
format: date-time
required:
- workspace_id
- path
- summary
- extra_perms
- version
- edited_at
AppWithLastVersion:
type: object
properties:
+32
View File
@@ -384,6 +384,22 @@ async fn create_app(
return Err(Error::BadRequest("App path cannot be empty".to_string()));
}
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM raw_app WHERE path = $1 AND workspace_id = $2)",
&app.path,
w_id
)
.fetch_one(&mut tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
&app.path
)));
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
@@ -536,6 +552,22 @@ async fn update_app(
if let Some(npath) = &ns.path {
if npath != path {
require_owner_of_path(&authed, path)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM raw_app WHERE path = $1 AND workspace_id = $2)",
npath,
w_id
)
.fetch_one(&mut tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
npath
)));
}
}
sqlb.set_str("path", npath);
}
+1
View File
@@ -29,6 +29,7 @@ pub enum FavoriteKind {
Script,
Flow,
App,
Raw_App,
}
#[derive(Deserialize)]
pub struct Favorite {
-1
View File
@@ -1270,7 +1270,6 @@ pub async fn run_flow_by_path(
let mut tx: QueueTransaction<'_, _> = (rsmq, user_db.begin(&authed).await?).into();
let scheduled_for = run_query.get_scheduled_for(tx.transaction_mut()).await?;
let args = run_query.add_include_headers(headers, args.unwrap_or_default());
let (uuid, tx) = push(
tx,
&w_id,
+2
View File
@@ -42,6 +42,7 @@ mod groups;
mod inputs;
pub mod jobs;
mod oauth2;
mod raw_apps;
mod resources;
mod schedule;
mod scripts;
@@ -123,6 +124,7 @@ pub async fn run_server(
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/raw_apps", raw_apps::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
+333
View File
@@ -0,0 +1,333 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
body,
extract::{Extension, Json, Path, Query},
response::Response,
routing::{delete, get, post},
Router,
};
use bytes::Bytes;
use hyper::{header, StatusCode};
use serde::{Deserialize, Serialize};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::FromRow;
use std::str;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
apps::ListAppQuery,
error::{Error, JsonResult, Result},
utils::{not_found_if_none, paginate, Pagination, StripPath},
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_apps))
.route("/get_data/:version/*path", get(get_data))
.route("/exists/*path", get(exists_app))
.route("/update/*path", post(update_app))
.route("/delete/*path", delete(delete_app))
.route("/create", post(create_app))
}
#[derive(FromRow, Deserialize, Serialize)]
pub struct ListableApp {
pub path: String,
pub workspace_id: String,
pub summary: String,
pub edited_at: chrono::DateTime<chrono::Utc>,
pub extra_perms: serde_json::Value,
pub starred: bool,
pub version: i32,
}
#[derive(Deserialize)]
pub struct CreateApp {
pub path: String,
pub summary: String,
pub value: String,
}
#[derive(Deserialize)]
pub struct EditApp {
pub path: Option<String>,
pub summary: Option<String>,
pub value: Option<String>,
}
async fn list_apps(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
) -> JsonResult<Vec<ListableApp>> {
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("raw_app as app")
.fields(&[
"app.workspace_id",
"app.path",
"app.edited_at",
"app.summary",
"app.extra_perms",
"app.version",
"favorite.path IS NOT NULL as starred",
])
.left()
.join("favorite")
.on(
"favorite.favorite_kind = 'raw_app' AND favorite.workspace_id = app.workspace_id AND favorite.path = app.path AND favorite.usr = ?"
.bind(&authed.username),
)
.order_desc("favorite.path IS NOT NULL")
.order_by("app.edited_at", true)
.and_where("app.workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
if lq.starred_only.unwrap_or(false) {
sqlb.and_where_is_not_null("favorite.path");
}
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableApp>(&sql)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn get_data(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, _version, path)): Path<(String, u16, StripPath)>,
) -> Result<Response> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_scalar!(
"SELECT data FROM raw_app
WHERE path = $1 AND workspace_id = $2",
path.to_owned(),
&w_id
)
.fetch_optional(&mut tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
let res = Response::builder().header(header::CONTENT_TYPE, "text/javascript");
Ok(res
.body(body::boxed(body::Full::from(Bytes::from(app))))
.unwrap())
}
async fn create_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
let mut tx = user_db.begin(&authed).await?;
if &app.path == "" {
return Err(Error::BadRequest("App path cannot be empty".to_string()));
}
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
app.path,
w_id
)
.fetch_one(&mut tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
&app.path
)));
}
sqlx::query!(
"INSERT INTO raw_app
(workspace_id, path, summary, extra_perms, data)
VALUES ($1, $2, $3, '{}', $4)",
w_id,
app.path,
app.summary,
app.value,
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"apps.create",
ActionKind::Create,
&w_id,
Some(&app.path),
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateApp { workspace: w_id, path: app.path.clone() },
);
Ok((StatusCode::CREATED, app.path))
}
async fn delete_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"DELETE FROM raw_app WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"apps.delete",
ActionKind::Delete,
&w_id,
Some(path),
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::DeleteApp { workspace: w_id, path: path.to_owned() },
);
Ok(format!("app {} deleted", path))
}
async fn update_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(app): Json<EditApp>,
) -> Result<String> {
use sql_builder::prelude::*;
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
let npath = &app.path;
let npath = if npath.is_some() || app.summary.is_some() {
let mut sqlb = SqlBuilder::update_table("raw_app");
sqlb.and_where_eq("path", "?".bind(&path));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
if let Some(npath) = &app.path {
if npath != path {
require_owner_of_path(&authed, path)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
npath,
w_id
)
.fetch_one(&mut tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
npath
)));
}
}
sqlb.set_str("path", npath);
}
if let Some(nsummary) = &app.summary {
sqlb.set_str("summary", nsummary);
}
if let Some(value) = &app.value {
sqlb.set_str("data", value);
sqlb.set("version", "version + 1");
}
sqlb.returning("path");
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut tx).await?;
not_found_if_none(npath_o, "Raw App", path)?
} else {
"".to_string()
};
audit_log(
&mut tx,
&authed.username,
"apps.update",
ActionKind::Update,
&w_id,
Some(&npath),
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateApp {
workspace: w_id,
old_path: path.to_owned(),
new_path: npath.clone(),
},
);
Ok(format!("app {} updated (npath: {:?})", path, npath))
}
async fn exists_app(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<bool> {
let path = path.to_path();
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM raw_app WHERE path = $1 AND workspace_id = $2)",
path,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
+1 -1
View File
@@ -630,7 +630,7 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
if job.id == Uuid::nil() {
tracing::info!(worker = %worker_name, "running warmup job");
} else {
tracing::info!(worker = %worker_name, id = %job.id, root_id = %job_root, "fetched job {}, root job: {}", job.id, job_root);
tracing::info!(worker = %worker_name, workspace_id = %job.workspace_id, id = %job.id, root_id = %job_root, "fetched job {}, root job: {}", job.id, job_root);
}
let job_dir = format!("{worker_dir}/{}", job.id);
+2 -2
View File
@@ -20,7 +20,7 @@ The default proxy is setup to use the remote backend: <https://app.windmill.dev>
You can configure another proxy to use like so:
```bash
REMOTE=http://localhost:8000 REMOTE_LSP=http://localhost:3000 npm run dev
REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev
```
## Use a Local backend
@@ -80,7 +80,7 @@ DATABASE_URL=postgres://postgres:changeme@127.0.0.1:5433/windmill?sslmode=disabl
In the frontend folder:
```bash
REMOTE=http://localhost:8000 REMOTE_LSP=http://localhost:3000 npm run dev
REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev
```
## Building
+12 -2
View File
@@ -5,11 +5,11 @@
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Path from './Path.svelte'
import { AppService, FlowService, ScriptService } from '$lib/gen'
import { AppService, FlowService, RawAppService, ScriptService } from '$lib/gen'
const dispatch = createEventDispatcher()
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app' | 'raw_app'
let kind: Kind
let initialPath: string = ''
@@ -25,6 +25,7 @@
kind_l: Kind
) {
kind = kind_l
path = undefined
initialPath = initialPath_l
summary = summary_l
loadOwner()
@@ -77,6 +78,15 @@
summary
}
})
} else if (kind == 'raw_app') {
await RawAppService.updateRawApp({
workspace: $workspaceStore!,
path: initialPath,
requestBody: {
path: path != initialPath ? path : undefined,
summary
}
})
}
dispatch('update', path)
drawer.closeDrawer()
+1 -1
View File
@@ -28,7 +28,7 @@
import Required from './Required.svelte'
import Tooltip from './Tooltip.svelte'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app' | 'raw_app'
let meta: Meta | undefined = undefined
export let namePlaceholder = ''
export let initialPath: string
@@ -13,7 +13,15 @@
const dispatch = createEventDispatcher()
type Kind = 'script' | 'group_' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
type Kind =
| 'script'
| 'group_'
| 'resource'
| 'schedule'
| 'variable'
| 'flow'
| 'app'
| 'raw_app'
let kind: Kind
let path: string = ''
+1 -1
View File
@@ -6,7 +6,7 @@
import { Star, StarOff } from 'lucide-svelte'
export let path: string
export let kind: 'flow' | 'app' | 'script'
export let kind: 'flow' | 'app' | 'script' | 'raw_app'
export let starred = false
export let workspace_id: string
+6 -7
View File
@@ -1,8 +1,7 @@
import type { Policy } from "$lib/gen";
import { writable } from "svelte/store";
import type {
App,
import type { Policy } from '$lib/gen'
import { writable } from 'svelte/store'
import type { App } from './types'
} from './types'
export const importStore = writable<{ summary: string, value: App, policy: Policy } | undefined>(undefined)
export const importStore = writable<{ summary: string; value: App; policy: Policy } | undefined>(
undefined
)
@@ -0,0 +1,143 @@
<script lang="ts">
import Dropdown from '$lib/components/Dropdown.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { RawAppService, type ListableRawApp } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import {
faEdit,
faEye,
faFileExport,
faShare,
faTrashAlt
} from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import Drawer from '../drawer/Drawer.svelte'
import DrawerContent from '../drawer/DrawerContent.svelte'
import FileInput from '../fileInput/FileInput.svelte'
import { goto } from '$app/navigation'
export let app: ListableRawApp & { canWrite: boolean }
export let marked: string | undefined
export let starred: boolean
export let shareModal: ShareModal
export let moveDrawer: MoveDrawer
export let deleteConfirmedCallback: (() => void) | undefined
let updateAppDrawer: Drawer
let { summary, version, path, extra_perms, workspace_id, canWrite } = app
const dispatch = createEventDispatcher()
</script>
<Drawer bind:this={updateAppDrawer} size="800px">
<DrawerContent title="Update app" on:close={() => updateAppDrawer?.toggleDrawer?.()}>
<FileInput
accept={'.js'}
multiple={false}
convertTo={'text'}
iconSize={24}
class="text-sm py-4"
on:change={async ({ detail }) => {
await RawAppService.updateRawApp({
workspace: $workspaceStore ?? '',
path,
requestBody: { value: detail?.[0] }
})
goto(`/apps/get_raw/${version + 1}/${path}`)
}}
/>
</DrawerContent>
</Drawer>
<Row
href="/apps/get_raw/{version}/{path}"
kind="raw_app"
{marked}
{path}
{summary}
workspaceId={workspace_id ?? $workspaceStore ?? ''}
{starred}
on:change
canFavorite={true}
>
<svelte:fragment slot="badges">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</svelte:fragment>
<svelte:fragment slot="actions">
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator}
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
on:click={() => updateAppDrawer?.toggleDrawer?.()}
>
Edit
</Button>
</div>
{/if}
{/if}
<Button
href="/apps/get_raw/{version}/{path}"
color="dark"
size="xs"
spacingSize="md"
startIcon={{ icon: faEye }}
>
View
</Button>
</span>
<Dropdown
placement="bottom-end"
dropdownItems={() => {
return [
{
displayName: 'View',
icon: faEye,
href: `/apps/get/${path}`
},
{
displayName: 'Move/Rename',
icon: faFileExport,
action: () => {
moveDrawer.openDrawer(path, summary, 'raw_app')
},
disabled: !canWrite
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: faShare,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'raw_app')
}
},
{
displayName: 'Delete',
icon: faTrashAlt,
action: async (event) => {
if (event?.shiftKey) {
await RawAppService.deleteRawApp({ workspace: $workspaceStore ?? '', path })
dispatch('change')
} else {
deleteConfirmedCallback = async () => {
await RawAppService.deleteRawApp({ workspace: $workspaceStore ?? '', path })
dispatch('change')
}
}
},
type: 'delete',
disabled: !canWrite
}
]
}}
/>
</svelte:fragment>
</Row>
@@ -9,7 +9,7 @@
const dispatch = createEventDispatcher()
export let kind: 'script' | 'flow' | 'app' = 'script'
export let kind: 'script' | 'flow' | 'app' | 'raw_app' = 'script'
export let summary: string | undefined = undefined
export let path: string
export let href: string
@@ -4,19 +4,21 @@
import { Code2, LayoutDashboard } from 'lucide-svelte'
import Icon from 'svelte-awesome'
export let kind: 'script' | 'flow' | 'app'
export let kind: 'script' | 'flow' | 'app' | 'raw_app'
export let href: string = '#'
const color = {
script: 'bg-blue-50 border-blue-200',
flow: 'bg-[#f0fdfa] border-[#99f6e4]',
app: 'bg-[#fff7ed] border-orange-300'
app: 'bg-[#fff7ed] border-orange-300',
raw_app: 'bg-[#fff7ed] border-orange-300'
}[kind]
const iconColor = {
script: '#60A5FA',
flow: '#14b8a6',
app: '#fb923c'
app: '#fb923c',
raw_app: '#fb923c'
}[kind]
</script>
@@ -25,7 +27,7 @@
<span class="ml-1 mb-0.5 -mt-0.5">
<Icon data={faBarsStaggered} scale={1.1} class="mr-0.5 text-[#14b8a6]" />
</span>
{:else if kind === 'app'}
{:else if kind === 'app' || kind === 'raw_app'}
<LayoutDashboard size={24} color={iconColor} />
{:else if kind === 'script'}
<Code2 size={24} color={iconColor} />
@@ -2,21 +2,42 @@
import { goto } from '$app/navigation'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { Button } from '$lib/components/common'
import { Button, FileInput } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { LayoutDashboard } from 'lucide-svelte'
import { importStore } from '../apps/store'
import { RawAppService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Path from '../Path.svelte'
let drawer: Drawer | undefined = undefined
let pendingJson: string
let rawAppDrawer: Drawer | undefined = undefined
let pendingJson: string = ''
let pendingCode: string = ''
let summary: string = ''
let path: string = ''
let pathError: string = ''
async function importJson() {
$importStore = JSON.parse(pendingJson)
await goto('/apps/add?nodraft=true')
drawer?.closeDrawer?.()
}
async function importRawApp() {
await RawAppService.createRawApp({
workspace: $workspaceStore!,
requestBody: {
path,
summary,
value: pendingCode
}
})
await goto(`/apps/get_raw/0/${path}`)
rawAppDrawer?.closeDrawer?.()
}
</script>
<!-- Buttons -->
@@ -28,8 +49,12 @@
href="/apps/add?nodraft=true"
dropdownItems={[
{
label: 'Import from raw JSON',
label: 'Import low-code app from JSON',
onClick: () => drawer?.toggleDrawer?.()
},
{
label: 'Import app in React/Vue/Svelte',
onClick: () => rawAppDrawer?.toggleDrawer?.()
}
]}
>
@@ -41,10 +66,43 @@
<!-- Raw JSON -->
<Drawer bind:this={drawer} size="800px">
<DrawerContent title="Import app from JSON" on:close={() => drawer?.toggleDrawer?.()}>
<DrawerContent title="Import low-code app from JSON" on:close={() => drawer?.toggleDrawer?.()}>
<SimpleEditor bind:code={pendingJson} lang="json" class="h-full" fixedOverflowWidgets={false} />
<svelte:fragment slot="actions">
<Button size="sm" on:click={importJson}>Import</Button>
</svelte:fragment>
</DrawerContent>
</Drawer>
<!-- Raw JSON -->
<Drawer bind:this={rawAppDrawer} size="800px">
<DrawerContent
title="Import app in React/Vue/Svelte"
on:close={() => rawAppDrawer?.toggleDrawer?.()}
>
<Path bind:error={pathError} bind:path initialPath="" namePlaceholder={'app'} kind="resource" />
<h2 class="border-b pb-1 mt-10 mb-4">Summary</h2>
<input
type="text"
bind:value={summary}
placeholder="Short summary to be displayed when listed"
/>
<h2 class="border-b pb-1 mt-10 mb-4">IIFE JS code</h2>
<FileInput
accept={'.js'}
multiple={false}
convertTo={'text'}
iconSize={24}
class="text-sm py-4"
on:change={({ detail }) => {
pendingCode = detail?.[0]
}}
/>
<svelte:fragment slot="actions">
<Button disabled={pathError != ''} size="sm" on:click={importRawApp}>Import</Button>
</svelte:fragment>
</DrawerContent>
</Drawer>
@@ -28,7 +28,7 @@
href="/flows/add?nodraft=true"
dropdownItems={[
{
label: 'Import from raw JSON',
label: 'Import from JSON',
onClick: () => drawer?.toggleDrawer?.()
}
]}
@@ -3,7 +3,16 @@
import { Alert, Badge, Skeleton } from '$lib/components/common'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, FlowService, ListableApp, Script, ScriptService, type Flow } from '$lib/gen'
import {
AppService,
FlowService,
ListableApp,
Script,
ScriptService,
type Flow,
type ListableRawApp,
RawAppService
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import type uFuzzy from '@leeoniya/ufuzzy'
@@ -25,8 +34,9 @@
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './FlowIcon.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
type TableItem<T, U extends 'script' | 'flow' | 'app'> = T & {
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
marked?: string
type?: U
@@ -37,12 +47,14 @@
type TableScript = TableItem<Script, 'script'>
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
let scripts: TableScript[] | undefined
let flows: TableFlow[] | undefined
let apps: TableApp[] | undefined
let raw_apps: TableRawApp[] | undefined
let filteredItems: (TableScript | TableFlow | TableApp)[] = []
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = []
let itemKind: 'script' | 'flow' | 'app' | 'all' = 'all'
@@ -102,14 +114,29 @@
loading = false
}
async function loadRawApps(): Promise<void> {
raw_apps = (await RawAppService.listRawApps({ workspace: $workspaceStore! })).map(
(app: ListableRawApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
}
)
loading = false
}
$: owners = Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
let combinedItems: (TableScript | TableFlow | TableApp)[] | undefined = undefined
let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = undefined
$: combinedItems =
flows == undefined || scripts == undefined || apps == undefined
flows == undefined || scripts == undefined || apps == undefined || raw_apps == undefined
? undefined
: [
...flows.map((x) => ({
@@ -126,6 +153,11 @@
...x,
type: 'app' as 'app',
time: new Date(x.edited_at).getTime()
})),
...raw_apps.map((x) => ({
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
}))
].sort((a, b) =>
a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1
@@ -150,8 +182,10 @@
loadFlows()
if (!archived) {
loadApps()
loadRawApps()
} else {
apps = []
raw_apps = []
}
}
}
@@ -230,6 +264,7 @@
loadScripts()
loadApps()
loadFlows()
loadRawApps()
}}
/>
@@ -239,6 +274,7 @@
loadScripts()
loadApps()
loadFlows()
loadRawApps()
}}
/>
@@ -321,37 +357,51 @@
<div class="border rounded-md divide-y divide-gray-200">
<!-- <VirtualList {items} let:item bind:start bind:end> -->
{#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path)}
{#if item.type == 'script'}
<ScriptRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadScripts}
script={item}
{shareModal}
{moveDrawer}
/>
{:else if item.type == 'flow'}
<FlowRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadFlows}
flow={item}
{shareModal}
{moveDrawer}
/>
{:else if item.type == 'app'}
<AppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadApps}
app={item}
{moveDrawer}
{shareModal}
/>
{/if}
{#key item.summary}
{#key item.starred}
{#if item.type == 'script'}
<ScriptRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadScripts}
script={item}
{shareModal}
{moveDrawer}
/>
{:else if item.type == 'flow'}
<FlowRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadFlows}
flow={item}
{shareModal}
{moveDrawer}
/>
{:else if item.type == 'app'}
<AppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadApps}
app={item}
{moveDrawer}
{shareModal}
/>
{:else if item.type == 'raw_app'}
<RawAppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadRawApps}
app={item}
{moveDrawer}
{shareModal}
/>
{/if}
{/key}
{/key}
{/each}
<!-- </VirtualList> -->
</div>
@@ -10,7 +10,7 @@
export let favoriteLinks = [] as {
label: string
href: string
kind: 'script' | 'flow' | 'app'
kind: 'script' | 'flow' | 'app' | 'raw_app'
}[]
</script>
@@ -42,13 +42,17 @@
{:else}
<div class="py-1 w-full max-w-full">
{#each favoriteLinks ?? [] as favorite (favorite.href)}
<a href={favorite.href} on:click={close} class="w-full inline-flex flex-row px-4 py-2 hover:bg-gray-100">
<a
href={favorite.href}
on:click={close}
class="w-full inline-flex flex-row px-4 py-2 hover:bg-gray-100"
>
<span class="center-center">
{#if favorite.kind == 'script'}
<Code2 size={16} />
{:else if favorite.kind == 'flow'}
<Icon data={faBarsStaggered} />
{:else if favorite.kind == 'app'}
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
<LayoutDashboard size={16} />
{/if}
</span>
@@ -3,7 +3,14 @@
import Icon from 'svelte-awesome'
import UserMenu from '$lib/components/sidebar/UserMenu.svelte'
import { AppService, FlowService, OpenAPI, ScriptService, UserService } from '$lib/gen'
import {
AppService,
FlowService,
OpenAPI,
RawAppService,
ScriptService,
UserService
} from '$lib/gen'
import { classNames, isCloudHosted } from '$lib/utils'
import { browser } from '$app/environment'
@@ -48,7 +55,11 @@
let innerWidth = browser ? window.innerWidth : 2000
let favoriteLinks = [] as { label: string; href: string; kind: 'app' | 'script' | 'flow' }[]
let favoriteLinks = [] as {
label: string
href: string
kind: 'app' | 'script' | 'flow' | 'raw_app'
}[]
$: $workspaceStore && $starStore && onLoad()
function onLoad() {
@@ -75,6 +86,10 @@
workspace: $workspaceStore ?? '',
starredOnly: true
})
const raw_apps = await RawAppService.listRawApps({
workspace: $workspaceStore ?? '',
starredOnly: true
})
favoriteLinks = [
...scripts.map((s) => ({
label: s.summary || s.path,
@@ -90,6 +105,11 @@
label: f.summary || f.path,
href: `/apps/get/${f.path}`,
kind: 'app' as 'app'
})),
...raw_apps.map((f) => ({
label: f.summary || f.path,
href: `/apps/get_raw/${f.version}/${f.path}`,
kind: 'raw_app' as 'raw_app'
}))
]
}
@@ -46,7 +46,7 @@
async function loadApp() {
if (importJson) {
sendUserToast('Loaded from raw JSON')
sendUserToast('Loaded from JSON')
if ('value' in importJson) {
summary = importJson.summary
value = importJson.value
@@ -0,0 +1,5 @@
export function load({ params }) {
return {
stuff: { title: `App ${params.path}` }
}
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { page } from '$app/stores'
import { Skeleton } from '$lib/components/common'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { onMount } from 'svelte'
let loaded = false
onMount(async () => {
globalThis.windmill = {
username: $userStore?.username,
email: $userStore?.email,
workspace: $workspaceStore
}
// //@ts-ignore
// await import('http://localhost:3000/app.iife.js')
/* @vite-ignore */
await import(
`/api/w/${$workspaceStore}/raw_apps/get_data/${$page.params.version}/${$page.params.path}`
)
try {
globalThis.render()
} catch (e) {
sendUserToast('App seem to be ill-defined', true)
console.error(e)
}
loaded = true
})
</script>
<div id="root" />
{#if !loaded}
<Skeleton layout={[10]} />
{/if}
File diff suppressed because one or more lines are too long
-1
View File
@@ -7,7 +7,6 @@ const config = {
'hljs',
'splitpanes__pane',
'splitpanes__splitter',
...(process.env.NODE_ENV === 'production'
? [
{ pattern: /^m(\w?)-.*$/ },