Compare commits

..

7 Commits

Author SHA1 Message Date
Faton Ramadani
5131ee8b89 feat(frontend): add a dev docker-compose file + update readme 2023-04-12 21:46:13 +02:00
Ruben Fiszel
a5f6d73f7d fix(backend): do not fail on schedule not existing anymore 2023-04-12 20:06:50 +02:00
Ruben Fiszel
670c84b901 exclude deno/gen/file from global cache 2023-04-12 19:18:48 +02:00
Oliver Veal
92a293488e feat: inputs library on run page
* display previous script inputs on script run page

* parallelise loading

* parallelise loading

* also working for flows

* separate endpoints for scripts and flows

* Splitpanes and Saved Inputs (UI)

* Saved inputs API endpoints

* Editable Input name

* Narrow width styling

* feat(frontend): Add a toggle to open the saved inputs (#1401)

* feat(frontend): Add a toggle to open the saved inputs

* feat(frontend): Add a toggle to open the saved inputs

* feat(frontend): Move toggle

* update all

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-04-12 17:29:06 +02:00
Ruben Fiszel
0cd1e65e46 frontend apps rename improvements 2023-04-12 14:38:13 +02:00
Faton Ramadani
6aa1008933 fix(frontend): Remove output when deleting a component (#1397) 2023-04-12 10:28:54 +02:00
Ruben Fiszel
9434bbb18b fix script explorer 2023-04-11 22:12:28 +02:00
35 changed files with 1432 additions and 421 deletions

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS input;
DROP TYPE RUNNABLE_TYPE;

View File

@@ -0,0 +1,13 @@
CREATE TYPE RUNNABLE_TYPE AS ENUM ('ScriptHash', 'ScriptPath', 'FlowPath');
CREATE TABLE IF NOT EXISTS input (
id UUID PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
runnable_id VARCHAR(255) NOT NULL,
runnable_type RUNNABLE_TYPE NOT NULL,
name TEXT NOT NULL,
args JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by VARCHAR(50) NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE
);

View File

@@ -2782,6 +2782,27 @@ paths:
schema:
type: string
/w/{workspace}/flows/input_history/p/{path}:
get:
summary: list inputs for previous completed flow jobs
operationId: getFlowInputHistoryByPath
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: input history for completed jobs with this flow path
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Input"
/w/{workspace}/apps/list:
get:
summary: list all available apps
@@ -3322,23 +3343,6 @@ paths:
schema:
$ref: "#/components/schemas/Job"
# /w/{workspace}/jobs/flow/current_state/{id}:
# get:
# summary: get flow current step state
# operationId: getJob
# tags:
# - job
# parameters:
# - $ref: "#/components/parameters/WorkspaceId"
# - $ref: "#/components/parameters/JobId"
# responses:
# "200":
# description: state details
# content:
# application/json:
# schema:
# type: string
/w/{workspace}/jobs_u/getupdate/{id}:
get:
summary: get job updates
@@ -4558,6 +4562,118 @@ paths:
"200":
description: unstar item
/w/{workspace}/inputs/history:
get:
summary: List Inputs used in previously completed jobs
operationId: getInputHistory
tags:
- input
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/RunnableId"
- $ref: "#/components/parameters/RunnableType"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Input history for completed jobs
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Input"
/w/{workspace}/inputs/list:
get:
summary: List saved Inputs for a Runnable
operationId: listInputs
tags:
- input
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/RunnableId"
- $ref: "#/components/parameters/RunnableType"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Saved Inputs for a Runnable
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Input"
/w/{workspace}/inputs/create:
post:
summary: Create an Input for future use in a script or flow
operationId: createInput
tags:
- input
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/RunnableId"
- $ref: "#/components/parameters/RunnableType"
requestBody:
description: Input
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateInput"
responses:
"201":
description: Input created
content:
text/plain:
schema:
type: string
format: uuid
/w/{workspace}/inputs/update:
post:
summary: Update an Input
operationId: updateInput
tags:
- input
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: UpdateInput
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateInput"
responses:
"201":
description: Input updated
content:
text/plain:
schema:
type: string
format: uuid
/w/{workspace}/inputs/delete/{input}:
post:
summary: Delete a Saved Input
operationId: deleteInput
tags:
- input
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/InputId"
responses:
"200":
description: Input deleted
content:
text/plain:
schema:
type: string
format: uuid
components:
securitySchemes:
bearerAuth:
@@ -4567,6 +4683,7 @@ components:
type: apiKey
in: cookie
name: token
parameters:
WorkspaceId:
name: workspace
@@ -4685,7 +4802,6 @@ components:
in: query
schema:
type: string
ScriptStartPath:
name: script_path_start
description: mask to filter matching starting path
@@ -4742,7 +4858,6 @@ components:
in: query
schema:
type: string
ResultFilter:
name: result
description: filter on jobs containing those result as a json subset (@> in postgres)
@@ -4802,9 +4917,26 @@ components:
# type: string
# enum: ["preview", "script", "dependencies"]
# explode: false
RunnableId:
name: runnable_id
in: query
schema:
type: string
RunnableType:
name: runnable_type
in: query
schema:
$ref: "#/components/schemas/RunnableType"
InputId:
name: input
in: path
required: true
schema:
type: string
schemas:
$ref: "../../openflow.openapi.yaml#/components/schemas"
Script:
type: object
properties:
@@ -4875,6 +5007,60 @@ components:
type: object
additionalProperties: {}
Input:
type: object
properties:
id:
type: string
name:
type: string
args:
type: object
created_by:
type: string
created_at:
type: string
format: date-time
is_public:
type: boolean
required:
- id
- name
- args
- created_by
- created_at
- is_public
CreateInput:
type: object
properties:
name:
type: string
args:
type: object
required:
- name
- args
- created_by
UpdateInput:
type: object
properties:
id:
type: string
name:
type: string
is_public:
type: boolean
required:
- id
- name
- is_public
RunnableType:
type: string
enum: ["ScriptHash", "ScriptPath", "FlowPath"]
QueuedJob:
type: object
properties:
@@ -5896,6 +6082,7 @@ components:
- extra_perms
- edited_at
- execution_mode
AppWithLastVersion:
type: object
properties:

View File

@@ -6,14 +6,20 @@
* LICENSE-AGPL for a copy of the license.
*/
use hyper::StatusCode;
use sql_builder::prelude::*;
use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{maybe_refresh_folders, require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
Json, Router,
};
use hyper::StatusCode;
use sql_builder::prelude::*;
use sql_builder::SqlBuilder;
use sqlx::{Postgres, Transaction};
use windmill_audit::{audit_log, ActionKind};
@@ -27,14 +33,6 @@ use windmill_common::{
};
use windmill_queue::{push, schedule::push_scheduled_job, JobPayload, QueueTransaction};
use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{maybe_refresh_folders, require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_flows))

View File

@@ -0,0 +1,282 @@
/*
* 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, jobs::CompletedJob, users::Authed};
use axum::{
extract::{Path, Query},
routing::{get, post},
Extension, Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::types::Uuid;
use std::{
fmt::{Display, Formatter},
vec,
};
use windmill_common::{
error::JsonResult,
scripts::to_i64,
utils::{paginate, Pagination},
};
use windmill_queue::JobKind;
pub fn workspaced_service() -> Router {
Router::new()
.route("/history", get(get_input_history))
.route("/list", get(list_saved_inputs))
.route("/create", post(create_input))
.route("/update", post(update_input))
.route("/delete/:id", post(delete_input))
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
pub struct InputRow {
pub id: Uuid,
pub workspace_id: String,
pub runnable_id: String,
pub runnable_type: RunnableType,
pub name: String,
pub args: Value,
pub created_at: DateTime<Utc>,
pub created_by: String,
pub is_public: bool,
}
#[derive(Debug, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "runnable_type")]
pub enum RunnableType {
ScriptHash,
ScriptPath,
FlowPath,
}
impl Display for RunnableType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
RunnableType::ScriptHash => write!(f, "ScriptHash"),
RunnableType::ScriptPath => write!(f, "ScriptPath"),
RunnableType::FlowPath => write!(f, "FlowPath"),
}
}
}
impl RunnableType {
fn job_kind(&self) -> JobKind {
match self {
RunnableType::ScriptHash => JobKind::Script,
RunnableType::ScriptPath => JobKind::Script,
RunnableType::FlowPath => JobKind::Flow,
}
}
fn column_name(&self) -> &'static str {
match self {
RunnableType::ScriptHash => "script_hash",
RunnableType::ScriptPath => "script_path",
RunnableType::FlowPath => "script_path",
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RunnableParams {
pub runnable_id: String,
pub runnable_type: RunnableType,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Input {
id: Uuid,
name: String,
created_at: chrono::DateTime<chrono::Utc>,
args: serde_json::Value,
created_by: String,
is_public: bool,
}
async fn get_input_history(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(r): Query<RunnableParams>,
) -> JsonResult<Vec<Input>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let sql = &format!(
"select distinct on (args) * from completed_job \
where {} = $1 and job_kind = $2 and workspace_id = $3 \
order by args, started_at desc limit $4 offset $5",
r.runnable_type.column_name()
);
let query = sqlx::query_as::<_, CompletedJob>(sql);
let query = match r.runnable_type {
RunnableType::ScriptHash => query.bind(to_i64(&r.runnable_id)?),
_ => query.bind(&r.runnable_id),
};
let rows = query
.bind(r.runnable_type.job_kind())
.bind(&w_id)
.bind(per_page as i32)
.bind(offset as i32)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
let mut inputs = vec![];
for row in rows {
inputs.push(Input {
id: row.id,
name: format!(
"{} {}",
row.created_at.format("%H:%M %-d/%-m"),
row.created_by
),
created_at: row.created_at,
args: row.args.unwrap_or(serde_json::json!({})),
created_by: row.created_by,
is_public: true,
});
}
Ok(Json(inputs))
}
async fn list_saved_inputs(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(r): Query<RunnableParams>,
) -> JsonResult<Vec<Input>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, InputRow>(
"select * from input \
where runnable_id = $1 and runnable_type = $2 and workspace_id = $3 \
and is_public IS true OR created_by = $4 \
order by created_at desc limit $5 offset $6",
)
.bind(&r.runnable_id)
.bind(&r.runnable_type)
.bind(&w_id)
.bind(&authed.username)
.bind(per_page as i32)
.bind(offset as i32)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
let mut inputs: Vec<Input> = Vec::new();
for row in rows {
inputs.push(Input {
id: row.id,
name: row.name,
args: row.args,
created_by: row.created_by,
created_at: row.created_at,
is_public: row.is_public,
})
}
Ok(Json(inputs))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateInput {
name: String,
args: serde_json::Value,
}
async fn create_input(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(r): Query<RunnableParams>,
Json(input): Json<CreateInput>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO input (id, workspace_id, runnable_id, runnable_type, name, args, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(&id)
.bind(&w_id)
.bind(&r.runnable_id)
.bind(&r.runnable_type)
.bind(&input.name)
.bind(&input.args)
.bind(&authed.username)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(id.to_string()))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateInput {
id: Uuid,
name: String,
is_public: bool,
}
async fn update_input(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(input): Json<UpdateInput>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query("UPDATE input SET name = $1, is_public = $2 WHERE id = $3 and workspace_id = $4")
.bind(&input.name)
.bind(&input.is_public)
.bind(&input.id)
.bind(&w_id)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(input.id.to_string()))
}
async fn delete_input(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, i_id)): Path<(String, Uuid)>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query("DELETE FROM input WHERE id = $1 and workspace_id = $2")
.bind(&i_id)
.bind(&w_id)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(i_id.to_string()))
}

View File

@@ -6,6 +6,12 @@
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed, OptAuthed},
variables::get_workspace_key,
BASE_URL,
};
use anyhow::Context;
use axum::{
extract::{FromRequest, Json, Path, Query},
@@ -34,13 +40,6 @@ use windmill_queue::{
get_queued_job, push, JobKind, JobPayload, QueueTransaction, QueuedJob, RawCode,
};
use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed, OptAuthed},
variables::get_workspace_key,
BASE_URL,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/run/f/*script_path", post(run_flow_by_path))

View File

@@ -7,6 +7,13 @@
*/
use crate::oauth2::AllClients;
use crate::{
db::UserDB,
oauth2::{build_oauth_clients, SlackVerifier},
tracing_init::{MyMakeSpan, MyOnResponse},
users::{Authed, OptAuthed},
webhook_util::WebhookShared,
};
use argon2::Argon2;
use axum::{middleware::from_extractor, routing::get, Extension, Router};
use db::DB;
@@ -22,14 +29,6 @@ use tower_http::{
};
use windmill_common::utils::rd_string;
use crate::{
db::UserDB,
oauth2::{build_oauth_clients, SlackVerifier},
tracing_init::{MyMakeSpan, MyOnResponse},
users::{Authed, OptAuthed},
webhook_util::WebhookShared,
};
mod apps;
mod audit;
mod capture;
@@ -39,6 +38,7 @@ mod flows;
mod folders;
mod granular_acls;
mod groups;
mod inputs;
pub mod jobs;
mod oauth2;
mod resources;
@@ -119,25 +119,27 @@ pub async fn run_server(
.nest(
"/w/:workspace_id",
Router::new()
.nest("/scripts", scripts::workspaced_service())
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/flows", flows::workspaced_service())
.nest("/folders", folders::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/inputs", inputs::workspaced_service())
.nest("/jobs", jobs::workspaced_service().layer(cors.clone()))
.nest("/oauth", oauth2::workspaced_service())
.nest("/resources", resources::workspaced_service())
.nest("/schedules", schedule::workspaced_service())
.nest("/scripts", scripts::workspaced_service())
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),
)
.nest("/variables", variables::workspaced_service())
.nest("/oauth", oauth2::workspaced_service())
.nest("/resources", resources::workspaced_service())
.nest("/schedules", schedule::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/acls", granular_acls::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/flows", flows::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/folders", folders::workspaced_service()),
.nest("/workspaces", workspaces::workspaced_service()),
)
.nest("/workspaces", workspaces::global_service())
.nest(

View File

@@ -6,10 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
use sql_builder::prelude::*;
use windmill_audit::{audit_log, ActionKind};
use windmill_parser::MainArgSignature;
use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
@@ -25,6 +21,7 @@ use axum::{
use hyper::StatusCode;
use serde::Serialize;
use serde_json::json;
use sql_builder::prelude::*;
use sql_builder::SqlBuilder;
use sqlx::{FromRow, Postgres, Transaction};
use std::{
@@ -32,6 +29,7 @@ use std::{
hash::{Hash, Hasher},
sync::Arc,
};
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{Error, JsonResult, Result},
schedule::Schedule,
@@ -44,6 +42,7 @@ use windmill_common::{
list_elems_from_hub, not_found_if_none, paginate, require_admin, Pagination, StripPath,
},
};
use windmill_parser::MainArgSignature;
use windmill_queue::{self, schedule::push_scheduled_job, QueueTransaction};
const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20;
@@ -85,6 +84,7 @@ pub fn workspaced_service() -> Router {
.route("/deployment_status/h/:hash", get(get_deployment_status))
.route("/list_paths", get(list_paths))
}
async fn list_scripts(
authed: Authed,
Extension(user_db): Extension<UserDB>,

View File

@@ -6,11 +6,10 @@
* LICENSE-AGPL for a copy of the license.
*/
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const MAX_PER_PAGE: usize = 10000;
pub const DEFAULT_PER_PAGE: usize = 1000;
@@ -20,7 +19,8 @@ pub struct Pagination {
pub page: Option<usize>,
pub per_page: Option<usize>,
}
#[derive(Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct StripPath(pub String);
impl StripPath {

View File

@@ -223,14 +223,17 @@ pub async fn schedule_again_if_scheduled<'c, R: rsmq_async::RsmqConnection + Clo
script_path: &str,
w_id: &str,
) -> windmill_common::error::Result<QueueTransaction<'c, R>> {
let schedule = get_schedule_opt(tx.transaction_mut(), w_id, schedule_path)
.await?
.ok_or_else(|| {
Error::InternalErr(format!(
"Could not find schedule {:?} for workspace {}",
schedule_path, w_id
))
})?;
let schedule = get_schedule_opt(tx.transaction_mut(), w_id, schedule_path).await?;
if schedule.is_none() {
tracing::error!(
"Schedule {schedule_path} in {w_id} not found. Impossible to schedule again"
);
return Ok(tx);
}
let schedule = schedule.unwrap();
if schedule.enabled && script_path == schedule.script_path {
let res = windmill_queue::schedule::push_scheduled_job(
tx,

View File

@@ -79,7 +79,7 @@ async fn copy_cache_from_bucket(bucket: &str, tx: Option<Sender<()>>) -> Option:
.arg("--size-only")
.arg("--fast-list")
.arg("--exclude")
.arg(format!("\"{TAR_CACHE_FILENAME}\""))
.arg(format!("\"{TAR_CACHE_FILENAME},/deno/gen/file/**\""))
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
@@ -128,7 +128,7 @@ async fn copy_cache_to_bucket(bucket: &str) {
.arg("--size-only")
.arg("--fast-list")
.arg("--exclude")
.arg(format!("\"{TAR_CACHE_FILENAME}\""))
.arg(format!("\"{TAR_CACHE_FILENAME},/deno/gen/file/**\""))
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()

86
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,86 @@
version: "3.7"
services:
db:
image: postgres:14
restart: unless-stopped
volumes:
- db_data:/var/lib/postgresql/data
expose:
- 5432
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: windmill
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
windmill_server:
image: ghcr.io/windmill-labs/windmill:main
deploy:
replicas: 1
restart: unless-stopped
expose:
- 8000
ports:
- 8000:8000
environment:
- DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db/windmill?sslmode=disable
- BASE_URL=http://${WM_BASE_URL}
- RUST_LOG=info
## You can set the number of workers to > 0 and not need any separate worker service
- NUM_WORKERS=0
- DISABLE_SERVER=false
- METRICS_ADDR=false
depends_on:
db:
condition: service_healthy
# volumes:
# - ./oauth.json/:/usr/src/app/oauth.json
windmill_worker:
image: ghcr.io/windmill-labs/windmill:main
deploy:
replicas: 3
restart: unless-stopped
environment:
- DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db/windmill?sslmode=disable
- BASE_URL=http://${WM_BASE_URL}
- BASE_INTERNAL_URL=http://windmill_server:8000
- RUST_LOG=info
- NUM_WORKERS=1
- DISABLE_SERVER=true
- KEEP_JOB_DIR=false
- DENO_PATH=/usr/bin/deno
- PYTHON_PATH=/usr/local/bin/python3
- METRICS_ADDR=false
depends_on:
db:
condition: service_healthy
# to mount the worker folder to debug,, KEEP_JOB_DIR=true and mount /tmp/windmill
volumes:
- worker_dependency_cache:/tmp/windmill/cache
lsp:
image: ghcr.io/windmill-labs/windmill-lsp:latest
restart: unless-stopped
expose:
- 3001
caddy:
image: caddy:2.5.2-alpine
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
ports:
- 80:80
- 443:443
environment:
- BASE_URL=${WM_BASE_URL}
volumes:
db_data: null
worker_dependency_cache: null

View File

@@ -17,7 +17,7 @@ In the root folder:
```bash
docker build . -t windmill
docker compose up db windmill_server windmill_worker
docker compose -f docker-compose-dev.yml up db windmill_server windmill_worker
```
### 2. Backend is run by cargo
@@ -28,20 +28,23 @@ docker compose up db windmill_server windmill_worker
- Install llvm
**On OSX:**
```bash
brew install llvm caddy gsed
# make LLVM tools available on PATH
echo 'export PATH="/opt/homebrew/opt/llvm/bin:$PATH"' >> ~/.zshrc
# now, restart your shell. You should now have the `lld` binary on your PATH.
```
- To test that you have Rust and Cargo installed run `cargo --version`
- In your terminal, go to the backend directory and run `cargo build`
- Run `cargo run`
**Known issue on M1 Mac while running `cargo build`**
- You may encounter `linking with cc failed` build time error.
- To solve this run:
```bash
@@ -49,7 +52,6 @@ docker compose up db windmill_server windmill_worker
source ~/.zshrc
```
**Do a Frontend Build**
In order to run the backend, you need to have a frontend build inside `frontend/build/`.
@@ -70,11 +72,14 @@ npm run build
```
**Known issue while running `npm run build`**
- You may encounter `FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory` error.
- To solve this run:
```bash
export NODE_OPTIONS=--max_old_space_size=8096
```
- run `npm run build` again
In the root folder:
@@ -86,7 +91,7 @@ docker-compose up db
In the backend folder:
```bash
DATABASE_URL=postgres://postgres:changeme@127.0.0.1:5433/windmill?sslmode=disable cargo run
DATABASE_URL=postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable cargo run
```
You can now access [http://127.0.0.1:8000](http://127.0.0.1:8000).
@@ -146,13 +151,13 @@ Recommended config for VS Code:
- turn _format on save_ on
## Building
The project is built with [SvelteKit](https://kit.svelte.dev/) and uses as output static files.
There are others adapters for sveltekit, but we use the static adapter.
To build the frontend as static assets, use:
```
npm run build
```
@@ -160,10 +165,11 @@ npm run build
The output is in the `build` folder.
The default build assume you serve every non static files as the 200.html file which is catchall. If you prefer a normal layout, you can use:
```
NOTCATCHALL=true npm run build
```
which will generate an index.html and allow you to serve the frontend with any static server.
Env variables used for build are set in .env file. See [https://vitejs.dev/guide/env-and-mode.html#env-files](https://vitejs.dev/guide/env-and-mode.html#env-files) for more details.

View File

@@ -101,8 +101,8 @@
>{/if}{#if typeof result == 'object' && Object.keys(result).length > 0}<div
class="mb-2 w-full text-sm text-gray-700 relative"
>The result keys are: <b>{truncate(Object.keys(result).join(', '), 50)}</b>
<div class="text-gray-500 text-sm absolute top-6 right-0">
<button on:click={jsonViewer.openDrawer}>Expand JSON</button>
<div class="text-gray-500 text-xs absolute top-5 right-0">
<button on:click={jsonViewer.openDrawer}>Expand</button>
</div></div
>{/if}{#if !forceJson && resultKind == 'table-col'}<div
class="grid grid-flow-col-dense border border-gray-200 rounded-md"

View File

@@ -0,0 +1,326 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { InputService, type Input, RunnableType, type CreateInput } from '$lib/gen/index.js'
import { userStore, workspaceStore } from '$lib/stores.js'
import { classNames, displayDate, displayDaysAgo, sendUserToast } from '$lib/utils.js'
import { faSave } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { ArrowLeftIcon, Edit, X } from 'lucide-svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
export let scriptHash: string | null = null
export let scriptPath: string | null = null
export let flowPath: string | null = null
let runnableId: string | undefined = scriptHash || scriptPath || flowPath || undefined
let runnableType: RunnableType | undefined = scriptHash
? RunnableType.SCRIPT_HASH
: scriptPath
? RunnableType.SCRIPT_PATH
: flowPath
? RunnableType.FLOW_PATH
: undefined
// Are the current Inputs valid and able to be saved?
export let isValid: boolean
export let args: object
let previousInputs: Input[] = []
interface EditableInput extends Input {
isEditing?: boolean
isSaving?: boolean
}
let savedInputs: EditableInput[] = []
let selectedInput: Input | null
async function loadInputHistory() {
previousInputs = await InputService.getInputHistory({
workspace: $workspaceStore!,
runnableId,
runnableType,
perPage: 10
})
}
async function loadSavedInputs() {
savedInputs = await InputService.listInputs({
workspace: $workspaceStore!,
runnableId,
runnableType,
perPage: 10
})
}
let savingInputs = false
async function saveInput(args: object) {
savingInputs = true
const requestBody: CreateInput = {
name: 'Saved ' + displayDate(new Date()),
args
}
try {
let id = await InputService.createInput({
workspace: $workspaceStore!,
runnableId,
runnableType,
requestBody
})
const input = {
id,
created_by: '',
created_at: new Date().toISOString(),
is_public: false,
...requestBody
}
savedInputs = [input, ...savedInputs]
} catch (err) {
console.error(err)
sendUserToast(`Failed to save Input: ${err}`, true)
}
savingInputs = false
}
async function updateInput(input: EditableInput) {
input.isSaving = true
try {
await InputService.updateInput({
workspace: $workspaceStore!,
requestBody: {
id: input.id,
name: input.name,
is_public: input.is_public
}
})
} catch (err) {
console.error(err)
sendUserToast(`Failed to update Input: ${err}`, true)
}
input.isSaving = false
}
async function deleteInput(input: Input) {
try {
await InputService.deleteInput({
workspace: $workspaceStore!,
input: input.id
})
savedInputs = savedInputs.filter((i) => i.id !== input.id)
if (selectedInput === input) {
selectedInput = null
}
} catch (err) {
console.error(err)
sendUserToast(`Failed to delete Input: ${err}`, true)
}
}
$: {
if ($workspaceStore && (scriptHash || scriptPath || flowPath)) {
loadInputHistory()
loadSavedInputs()
}
}
const dispatch = createEventDispatcher()
const selectArgs = (selected_args: object) => {
dispatch('selected_args', selected_args)
}
</script>
<div class="min-w-[300px] h-full">
<Splitpanes horizontal={true}>
<Pane>
<div class="w-full flex flex-col gap-4 p-4">
<div class="w-full flex justify-between items-center gap-4 flex-wrap">
<span class="text-sm font-extrabold flex-shrink-0"
>Saved Inputs <Tooltip
>Shared tooltips are available to anyone with access to the script</Tooltip
></span
>
<Button
on:click={() => saveInput(args)}
disabled={!isValid}
loading={savingInputs}
startIcon={{ icon: faSave }}
color="blue"
size="xs"
>
<span>Save Current Input</span>
</Button>
</div>
<div class="w-full flex flex-col gap-2 h-full overflow-y-auto p">
{#if savedInputs.length > 0}
{#each savedInputs as i}
<button
class={classNames(
`w-full flex items-center group justify-between gap-4 py-2 px-4 text-left border rounded-md hover:bg-gray-100 transition-all`,
selectedInput === i ? 'border-blue-500 bg-blue-50' : ''
)}
on:click={() => {
if (!i.isEditing) {
if (selectedInput === i) {
selectedInput = null
} else {
selectedInput = i
}
}
}}
>
<div class="w-full h-full items-center justify-between flex gap-1 min-w-0">
{#if i.isEditing}
<form
on:submit={() => {
updateInput(i)
i.isEditing = false
i.isSaving = false
}}
class="w-full"
>
<input type="text" bind:value={i.name} class="text-gray-700" />
</form>
{:else}
<small
class="whitespace-nowrap overflow-hidden text-ellipsis flex-shrink text-left"
>
{i.name}
</small>
{/if}
{#if i.created_by == $userStore?.username || $userStore?.is_admin || $userStore?.is_super_admin}
<div class="items-center flex gap-2">
{#if !i.isEditing}
<div class="group-hover:block hidden -my-2">
<Toggle
size="xs"
options={{ right: 'shared' }}
bind:checked={i.is_public}
on:change={() => {
updateInput(i)
}}
/>
</div>
{/if}
<Button
loading={i.isSaving}
color="gray"
size="xs"
variant="border"
spacingSize="xs2"
btnClasses={'group-hover:block hidden'}
on:click={(e) => {
e.stopPropagation()
i.isEditing = !i.isEditing
if (!i.isEditing) {
updateInput(i)
i.isSaving = false
}
}}
>
<Edit class="w-4 h-4" />
</Button>
<Button
color="red"
size="xs"
spacingSize="xs2"
variant="border"
btnClasses={i.isEditing ? 'block' : 'group-hover:block hidden'}
on:click={() => deleteInput(i)}
>
<X class="w-4 h-4" />
</Button>
</div>
{:else}
<span class="text-xs text-gray-600">By {i.created_by}</span>
{/if}
</div>
</button>
{/each}
{:else}
<div class="text-center text-gray-500">No saved Inputs</div>
{/if}
</div>
</div>
</Pane>
<Pane>
<div class="w-full flex flex-col gap-4 p-4">
<span class="text-sm font-extrabold">Previous Inputs</span>
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
{#if previousInputs.length > 0}
{#each previousInputs as i}
<button
class={classNames(
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-gray-100 transition-a`,
selectedInput === i ? 'border-blue-500 bg-blue-50' : ''
)}
on:click={() => {
if (selectedInput === i) {
selectedInput = null
} else {
selectedInput = i
}
}}
>
<div class="w-full h-full items-center flex gap-4 min-w-0">
<small
class="whitespace-nowrap overflow-hidden text-ellipsis flex-shrink text-left"
>
{displayDaysAgo(i.created_at)} by {i.created_by}
</small>
</div>
</button>
{/each}
{:else}
<div class="text-center text-gray-500">No previous Inputs</div>
{/if}
</div>
</div>
</Pane>
<Pane class="flex flex-col justify-between">
<div class="w-full flex flex-col gap-4 p-4 h-full">
<span class="text-sm font-extrabold">Preview</span>
<div class="w-full h-full overflow-auto">
{#if Object.keys(selectedInput?.args || {}).length > 0}
<div class="border h-full p-2">
<ObjectViewer json={selectedInput?.args} />
</div>
{:else}
<div class="text-center text-gray-500">
Select an Input to preview scripts arguments
</div>
{/if}
</div>
</div>
<div class="w-full flex flex-col p-4">
<Button
color="blue"
btnClasses="w-full"
size="sm"
spacingSize="xl"
on:click={() => selectArgs(selectedInput?.args)}
disabled={Object.keys(selectedInput?.args || {}).length === 0}
>
<ArrowLeftIcon class="w-4 h-4 mr-2" />
Use Input
</Button>
</div>
</Pane>
</Splitpanes>
</div>

View File

@@ -22,18 +22,17 @@
<div
class="pointer-events-auto w-full max-w-sm overflow-hidden bg-white shadow-lg ring-1 ring-black ring-opacity-5 border"
>
<div class="p-4">
<div class="p-2 min-h-[60px]">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="flex-shrink-0 mt-0.5">
{#if error}
<XCircleIcon class="h-6 w-6 text-red-400" />
<XCircleIcon class="h-4 w-4 text-red-400" />
{:else}
<CheckCircle2 class="h-6 w-6 text-green-400" />
<CheckCircle2 class="h-4 w-4 text-green-400" />
{/if}
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-gray-900">{error ? 'Error' : 'Success'}</p>
<p class="mt-1 text-sm text-gray-500">{message}</p>
<div class="ml-3 w-0 flex-1">
<p class="text-sm text-gray-500">{message}</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button

View File

@@ -53,9 +53,9 @@
}}
/>
<div
class="w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-4 peer-focus:ring-blue-300
peer-checked:after:translate-x-full peer-checked:after:border-white after:content-['']
after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300
class="w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-4 peer-focus:ring-blue-300
peer-checked:after:translate-x-full peer-checked:after:border-white after:content-['']
after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300
after:border after:rounded-full after:h-5 after:w-5 after:transition-all {color == 'red'
? 'peer-checked:bg-red-600'
: 'peer-checked:bg-blue-600'}"

View File

@@ -190,7 +190,7 @@
$state = $state
} catch (e) {
sendUserToast('Error running frontend script: ' + e.message, true)
sendUserToast(`Error running frontend script ${id}: ` + e.message, true)
// Manually add a fake job to the job list to show the error
@@ -206,8 +206,7 @@
}
loading = false
return
}
if (noBackend) {
} else if (noBackend) {
if (!noToast) {
sendUserToast('This app is not connected to a windmill backend, it is a static preview')
}

View File

@@ -25,7 +25,6 @@
faClipboard,
faExternalLink,
faFileExport,
faGlobe,
faSave
} from '@fortawesome/free-solid-svg-icons'
import {
@@ -42,7 +41,7 @@
import { getContext } from 'svelte'
import { Icon } from 'svelte-awesome'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { appToHubUrl, classNames, copyToClipboard, sendUserToast } from '../../../utils'
import { classNames, copyToClipboard, sendUserToast } from '../../../utils'
import type {
AppInput,
ConnectedAppInput,
@@ -607,14 +606,14 @@
appExport.open($app)
}
},
{
displayName: 'Publish to Hub',
icon: faGlobe,
action: () => {
const url = appToHubUrl(toStatic($app, $staticExporter, $summary))
window.open(url.toString(), '_blank')
}
},
// {
// displayName: 'Publish to Hub',
// icon: faGlobe,
// action: () => {
// const url = appToHubUrl(toStatic($app, $staticExporter, $summary))
// window.open(url.toString(), '_blank')
// }
// },
{
displayName: 'Hub compatible JSON',
icon: faFileExport,

View File

@@ -22,7 +22,6 @@
app,
mode,
connectingInput,
runnableComponents,
summary,
focusedGrid,
parentWidth,
@@ -39,40 +38,6 @@
.flatMap((id) => dfs($app.grid, id, $app.subgrids ?? {}))
.filter((x) => x != undefined) as string[]
}
function removeGridElement(component) {
if (component) {
$app.grid = $app.grid.filter((gridComponent) => {
if (gridComponent.data.id === component.id) {
if (
gridComponent.data.componentInput?.type === 'runnable' &&
gridComponent.data.componentInput?.runnable?.type === 'runnableByName' &&
gridComponent.data.componentInput?.runnable.inlineScript
) {
const { name, inlineScript } = gridComponent.data.componentInput?.runnable
if (!$app.unusedInlineScripts) {
$app.unusedInlineScripts = []
}
$app.unusedInlineScripts.push({
name,
inlineScript
})
$app = $app
}
}
return gridComponent.data.id !== component?.id
})
delete $runnableComponents[component.id]
$runnableComponents = $runnableComponents
$selectedComponent = undefined
}
}
</script>
<div class="relative w-full z-20 overflow-visible">
@@ -135,7 +100,6 @@
component={dataItem.data}
selected={Boolean($selectedComponent?.includes(dataItem.id))}
locked={isFixed(dataItem)}
on:delete={() => removeGridElement(dataItem.data)}
on:lock={() => {
const gridItem = findGridItem($app, dataItem.id)
if (gridItem) {

View File

@@ -107,7 +107,7 @@
bind:inlineScript={hiddenInlineScript.script.inlineScript}
/>
{:else}
<span class="text-gray-600 text-xs">No hiddenInlineScript.script defined</span>
<span class="text-gray-600 text-xs">No script defined</span>
{/if}
</div>
{#if Object.keys(hiddenInlineScript.script.fields).length > 0}

View File

@@ -32,7 +32,10 @@ export function dfs(
for (const item of grid) {
if (item.id === id) {
return [id]
} else if (item.data.type == 'tablecomponent' && item.data.actionButtons.find((x) => x.id)) {
} else if (
item.data.type == 'tablecomponent' &&
item.data.actionButtons.find((x) => id == x.id)
) {
return [item.id, id]
} else {
for (let i = 0; i < (item.data.numberOfSubgrids ?? 0); i++) {
@@ -133,11 +136,14 @@ export function findGridItem(app: App, id: string): GridItem | undefined {
}
export function getNextGridItemId(app: App): string {
const subgridsKeys = allItems(app.grid, app.subgrids).map((x) => x.id)
const withoutDash = subgridsKeys.map((element) => element.split('-')[0])
const id = getNextId([...new Set(withoutDash)])
return id
const allIds = allItems(app.grid, app.subgrids).flatMap((x) => {
if (x.data.type === 'tablecomponent') {
return [x.id, ...x.data.actionButtons.map((x) => x.id)]
} else {
return [x.id]
}
})
return getNextId(allIds)
}
export function getAllRecomputeIdsForComponent(app: App, id: string | undefined) {

View File

@@ -79,7 +79,6 @@
{component}
{selected}
shouldHideActions={$connectingInput.opened}
on:delete
on:lock
on:expand
{locked}

View File

@@ -12,7 +12,7 @@
export let first: boolean = false
</script>
<OutputHeader selectable={false} {id} {name} color="blue" {first}>
<OutputHeader renamable={false} selectable={true} {id} {name} color="blue" {first}>
<ComponentOutputViewer
componentId={id}
on:select={({ detail }) => {

View File

@@ -6,6 +6,8 @@
import { getContext } from 'svelte'
import { allsubIds, findGridItem } from '../../appUtils'
import IdEditor from './IdEditor.svelte'
import type { AppComponent } from '../../component'
import type { Runnable } from '$lib/components/apps/inputType'
export let id: string
export let name: string
@@ -13,6 +15,7 @@
export let nested: boolean = false
export let color: 'blue' | 'indigo' = 'indigo'
export let selectable: boolean = true
export let renamable: boolean = true
const { manuallyOpened, search, hasResult } = getContext<ContextPanelContext>('ContextPanel')
@@ -47,66 +50,102 @@
}
function renameId(newId: string): void {
{
const item = findGridItem($app, id)
if (item) {
item.data.id = newId
item.id = newId
}
const oldSubgrids = Object.keys($app.subgrids ?? {}).filter((subgrid) =>
subgrid.startsWith(id + '-')
)
oldSubgrids.forEach((subgrid) => {
if ($app.subgrids) {
$app.subgrids[subgrid.replace(id, newId)] = $app.subgrids[subgrid]
delete $app.subgrids[subgrid]
}
})
allItems($app.grid, $app.subgrids).forEach((item) => {
if (item.data.componentInput?.type == 'connected') {
if (item.data.componentInput.connection?.componentId === id) {
item.data.componentInput.connection.componentId = newId
}
} else if (item.data.componentInput?.type == 'runnable') {
if (
item.data.componentInput?.runnable?.type === 'runnableByName' &&
item.data.componentInput?.runnable?.inlineScript?.refreshOn
?.map((x) => x.id)
?.includes(id)
) {
item.data.componentInput.runnable.inlineScript.refreshOn =
item.data.componentInput.runnable.inlineScript.refreshOn.map((x) => {
if (x.id === id) {
return {
id: newId,
key: x.key
}
}
return x
})
}
}
const item = findGridItem($app, id)
Object.values(item.data.configuration ?? {}).forEach((config) => {
if (config.type === 'connected') {
if (config.connection?.componentId === id) {
config.connection.componentId = newId
}
} else if (config.type == 'oneOf') {
Object.values(config.configuration ?? {}).forEach((choices) => {
Object.values(choices).forEach((c) => {
if (c.type === 'connected') {
if (c.connection?.componentId === id) {
c.connection.componentId = newId
}
}
})
})
}
if (!item) {
return
}
item.data.id = newId
item.id = newId
const oldSubgrids = Object.keys($app.subgrids ?? {}).filter((subgrid) =>
subgrid.startsWith(id + '-')
)
oldSubgrids.forEach((subgrid) => {
if ($app.subgrids) {
$app.subgrids[subgrid.replace(id, newId)] = $app.subgrids[subgrid]
delete $app.subgrids[subgrid]
}
})
function propagateRename(from: string, to: string) {
allItems($app.grid, $app.subgrids).forEach((item) => {
renameComponent(from, to, item.data)
})
$app.hiddenInlineScripts.forEach((x) => {
console.log('process', x.name, id)
processRunnable(from, to, {
name: x.name,
inlineScript: x.inlineScript,
type: 'runnableByName'
})
})
$app = $app
$selectedComponent = [newId]
}
propagateRename(id, newId)
if (item?.data.type == 'tablecomponent') {
for (let c of item.data.actionButtons) {
let old = c.id
c.id = c.id.replace(id + '_', newId + '_')
propagateRename(old, c.id)
}
}
$app = $app
$selectedComponent = [newId]
}
function renameComponent(from: string, to: string, data: AppComponent) {
if (data.type == 'tablecomponent') {
for (let c of data.actionButtons) {
renameComponent(from, to, c)
}
}
let componentInput = data.componentInput
if (componentInput?.type == 'connected') {
if (componentInput.connection?.componentId === from) {
componentInput.connection.componentId = to
}
} else if (componentInput?.type == 'runnable') {
processRunnable(from, to, componentInput.runnable)
}
Object.values(data.configuration ?? {}).forEach((config) => {
if (config.type === 'connected') {
if (config.connection?.componentId === from) {
config.connection.componentId = to
}
} else if (config.type == 'oneOf') {
Object.values(config.configuration ?? {}).forEach((choices) => {
Object.values(choices).forEach((c) => {
if (c.type === 'connected') {
if (c.connection?.componentId === id) {
c.connection.componentId = to
}
}
})
})
}
})
}
function processRunnable(from: string, to: string, runnable: Runnable) {
if (
runnable?.type === 'runnableByName' &&
runnable?.inlineScript?.refreshOn?.find((x) => x.id === from)
) {
console.log('processss')
runnable.inlineScript.refreshOn = runnable.inlineScript.refreshOn.map((x) => {
console.log('renaming', x)
if (x.id === from) {
return {
id: to,
key: x.key
}
}
return x
})
}
}
</script>
@@ -161,7 +200,7 @@
</div>
{/if}
</button>
{#if selectable && ($selectedComponent?.includes(id) || $hoverStore === id)}
{#if selectable && renamable && ($selectedComponent?.includes(id) || $hoverStore === id)}
<IdEditor
{id}
on:selected={() => ($selectedComponent = [id])}

View File

@@ -11,7 +11,7 @@
export let first: boolean = false
</script>
<OutputHeader {id} name={'Table action'} {first}>
<OutputHeader renamable={false} {id} name={'Table action'} {first}>
<ComponentOutputViewer
componentId={id}
on:select={({ detail }) => {

View File

@@ -150,7 +150,7 @@
startIcon={{ icon: faCodeBranch }}
btnClasses="truncate"
>
fork a detached script
Copy a script
</Button>
<Button
on:click={() => dispatch('delete')}

View File

@@ -14,8 +14,14 @@
function deleteBackgroundScript(index: number) {
// remove the script from the array at the index
$app.hiddenInlineScripts.splice(index, 1)
$app.hiddenInlineScripts = [...$app.hiddenInlineScripts]
if ($app.hiddenInlineScripts.length - 1 == index) {
$app.hiddenInlineScripts.splice(index, 1)
$app.hiddenInlineScripts = [...$app.hiddenInlineScripts]
} else {
$app.hiddenInlineScripts[index].inlineScript = undefined
$app.hiddenInlineScripts[index].name = `Background Script ${index}`
$app.hiddenInlineScripts = $app.hiddenInlineScripts
}
delete $runnableComponents[`bg_${index}`]
}

View File

@@ -59,6 +59,7 @@
push(history, $app)
if (componentSettings?.item.id) {
delete $worldStore.outputsById[componentSettings?.item.id]
$errorByComponent = clearErrorByComponentId(componentSettings?.item.id, $errorByComponent)
$jobs = clearJobsByComponentId(componentSettings?.item.id, $jobs)
@@ -235,7 +236,6 @@
<TableActions id={component.id} bind:components={componentSettings.item.data.actionButtons} />
{/if}
<AlignmentEditor bind:component={componentSettings.item.data} />
{#if componentSettings.item.data.type === 'buttoncomponent' || componentSettings.item.data.type === 'formcomponent' || componentSettings.item.data.type === 'formbuttoncomponent'}
<Recompute
bind:recomputeIds={componentSettings.item.data.recomputeIds}
@@ -244,6 +244,7 @@
{/if}
<div class="grow shrink" />
<AlignmentEditor bind:component={componentSettings.item.data} />
{#if Object.keys(ccomponents[component.type].customCss ?? {}).length > 0}
<PanelSection title="Styling">

View File

@@ -14,7 +14,6 @@
export let recomputeOnInputChanged: boolean = false
const colors = {
red: 'text-red-800 border-red-600 bg-red-100',
green: 'text-green-800 border-green-600 bg-green-100',
indigo: 'text-indigo-800 border-indigo-600 bg-indigo-100',
blue: 'text-blue-800 border-blue-600 bg-blue-100'
@@ -157,13 +156,13 @@
</Button>
{/if}
</div>
<div class="flex flex-row gap-2 flex-wrap">
<div class="flex flex-row gap-2 flex-wrap mt-2">
{#each frontendDependencies as label, index}
<span class={classNames(badgeClass, colors['red'])}>
<span class={classNames(badgeClass, colors['blue'])}>
{label}
<button
on:click={() => deleteDep(index)}
class="bg-red-300 cursor-pointer hover:bg-red-400 ml-1 rounded-md"
class="bg-blue-300 cursor-pointer hover:bg-blue-400 ml-1 rounded-md"
>
<X size={18} class="p-0.5" />
</button>

View File

@@ -11,9 +11,11 @@
<div
class={'bg-white py-3 ' +
(stickToTop ? 'lg:sticky lg:top-0 z-[500] border-b border-gray-200 border-opacity-0 duration-300 '
+ (scrollY >= 30 ? 'border-opacity-100 ' : '') : '')
+ ($$props.class || '')}
(stickToTop
? 'lg:sticky lg:top-0 z-[500] border-b border-gray-200 border-opacity-0 duration-300 ' +
(scrollY >= 30 ? 'border-opacity-100 ' : '')
: '') +
($$props.class || '')}
>
<div class={'w-full flex flex-wrap justify-between items-center gap-4 ' + wide}>
<div class="flex flex-wrap items-center gap-2">
@@ -26,7 +28,7 @@
<slot name="middle" />
{/if}
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2 lg:gap-4">
{#if $$slots.right}
<slot name="right" />
{/if}

View File

@@ -20,8 +20,9 @@ export const DENO_INIT_CODE = `// Ctrl/CMD+. to cache dependencies on imports ho
export async function main(
a: number,
b: "my" | "enum",
//c: Resource<'postgresql'>,
d = "inferred type string from default arg",
c = { nested: "object" },
e = { nested: "object" },
//e: wmill.Base64
) {
// let x = await wmill.getVariable('u/user/foo')

View File

@@ -8,7 +8,6 @@
displayDaysAgo,
emptyString,
encodeState,
flowToHubUrl,
sendUserToast
} from '$lib/utils'
import {
@@ -19,7 +18,6 @@
faClipboard,
faCodeFork,
faEdit,
faGlobe,
faList,
faPlay,
faShare,
@@ -249,7 +247,7 @@
{/if}
<div class="flex gap-2 flex-wrap mt-2">
<Button
<!-- <Button
target="_blank"
href={flowToHubUrl(flow).toString()}
variant="border"
@@ -258,7 +256,7 @@
startIcon={{ icon: faGlobe }}
>
Publish to Hub
</Button>
</Button> -->
<Button
on:click={() => shareModal.openDrawer(flow?.path ?? '', 'flow')}
variant="border"

View File

@@ -1,5 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import SavedInputs from '$lib/components/SavedInputs.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { Button, Kbd, Skeleton } from '$lib/components/common'
import { FlowService, JobService, type Flow } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import {
canWrite,
defaultIfEmptyString,
@@ -8,20 +15,19 @@
getModifierKey,
sendUserToast
} from '$lib/utils'
import { FlowService, type Flow, JobService } from '$lib/gen'
import { goto } from '$app/navigation'
import { userStore, workspaceStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { Button, Kbd, Skeleton } from '$lib/components/common'
import { faEye, faPen, faPlay } from '@fortawesome/free-solid-svg-icons'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { tweened } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-svelte'
const path = $page.params.path
let flow: Flow | undefined
let runForm: RunForm | undefined
let isValid = true
let can_write = false
let args: object = {}
async function loadFlow() {
try {
@@ -76,87 +82,125 @@
break
}
}
let savedInputPaneSize = tweened(0, {
duration: 200,
easing: cubicOut
})
</script>
<svelte:window on:keydown={onKeyDown} />
<CenteredPage>
{#if flow}
<div class="flex flex-row flex-wrap justify-between gap-4 mb-6">
<div class="w-full">
<div class="flex flex-col mt-6 mb-2 w-full">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-1"
>
<div class="flex flex-row gap-4">
{#if !$userStore?.operator && can_write}
<div>
<Button
size="sm"
startIcon={{ icon: faPen }}
disabled={flow == undefined}
variant="border"
href="/flows/edit/{flow?.path}">Edit</Button
>
<SplitPanesWrapper class="h-screen">
<Splitpanes class="overflow-hidden">
<Pane class="px-4 flex justify-center" size={100 - $savedInputPaneSize} minSize={50}>
<div class="w-full max-w-4xl flex flex-col">
{#if flow}
<div class="flex flex-row flex-wrap justify-between gap-4 mb-6">
<div class="w-full">
<div class="flex flex-col mt-6 mb-2 w-full">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-1"
>
<div class="flex flex-row gap-4">
<div class="flex flex-row items-center gap-2">
{#if !$userStore?.operator && can_write}
<div>
<Button
size="sm"
startIcon={{ icon: faPen }}
disabled={flow == undefined}
variant="border"
href="/flows/edit/{flow?.path}"
>
Edit
</Button>
</div>
{/if}
<Button
size="sm"
startIcon={{ icon: faEye }}
disabled={flow == undefined}
variant="border"
href="/flows/get/{flow?.path}?workspace_id={$workspaceStore}"
>
Flow
</Button>
<Button
startIcon={{ icon: faPlay }}
disabled={runForm == undefined || !isValid}
on:click={() => runForm?.run()}
>Run <Kbd class="ml-2">{getModifierKey()}+Enter</Kbd></Button
>
</div>
</div>
<div class="flex flex-col">
<h1 class="break-words py-2 mr-2">
{defaultIfEmptyString(flow.summary, flow.path)}
</h1>
{#if !emptyString(flow.summary)}
<h2 class="font-bold pb-4">{flow.path}</h2>
{/if}
</div></div
>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">
{#if flow}
Edited {displayDaysAgo(flow.edited_at || '')} by {flow.edited_by || 'unknown'}
{/if}
</span>
<SharedBadge canWrite={can_write} extraPerms={flow?.extra_perms ?? {}} />
</div>
{/if}
<div class="md:pr-4">
<Button
size="sm"
startIcon={{ icon: faEye }}
disabled={flow == undefined}
btnClasses="mr-4"
variant="border"
href="/flows/get/{flow?.path}?workspace_id={$workspaceStore}">View flow</Button
>
</div>
<div>
<Button
startIcon={{ icon: faPlay }}
disabled={runForm == undefined || !isValid}
on:click={() => runForm?.run()}
>Run <Kbd class="ml-2">{getModifierKey()}+Enter</Kbd></Button
>
</div>
</div>
<div class="flex flex-col">
<h1 class="break-words py-2 mr-2">
{defaultIfEmptyString(flow.summary, flow.path)}
</h1>
{#if !emptyString(flow.summary)}
<h2 class="font-bold pb-4">{flow.path}</h2>
{/if}
</div></div
>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">
{#if flow}
Edited {displayDaysAgo(flow.edited_at || '')} by {flow.edited_by || 'unknown'}
{/if}
</span>
<SharedBadge canWrite={can_write} extraPerms={flow?.extra_perms ?? {}} />
{#if !emptyString(flow.description)}
<div class="prose text-sm box max-w-6xl w-full mt-8">
{defaultIfEmptyString(flow.description, 'No description')}
</div>
{/if}
</div>
</div>
<div class="flex justify-end">
<Button
size="xs"
variant="border"
disabled={flow == undefined}
color="dark"
on:click={() => {
//savedInputPaneSize = savedInputPaneSize == 0 ? 30 : 0
savedInputPaneSize.set($savedInputPaneSize === 0 ? 30 : 0)
}}
>
<div class="flex flex-row gap-2 items-center">
{$savedInputPaneSize === 0 ? 'Open input library' : 'Close input library'}
{#if $savedInputPaneSize === 0}
<ArrowRightIcon class="w-4 h-4" />
{:else}
<ArrowLeftIcon class="w-4 h-4" />
{/if}
</div>
</Button>
</div>
<RunForm
{loading}
autofocus
bind:this={runForm}
bind:isValid
detailed={false}
runnable={flow}
runAction={runFlow}
viewCliRun
isFlow
bind:args
/>
{:else}
<Skeleton layout={[2, [3], 1, [2], 4, [4], 3, [8]]} />
{/if}
</div>
{#if !emptyString(flow.description)}
<div class="prose text-sm box max-w-6xl w-full mt-8">
{defaultIfEmptyString(flow.description, 'No description')}
</div>
{/if}
</div>
<RunForm
{loading}
autofocus
bind:this={runForm}
bind:isValid
detailed={false}
runnable={flow}
runAction={runFlow}
viewCliRun
isFlow
/>
{:else}
<Skeleton layout={[2, [3], 1, [2], 4, [4], 3, [8]]} />
{/if}
</CenteredPage>
</Pane>
<Pane size={$savedInputPaneSize}>
<SavedInputs flowPath={path} {isValid} {args} on:selected_args={(e) => (args = e.detail)} />
</Pane>
</Splitpanes>
</SplitPanesWrapper>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, Kbd, Skeleton } from '$lib/components/common'
import RunForm from '$lib/components/RunForm.svelte'
import SavedInputs from '$lib/components/SavedInputs.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { Alert, Badge, Button, Kbd, Skeleton } from '$lib/components/common'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { JobService, ScriptService, type Script } from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { userStore, workspaceStore } from '$lib/stores'
@@ -19,6 +20,10 @@
truncateHash
} from '$lib/utils'
import { faEye, faPen, faPlay } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { tweened } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-svelte'
$: hash = $page.params.hash
let script: Script | undefined
@@ -26,15 +31,18 @@
let isValid = true
let can_write = false
let topHash: string | undefined
let args: object = {}
async function loadScript() {
if (hash) {
script = await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash })
if (script.schema == undefined) {
script.schema = emptySchema()
await inferArgs(script.language, script.content, script.schema)
script = script
}
if (script.path && script.archived) {
const script_by_path = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
@@ -44,6 +52,7 @@
} else {
topHash = undefined
}
can_write =
script.workspace_id == $workspaceStore &&
canWrite(script.path, script.extra_perms!, $userStore)
@@ -95,121 +104,163 @@
break
}
}
let savedInputPaneSize = tweened(0, {
duration: 200,
easing: cubicOut
})
</script>
<svelte:window on:keydown={onKeyDown} />
<CenteredPage>
{#if script}
<div class="flex flex-col justify-between gap-4 mb-6">
{#if topHash}
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/run/{topHash}">Go to the HEAD of this path</a>
</Alert>
{/if}
<div class="w-full">
<div class="flex flex-col mt-6 mb-2 w-full">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-1 gap-y-2"
>
<div class="flex flex-row gap-4">
{#if !$userStore?.operator && can_write}
<div>
<Button
size="sm"
startIcon={{ icon: faPen }}
disabled={script == undefined}
variant="border"
href="/scripts/edit/{script?.hash}">Edit</Button
>
<SplitPanesWrapper class="h-screen">
<Splitpanes class="overflow-hidden">
<Pane class="px-4 flex justify-center" size={100 - $savedInputPaneSize} minSize={50}>
<div class="w-full max-w-4xl flex flex-col">
{#if script}
<div class="flex flex-col justify-between gap-4 mb-6">
{#if topHash}
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/run/{topHash}">Go to the HEAD of this path</a>
</Alert>
{/if}
<div class="w-full">
<div class="flex flex-col mt-6 mb-2 w-full">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-1 gap-y-2"
>
<div class="flex flex-row gap-4">
{#if !$userStore?.operator && can_write}
<div>
<Button
size="sm"
startIcon={{ icon: faPen }}
disabled={script == undefined}
variant="border"
href="/scripts/edit/{script?.hash}">Edit</Button
>
</div>
{/if}
<div class="md:pr-4">
<Button
size="sm"
startIcon={{ icon: faEye }}
disabled={script == undefined}
variant="border"
href="/scripts/get/{script?.hash}?workspace_id={$workspaceStore}"
>Script</Button
>
</div>
<div>
<Button
startIcon={{ icon: faPlay }}
disabled={runForm == undefined || !isValid}
on:click={() => runForm?.run()}
>
Run <Kbd class="ml-2">{getModifierKey()}+Enter</Kbd>
</Button>
</div>
</div>
<div class="flex flex-col grow">
<h1 class="break-words py-2 mr-2">
{defaultIfEmptyString(script.summary, script.path)}
</h1>
{#if !emptyString(script.summary)}
<h2 class="font-bold pb-4">{script.path}</h2>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">
{#if script}
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by ||
'unknown'}
{/if}
</span>
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
{/if}
<div class="md:pr-4">
<Button
size="sm"
startIcon={{ icon: faEye }}
disabled={script == undefined}
variant="border"
href="/scripts/get/{script?.hash}?workspace_id={$workspaceStore}">View</Button
>
</div>
<div>
<Button
startIcon={{ icon: faPlay }}
disabled={runForm == undefined || !isValid}
on:click={() => runForm?.run()}
>
Run <Kbd class="ml-2">{getModifierKey()}+Enter</Kbd>
</Button>
</div>
</div>
<div class="flex flex-col grow">
<h1 class="break-words py-2 mr-2">
{defaultIfEmptyString(script.summary, script.path)}
</h1>
{#if !emptyString(script.summary)}
<h2 class="font-bold pb-4">{script.path}</h2>
{/if}
{#if !emptyString(script.description)}
<div class="prose text-sm box max-w-6xl w-full mb-4 mt-8">
{defaultIfEmptyString(script.description, 'No description')}
</div>
{/if}
</div>
{#if script?.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Not deployed properly</p>
<p>
This version of this script is unable to be run because because the deployment had
the following errors:
</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre>
</div>
{:else if script && script?.lock == undefined}
<div
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4"
role="alert"
>
<p class="font-bold">Deployment in progress</p>
<p>Refresh this page in a few seconds.</p>
</div>
{:else}
<div class="flex justify-end">
<Button
variant="border"
size="xs"
color="dark"
on:click={() => {
//savedInputPaneSize = savedInputPaneSize == 0 ? 30 : 0
savedInputPaneSize.set($savedInputPaneSize === 0 ? 30 : 0)
}}
>
<div class="flex flex-row gap-2 items-center">
{$savedInputPaneSize === 0 ? 'Open input library' : 'Close input library'}
{#if $savedInputPaneSize === 0}
<ArrowRightIcon class="w-4 h-4" />
{:else}
<ArrowLeftIcon class="w-4 h-4" />
{/if}
</div>
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">
{#if script}
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by || 'unknown'}
{/if}
</span>
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
</div>
<RunForm
{loading}
autofocus
detailed={false}
bind:isValid
bind:this={runForm}
runnable={script}
runAction={runScript}
viewCliRun
isFlow={false}
bind:args
/>
{/if}
{:else}
<Skeleton layout={[2, [3], 1, [2], 4, [4], 3, [8]]} />
{/if}
</div>
{#if !emptyString(script.description)}
<div class="prose text-sm box max-w-6xl w-full mb-4 mt-8">
{defaultIfEmptyString(script.description, 'No description')}
</div>
{/if}
</div>
</Pane>
{#if script?.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Not deployed properly</p>
<p>
This version of this script is unable to be run because because the deployment had the
following errors:
</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre>
</div>
{:else if script && script?.lock == undefined}
<div class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
<p class="font-bold">Deployment in progress</p>
<p>Refresh this page in a few seconds.</p>
</div>
{:else}
<RunForm
{loading}
autofocus
detailed={false}
bind:isValid
bind:this={runForm}
runnable={script}
runAction={runScript}
viewCliRun
isFlow={false}
/>
{/if}
{:else}
<Skeleton layout={[2, [3], 1, [2], 4, [4], 3, [8]]} />
{/if}
</CenteredPage>
<Pane size={$savedInputPaneSize}>
<SavedInputs scriptHash={hash} {isValid} {args} on:selected_args={(e) => (args = e.detail)} />
</Pane>
</Splitpanes>
</SplitPanesWrapper>