Compare commits

...
Author SHA1 Message Date
Ruben Fiszelandrubenfiszel 530a72ba83 chore(main): release 1.499.0 (#5990)
* chore(main): release 1.499.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-19 00:58:56 +02:00
claude[bot]andRuben Fiszel 2a334421e8 fix: prevent keypress events from bubbling in decision tree drawer (#5993)
Fixes #5991

- Add keydown event handlers with stopPropagation() to prevent SvelteFlow 
  from receiving keypress events from text inputs in decision tree config
- Applied at individual input level (summary field) and container level 
  (configuration panel) to catch all input types
- Prevents accidental node deletion when typing in configuration inputs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-06-19 00:34:44 +02:00
Ruben Fiszel c7c2efbbe5 nits 2025-06-18 23:46:21 +02:00
b1c4f8b29d feat: devOps role can edit worker groups (#5984)
* feat: allow devOps role to edit worker groups config

- Updated backend permission checks in configs.rs to use require_devops_role() instead of require_super_admin()
- Updated frontend UI in workers page to show worker group management for devOps users
- Updated WorkerGroup component to allow devOps role access to all configuration features
- Updated AssignableTagsInner component to allow devOps users to manage tags

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* Update configs.rs

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-18 23:06:53 +02:00
Ruben Fiszel 14005fe4c1 nits debug 2025-06-18 22:41:05 +02:00
Ruben Fiszel 4326bb8dc9 improve decision tree preview 2025-06-18 22:33:02 +02:00
centdix 18d12525d2 fix typo (#5982) 2025-06-18 18:29:15 +02:00
Ruben Fiszel 47d1ef0f1c fix additional python paths for windows 2025-06-18 18:24:54 +02:00
86b5fab4dc [Claude PR] Fix incorrect $props generic syntax in Svelte 5 components (#5975)
* fix: correct $props generic syntax in Svelte 5 components

Replace incorrect `$props<T>()` syntax with correct `let x: T = $props()` syntax
to ensure proper TypeScript typing instead of falling back to `any` types.

This affects 11 Svelte 5 components throughout the frontend codebase.

Fixes #5974

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* fix claude pr

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
2025-06-18 18:21:20 +02:00
centdix a58b0ffd06 internal: fix sqlx git action usage (#5980)
* create frontend build dir

* specify version

* add cache
2025-06-18 18:20:07 +02:00
Ruben Fiszel 38a050b0f4 simplify select (#5978)
* improve select

* improve
2025-06-18 17:01:57 +02:00
Ruben Fiszel c088322159 fix input transform reactivity 2025-06-18 16:14:57 +02:00
claude[bot]andRuben Fiszel 079af9292c [Claude PR] Add account_id update support for variables (#5953)
* write sqlx

* all

* update sqlx

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-18 15:25:36 +02:00
centdix 0f560bdc41 internal: fix claude tool usage (#5976)
* fix claude tool usage

* fix
2025-06-18 15:00:42 +02:00
dieriba f97a61ecc8 fix openapi export duplicate issue and nits (#5971)
* nits and fix duplicate issue

* update .sqlx

* nits
2025-06-18 14:28:26 +02:00
Diego Imbert be0c8ddae0 prevent datatable from spamming loadMore on scroll to end (#5973) 2025-06-18 14:25:55 +02:00
centdix 959280bf5a fix wrong typo (#5970) 2025-06-18 13:22:11 +02:00
centdix 38b06bf3a7 internal: add /updatesqlx as git command (#5969)
* add flow to update sqlx

* archive aider

* fix

* add comments
2025-06-18 13:08:59 +02:00
Ruben Fiszelandrubenfiszel f5f2f8f344 chore(main): release 1.498.0 (#5957)
* chore(main): release 1.498.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 22:29:56 +02:00
dieriba aba8c01d7f feat: windmill http triggers and webhooks to openapi spec (#5918)
* add sum and description to http routes

* add to openapi spec

* add subsection

* add collaspable and action button

* added path rendering,

* migrate logic to backend

* remover server and handle different format generation

* add filter for http route and document generated

* remove print

* handled webhook and integrated server component

* done

* add download and copy

* nits

* update .sqlx

* remove .vscode

* fix npm check

* add summary description and fix toggle

* added security handling and nits

* update .sqlx

* nits and rename key

* remove unused code

* update ref

* nits rename var

* nits

* nits

* nits

* nits

* nits

* add token generation for cURL command

* create token

* update label and remove section

* update repo ref

* clean

* let brower start download before cleaning up resource

* handle sync/async for webhook

* format fix

* nits

* nits

* reset sum and description
2025-06-17 19:49:00 +02:00
Diego Imbert 28a0209568 fix gcp trigger bind to undefined (#5967) 2025-06-17 19:47:40 +02:00
Diego Imbert 0cdff8acd1 Fix MQTT undefined binding bug (#5966) 2025-06-17 19:34:28 +02:00
centdix e4534cabf5 ai chat textarea caret wrong positionning (#5964)
* fix textarea caret

* rename style
2025-06-17 18:44:41 +02:00
Diego Imbert 5bdbaf149b Fix unreactive navbar wizard (#5963)
* fix unreactive AppPicker

* migrate StaticInputEditor to svelte 5
2025-06-17 18:42:27 +02:00
Ruben Fiszel 51b3823f7b improve worker tooltip to get hostname, workerGroup 2025-06-17 18:24:30 +02:00
pyranota 74de2397ce docs: add Nix development guide (#5962)
* update readme

* change formatting

* link docker/dev.nu

Signed-off-by: pyranota <pyra@duck.com>

* move stuff a bit

* remove unrelated docs

* remove duplicates

Signed-off-by: pyranota <pyra@duck.com>

* add php to path

Signed-off-by: pyranota <pyra@duck.com>

---------

Signed-off-by: pyranota <pyra@duck.com>
2025-06-17 18:15:30 +02:00
centdix 2d0e65b7ca internal: give database schemas information to Claude (#5961)
* add schema

* give claude the schema

* use summarized schema

* add indexes

* add usage comments

* Remove backend/schema.sql from remote
2025-06-17 17:35:30 +02:00
Diego ImbertandRuben Fiszel 84808e2694 fix conditional tabs reactivity (#5958)
* fix app conditional wrapper reactivity

* Convert GridCondition to Svelte 5

* Revert "fix app conditional wrapper reactivity"

This reverts commit 2b5910fde2.

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-17 11:29:51 +02:00
centdix 8407ac148b nit: fix typo in ai form filling text (#5959)
* differentiate flow from script

* fix typing
2025-06-17 11:24:50 +02:00
centdix 7490e883d7 feat: use provider api to list available AI models in workspace settings (#5947)
* use open router of model lists

* draft

* allow get in ai proxy

* add fetch available models function

* use func

* fix for anthropic

* fix

* fetch on mount

* fix ai settings

* fix

* handle azure
2025-06-17 08:48:47 +00:00
Ruben Fiszelandrubenfiszel ad2de83354 chore(main): release 1.497.2 (#5956)
* chore(main): release 1.497.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 09:16:49 +02:00
Ruben Fiszel 8b7aefb3bc improve tag of new flow dependency job 2025-06-17 09:12:23 +02:00
Ruben Fiszel 0f63d03093 dind compile 2025-06-17 08:14:11 +02:00
Ruben Fiszel 38eb71bdf5 fix: always rm containers in docker mode 2025-06-17 02:32:49 +02:00
Ruben Fiszel 26bec054a3 fix: flow steps use their tags if any specific when used as subflow 2025-06-17 02:27:24 +02:00
Ruben Fiszel d0ebb66d0d nits perf improvements 2025-06-17 02:06:58 +02:00
Ruben Fiszel bc9893402b nit prevent default component list 2025-06-17 02:00:30 +02:00
Ruben Fiszelandrubenfiszel 13ac13e0b7 chore(main): release 1.497.1 (#5955)
* chore(main): release 1.497.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 01:53:14 +02:00
Ruben Fiszel 1c6a7c8cd0 fix: fix mcp server initialization 2025-06-17 01:44:04 +02:00
Ruben Fiszelandrubenfiszel 8e8e1a3129 chore(main): release 1.497.0 (#5912)
* chore(main): release 1.497.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 00:09:22 +02:00
Ruben Fiszel babf046871 select click stop immediate propagation 2025-06-16 21:49:49 +02:00
Ruben Fiszel 02a4949fd8 stabilize input specs key 2025-06-16 21:27:30 +02:00
Ruben Fiszel d2fa2e6464 nit scriptbuilder 2025-06-16 21:10:48 +02:00
Ruben Fiszel d457bf5c80 nit showExpr 2025-06-16 19:03:02 +02:00
Ruben Fiszel 2bc06b72d2 nit showExpr + badge improvements 2025-06-16 18:29:04 +02:00
Diego Imbert b106de5438 fix drawer subgrid not selecting (#5948) 2025-06-16 17:38:35 +02:00
Ruben Fiszel ed6d018253 warn for raw scripts 2025-06-16 15:16:56 +02:00
130 changed files with 5575 additions and 1063 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.43
- uses: astral-sh/setup-uv@v6
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.6.2"
- uses: actions-rust-lang/setup-rust-toolchain@v1
+3 -4
View File
@@ -69,17 +69,16 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
allowed_tools: "mcp__github__create_pull_request,Bash(npm run check),Bash(npm install),Bash(cargo check),Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y)"
allowed_tools: "mcp__github__create_pull_request,Bash(npm:*),Bash(cargo:*)"
custom_instructions: "IMPORTANT INSTRUCTIONS:
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main, with the title starting with [Claude PR].
- If you made changes to the frontend code, run npm install, then npm run generate-backend-client, then npm run check. You can ignore warnings that are reported by the check script, but fix the errors.
- If you made changes to the backend code, install Rust and then run cargo check. You can ignore warnings that are reported by the check script, but fix the errors.
- If you made changes to the backend code, run cargo check. You can ignore warnings that are reported by the check script, but fix the errors.
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
AVAILABLE TOOLS:
- mcp__github__create_pull_request: Create a PR from a branch to main
- Bash(npm run check): Run the check script. You should run this tool after making changes to the frontend code.
- Bash(npm install): Install dependencies. You need this to run npm run check.
- Bash(npm run generate-backend-client): Generate the backend client. You need this to run npm run check.
- Bash(cargo check): Run the cargo check script. You should run this tool after making changes to the backend code.
- Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y): Install Rust. You need this to run cargo check."
- Bash(cargo check): Run the cargo check script. You should run this tool after making changes to the backend code."
trigger_phrase: "/ai"
+105
View File
@@ -0,0 +1,105 @@
name: Update SQLx
on:
issue_comment:
types: [created]
jobs:
update-sqlx:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/updatesqlx')
runs-on: ubicloud-standard-8
permissions:
contents: write
pull-requests: write
issues: write
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: windmill
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Comment on PR - Starting
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Starting sqlx update...'
})
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.issue.pull_request.head.ref }}
fetch-depth: 0
- name: Checkout windmill-ee-private
uses: actions/checkout@v3
with:
repository: windmill-labs/windmill-ee-private
path: windmill-ee-private
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./backend -> target"
- name: Install xmlsec build-time deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxml2-dev libssl-dev \
xmlsec1 libxmlsec1-dev libxmlsec1-openssl
- name: Run update-sqlx script
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/windmill
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
mkdir frontend/build
cd backend
cargo install sqlx-cli --version 0.8.5
sqlx migrate run
./update_sqlx.sh --dir ./windmill-ee-private
# Pass the branch name to the next step
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
- name: Commit changes if any
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/.sqlx
git commit -m "Update SQLx metadata"
git push origin ${{ env.BRANCH_NAME }}
- name: Comment on PR - Completed
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Successfully ran sqlx update'
})
+57
View File
@@ -1,5 +1,62 @@
# Changelog
## [1.499.0](https://github.com/windmill-labs/windmill/compare/v1.498.0...v1.499.0) (2025-06-18)
### Features
* devOps role can edit worker groups ([#5984](https://github.com/windmill-labs/windmill/issues/5984)) ([b1c4f8b](https://github.com/windmill-labs/windmill/commit/b1c4f8b29d0fb4cad76853110b84a87892b54661))
### Bug Fixes
* prevent keypress events from bubbling in decision tree drawer ([#5993](https://github.com/windmill-labs/windmill/issues/5993)) ([2a33442](https://github.com/windmill-labs/windmill/commit/2a334421e85abf046784aab57522582439ef2901))
## [1.498.0](https://github.com/windmill-labs/windmill/compare/v1.497.2...v1.498.0) (2025-06-17)
### Features
* use provider api to list available AI models in workspace settings ([#5947](https://github.com/windmill-labs/windmill/issues/5947)) ([7490e88](https://github.com/windmill-labs/windmill/commit/7490e883d747a7f65b2fefd3ec14b1cfc3d9bbd4))
* windmill http triggers and webhooks to openapi spec ([#5918](https://github.com/windmill-labs/windmill/issues/5918)) ([aba8c01](https://github.com/windmill-labs/windmill/commit/aba8c01d7f44ba4be369a3c711be9e156d6bf215))
## [1.497.2](https://github.com/windmill-labs/windmill/compare/v1.497.1...v1.497.2) (2025-06-17)
### Bug Fixes
* always rm containers in docker mode ([38eb71b](https://github.com/windmill-labs/windmill/commit/38eb71bdf55ee2f606d1d2ad2e987d5af16d88c0))
* flow steps use their tags if any specific when used as subflow ([26bec05](https://github.com/windmill-labs/windmill/commit/26bec054a3447a91c5d5f56d8b98717c06496087))
## [1.497.1](https://github.com/windmill-labs/windmill/compare/v1.497.0...v1.497.1) (2025-06-16)
### Bug Fixes
* fix mcp server initialization ([1c6a7c8](https://github.com/windmill-labs/windmill/commit/1c6a7c8cd0bd8396f158e3cb0583b927ce957f12))
## [1.497.0](https://github.com/windmill-labs/windmill/compare/v1.496.3...v1.497.0) (2025-06-16)
### Features
* add api tools to ai chat ([#5921](https://github.com/windmill-labs/windmill/issues/5921)) ([f7a83c0](https://github.com/windmill-labs/windmill/commit/f7a83c03c12b8ae70179fb228e0e2391b6ea2858))
* **backend:** use streamable http in favor of sse for MCP ([#5910](https://github.com/windmill-labs/windmill/issues/5910)) ([d47c078](https://github.com/windmill-labs/windmill/commit/d47c078bb5ab86d82d9cbbce3c55c89c0c20d809))
* better graph layout algorithm + migrate to svelte 5 almost everywhere + xyflow 1.0 ([23920ae](https://github.com/windmill-labs/windmill/commit/23920aee84fdca4a557a34ff2d66a0bb7bdca605))
* fill runnable inputs with AI chat ([#5887](https://github.com/windmill-labs/windmill/issues/5887)) ([b4a6a7e](https://github.com/windmill-labs/windmill/commit/b4a6a7e72429617d420af85a9de35bb13adfc6fb))
* **go:** local go.mod ([#5929](https://github.com/windmill-labs/windmill/issues/5929)) ([0b89260](https://github.com/windmill-labs/windmill/commit/0b89260540b307c6d614ca4275dd038fbfdac33c))
* multiple azure models support ([#5920](https://github.com/windmill-labs/windmill/issues/5920)) ([f412ede](https://github.com/windmill-labs/windmill/commit/f412ede6ed48e9a492f39582ac70a5584477529e))
* **rust:** add rust sdk ([#5909](https://github.com/windmill-labs/windmill/issues/5909)) ([332f66e](https://github.com/windmill-labs/windmill/commit/332f66e3483abbeacd4e7c1b74c94c5265314882))
### Bug Fixes
* ai chat tooltip + user settings autocomplete issue ([#5917](https://github.com/windmill-labs/windmill/issues/5917)) ([6f907c7](https://github.com/windmill-labs/windmill/commit/6f907c79b4cf6279bd52e35a3ee96e0d021422f5))
* audit logs for token refresh + consider refresh for active users ([#5930](https://github.com/windmill-labs/windmill/issues/5930)) ([cf2d09e](https://github.com/windmill-labs/windmill/commit/cf2d09e7a8c5d2472af0d483689c3fcfa2976117))
* fix input with wrong height on first render ([#5935](https://github.com/windmill-labs/windmill/issues/5935)) ([1a6283b](https://github.com/windmill-labs/windmill/commit/1a6283b42a6a514ab2e05160855cdc0f70b61d0e))
* flow step missing input warnings ([#5916](https://github.com/windmill-labs/windmill/issues/5916)) ([f077849](https://github.com/windmill-labs/windmill/commit/f077849b8f7c1916fd420e85b4844a5c5e93a139))
* **frontend:** use correct kind for flow insert module btn ([#5938](https://github.com/windmill-labs/windmill/issues/5938)) ([17c8c8a](https://github.com/windmill-labs/windmill/commit/17c8c8a5616ab8656799cea3fc5bc7cfaedc4995))
## [1.496.3](https://github.com/windmill-labs/windmill/compare/v1.496.2...v1.496.3) (2025-06-09)
+1 -1
View File
@@ -1,3 +1,3 @@
To have an overview of what this app does, see @.cursor/rules/windmill-overview.mdc
For backend modifications, follow the rules mentioned here @.cursor/rules/rust-best-practices.mdc
For backend modifications, follow the rules mentioned here @.cursor/rules/rust-best-practices.mdc. You also have access to a summarized version of the database schema here @backend/summarized_schema.txt
For frontend modifications, follow the rules mentioned here @.cursor/rules/svelte5-best-practices.mdc
+2 -1
View File
@@ -367,10 +367,11 @@ you to have it being synced automatically everyday.
## Run a local dev setup
Using [Nix](./frontend/README_DEV.md#nix) (Recommended).
See the [./frontend/README_DEV.md](./frontend/README_DEV.md) file for all
running options.
Using [Nix](./frontend/README_DEV.md#nix).
### only Frontend
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n flow\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "33367c42e87e78ae987c0966dc4d445c5eff75b2e2843ffd7a46b03cbaea9ae8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n summary,\n description,\n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"describe": {
"columns": [
{
@@ -35,11 +35,21 @@
},
{
"ordinal": 6,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 7,
"ordinal": 9,
"name": "http_method: _",
"type_info": {
"Custom": {
@@ -57,32 +67,32 @@
}
},
{
"ordinal": 8,
"ordinal": 10,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 9,
"ordinal": 11,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "authentication_method: _",
"type_info": {
"Custom": {
@@ -101,27 +111,27 @@
}
},
{
"ordinal": 14,
"ordinal": 16,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"ordinal": 17,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 16,
"ordinal": 18,
"name": "authentication_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 17,
"ordinal": 19,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 20,
"name": "raw_string",
"type_info": "Bool"
}
@@ -139,6 +149,8 @@
false,
false,
false,
true,
true,
false,
false,
false,
@@ -154,5 +166,5 @@
false
]
},
"hash": "144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db"
"hash": "39401cb0db8d367b5beb2be0c13aa7595adae0eac4e4e3a888cb12b972d1a7ce"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n edited_at = now(), \n is_static_website = $16\n WHERE \n workspace_id = $17 AND \n path = $18\n ",
"query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n summary = $16,\n description = $17,\n edited_at = now(), \n is_static_website = $18\n WHERE \n workspace_id = $19 AND \n path = $20\n ",
"describe": {
"columns": [],
"parameters": {
@@ -47,6 +47,8 @@
}
}
},
"Varchar",
"Text",
"Bool",
"Text",
"Text"
@@ -54,5 +56,5 @@
},
"nullable": []
},
"hash": "187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b"
"hash": "3f05e6186050a7ce6d8efb41067d3c5282319fe7e041f114e02fb22b91716637"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n summary,\n description,\n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,31 +45,41 @@
},
{
"ordinal": 8,
"name": "edited_by",
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "edited_at",
"type_info": "Timestamptz"
"name": "description",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "email",
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 12,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "authentication_method: _",
"type_info": {
"Custom": {
@@ -88,7 +98,7 @@
}
},
{
"ordinal": 14,
"ordinal": 16,
"name": "http_method: _",
"type_info": {
"Custom": {
@@ -106,22 +116,22 @@
}
},
{
"ordinal": 15,
"ordinal": 17,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"ordinal": 18,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 17,
"ordinal": 19,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 20,
"name": "raw_string",
"type_info": "Bool"
}
@@ -140,6 +150,8 @@
true,
false,
false,
true,
true,
false,
false,
false,
@@ -153,5 +165,5 @@
false
]
},
"hash": "56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364"
"hash": "4228b098883408323bd8413ee094454b95962047458a6927d19ac0d3e7b3f0fa"
}
@@ -0,0 +1,93 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n route_path,\n http_method AS \"http_method: _\",\n is_async,\n workspaced_route,\n summary,\n description,\n authentication_method AS \"authentication_method: _\",\n authentication_resource_path\n FROM\n http_trigger\n WHERE\n path ~ ANY($1) AND\n route_path ~ ANY($2) AND\n workspace_id = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 2,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspaced_route",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "authentication_method: _",
"type_info": {
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
}
},
{
"ordinal": 7,
"name": "authentication_resource_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "714fb0f66ceb536aee8cb9ae0144757b999d25870fda37fe904e09dd5c742015"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
null
]
},
"hash": "7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "8373b2649ab46310860adbdd7b717261771ac61d46d82d42d085ffebeb18be06"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n script\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "dc36b46b9eb80cb7c92fa72519d117eda99a6f482a073ccd36a6431ef689a3fd"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17\n )\n ",
"query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n summary,\n description,\n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -14,6 +14,8 @@
"Bool",
"Bool",
"Varchar",
"Varchar",
"Text",
"Bool",
"Bool",
{
@@ -53,5 +55,5 @@
},
"nullable": []
},
"hash": "8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12"
"hash": "ed99d4d088d0fd0c01f29803b12e99ae0a53d0b1feaa67737da409c51c1b6751"
}
+301 -265
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.496.3"
version = "1.499.0"
authors.workspace = true
edition.workspace = true
@@ -32,7 +32,7 @@ members = [
]
[workspace.package]
version = "1.496.3"
version = "1.499.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -130,6 +130,7 @@ prometheus = { workspace = true, optional = true }
uuid.workspace = true
gethostname.workspace = true
serde_json.workspace = true
serde_yml.workspace = true
serde.workspace = true
deno_core = { workspace = true, optional = true }
object_store = { workspace = true, optional = true }
@@ -201,6 +202,7 @@ tower-http = { version = "^0.6", features = ["trace", "cors"] }
tower-cookies = "^0.10"
serde = "^1"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4"] }
thiserror = "^2"
anyhow = "^1"
@@ -230,7 +232,7 @@ php-parser-rs = { git = "https://github.com/php-rust-tools/parser", rev = "ec4cb
cron = "^0"
mail-send = { version = "0.4.0", features = ["builder"], default-features=false }
urlencoding = "^2"
url = "^2"
url = { version = "^2" , features = ["serde"]}
async-oauth2 = "^0"
reqwest = { version = "^0.12", features = ["json", "stream", "gzip"] }
time = "^0"
+1 -1
View File
@@ -1 +1 @@
2c3e21f4573486628e0b8969ff478c237bd0283f
67e727c618cf673850a0887931c803241abfcfe8
@@ -4,4 +4,4 @@ DROP TYPE http_method;
ALTER TABLE script DROP COLUMN has_preprocessor;
DROP FUNCTION prevent_route_path_change();
DROP FUNCTION prevent_route_path_change();
@@ -0,0 +1,4 @@
-- Add down migration script here
ALTER TABLE http_trigger
DROP COLUMN summary,
DROP COLUMN description;
@@ -0,0 +1,5 @@
-- Add up migration script here
ALTER TABLE http_trigger
ADD COLUMN summary VARCHAR(512) NULL,
ADD COLUMN description TEXT NULL;
+154
View File
@@ -0,0 +1,154 @@
# This script is used to summarize the database schema.
# You can use pg_dump to dump the schema to a file.
# pg_dump --file "schema.sql" --host "localhost" --port "5432" --username "postgres" --no-password --format=c --large-objects --schema-only --no-owner --no-privileges --no-tablespaces --no-unlogged-table-data --no-comments --no-publications --no-subscriptions --no-security-labels --no-toast-compression --no-table-access-method --verbose --schema "public" "windmill"
# Then you can run python summarize_schema.py schema.sql to get the summarized schema.
import re
import sys
from collections import defaultdict
def summarize_schema(file_path):
"""
Parses a PostgreSQL dump file and extracts a summarized schema.
"""
tables = defaultdict(lambda: {'columns': [], 'pks': set(), 'fks': [], 'indexes': []})
enums = defaultdict(list)
# Use state variables to parse multi-line definitions
current_table = None
current_enum = None
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# --- State Resets ---
if line.startswith(');'):
current_table = None
current_enum = None
continue
# --- Parse ENUM definitions ---
match_enum = re.match(r"CREATE TYPE public\.(\w+) AS ENUM \($", line)
if match_enum:
current_enum = match_enum.group(1)
continue
if current_enum:
# Extract enum values, which are typically like 'value',
value = line.strip("',")
if value and not value.startswith('--'):
enums[current_enum].append(value)
continue
# --- Parse TABLE definitions ---
match_table = re.match(r"CREATE TABLE public\.(\w+) \($", line)
if match_table:
current_table = match_table.group(1)
continue
if current_table:
# Parse columns within a CREATE TABLE block
# e.g., "column_name type NOT NULL,"
# e.g., "id bigint NOT NULL,"
match_column = re.match(r'^"?(\w+)"?\s+([\w\d\.\[\]\(\)]+)', line)
if match_column:
col_name = match_column.group(1)
col_type = match_column.group(2)
tables[current_table]['columns'].append(f"{col_name} ({col_type})")
# Parse PRIMARY KEY defined inside the table
match_pk = re.search(r"CONSTRAINT \w+ PRIMARY KEY \((.+)\)", line)
if match_pk:
# Handle multiple PK columns: "col1, col2, col3"
pk_cols = [p.strip().strip('"') for p in match_pk.group(1).split(',')]
tables[current_table]['pks'].update(pk_cols)
continue
# --- Parse Foreign Keys (defined outside CREATE TABLE) ---
match_fk = re.match(r"ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \w+ FOREIGN KEY \(([\w,\s\"]+)\) REFERENCES public\.(\w+)\(([\w,\s\"]+)\);", line)
if match_fk:
from_table, from_cols, to_table, to_cols = match_fk.groups()
# Clean up column names
from_cols_clean = ', '.join([c.strip().strip('"') for c in from_cols.split(',')])
to_cols_clean = ', '.join([c.strip().strip('"') for c in to_cols.split(',')])
fk_string = f"({from_cols_clean}) -> {to_table}({to_cols_clean})"
tables[from_table]['fks'].append(fk_string)
# --- Parse Index definitions ---
match_index = re.match(r"CREATE (UNIQUE )?INDEX (\w+) ON public\.(\w+) USING (\w+) \((.+)\);", line)
if match_index:
is_unique = match_index.group(1) is not None
index_name = match_index.group(2)
table_name = match_index.group(3)
index_type = match_index.group(4)
columns = match_index.group(5)
# Clean up column expressions
columns_clean = columns.replace('"', '')
unique_str = "UNIQUE " if is_unique else ""
index_string = f"{unique_str}INDEX {index_name} ({index_type}) ON ({columns_clean})"
tables[table_name]['indexes'].append(index_string)
return enums, tables
def format_output(enums, tables):
"""
Formats the parsed schema data into a clean, readable string.
"""
output = []
output.append("### Simplified Database Schema ###")
output.append("\n--- Custom Data Types (ENUMs) ---\n")
if not enums:
output.append("No custom ENUM types found.")
else:
for name, values in sorted(enums.items()):
output.append(f"{name}:")
for v in values:
output.append(f" - {v}")
output.append("")
output.append("\n--- Tables and Relationships ---\n")
if not tables:
output.append("No tables found.")
else:
for name, data in sorted(tables.items()):
output.append(f"TABLE: {name}")
for col in data['columns']:
col_name = col.split(' ')[0]
marker = " (PK)" if col_name in data['pks'] else ""
output.append(f" - {col}{marker}")
if data['fks']:
output.append(" Relationships:")
for fk in data['fks']:
output.append(f" - {fk}")
if data['indexes']:
output.append(" Indexes:")
for idx in data['indexes']:
output.append(f" - {idx}")
output.append("-" * 20)
return "\n".join(output)
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <path_to_dump.sql>")
sys.exit(1)
input_file = sys.argv[1]
try:
enums_data, tables_data = summarize_schema(input_file)
formatted_summary = format_output(enums_data, tables_data)
print(formatted_summary)
except FileNotFoundError:
print(f"Error: The file '{input_file}' was not found.")
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred: {e}")
sys.exit(1)
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,4 +1,18 @@
./substitute_ee_code.sh --dir ../windmill-ee-private
#!/bin/bash
# Default directory
EE_DIR="../windmill-ee-private"
# Parse arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--dir) EE_DIR="$2"; shift ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
shift
done
./substitute_ee_code.sh --dir "$EE_DIR"
# Check if running on macOS
if [[ "$(uname)" == "Darwin" ]]; then
@@ -18,4 +32,4 @@ if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' 's/^#samael = { version="0.0.14", features = \["xmlsec"\] }/samael = { version="0.0.14", features = ["xmlsec"] }/' Cargo.toml
# Comment out the git-based samael dependency
sed -i '' 's/^\(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/# \1/' Cargo.toml
fi
fi
+4 -2
View File
@@ -17,7 +17,7 @@ agent_worker_server = []
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["dep:tinyvector", "dep:hf-hub", "dep:tokenizers", "dep:candle-core", "dep:candle-transformers", "dep:candle-nn"]
parquet = ["dep:datafusion", "dep:object_store", "dep:url", "windmill-common/parquet", "windmill-worker/parquet"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
tantivy = ["dep:windmill-indexer"]
@@ -76,6 +76,7 @@ hex.workspace = true
base64.workspace = true
base32.workspace = true
serde_urlencoded.workspace = true
serde_yml.workspace = true
cron.workspace = true
mime_guess.workspace = true
rust-embed = { workspace = true, optional = true }
@@ -102,6 +103,7 @@ prometheus = { workspace = true, optional = true }
async_zip = { workspace = true, optional = true }
regex.workspace = true
bytes.workspace = true
url.workspace = true
samael = { workspace = true, optional = true }
libxml = { workspace = true, optional = true }
async-recursion.workspace = true
@@ -116,7 +118,6 @@ candle-nn = { workspace = true, optional = true}
datafusion = { workspace = true, optional = true}
object_store = { workspace = true, optional = true}
openidconnect = { workspace = true, optional = true}
url = { workspace = true, optional = true}
jsonwebtoken = { workspace = true }
matchit = { workspace = true, optional = true }
tokio-tungstenite = { workspace = true, optional = true}
@@ -126,6 +127,7 @@ nkeys = { workspace = true, optional = true }
const_format.workspace = true
pin-project.workspace = true
http.workspace = true
indexmap.workspace = true
async-stream.workspace = true
ulid.workspace = true
rust-postgres = { workspace = true, optional = true }
+158 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.496.3
version: 1.499.0
title: Windmill API
contact:
@@ -8451,6 +8451,51 @@ paths:
"201":
description: default error handler set
/w/{workspace}/openapi/generate:
post:
summary: generate openapi spec from http routes/webhook
operationId: generateOpenapiSpec
tags:
- openapi
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: openapi spec info and url
content:
application/json:
schema:
$ref: "#/components/schemas/GenerateOpenapiSpec"
responses:
"200":
description: openapi spec
content:
text/plain:
schema:
type: string
/w/{workspace}/openapi/download:
post:
summary: Download the OpenAPI v3.1 spec as a file
operationId: DownloadOpenapiSpec
tags:
- openapi
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: openapi spec info and url
content:
application/json:
schema:
$ref: "#/components/schemas/GenerateOpenapiSpec"
responses:
"200":
description: Downloaded OpenAPI spec
content:
application/octet-stream:
schema:
type: string
format: binary
/w/{workspace}/http_triggers/create_many:
post:
summary: create many HTTP triggers
@@ -14754,6 +14799,106 @@ components:
- custom_script
- signature
RunnableKind:
type: string
enum:
- script
- flow
OpenapiSpecFormat:
type: string
enum:
- yaml
- json
OpenapiHttpRouteFilters:
type: object
properties:
folder_regex:
type: string
path_regex:
type: string
route_path_regex:
type: string
required:
- folder_regex
- path_regex
- route_path_regex
WebhookFilters:
type: object
properties:
user_or_folder_regex:
type: string
enum:
- "*"
- u
- f
user_or_folder_regex_value:
type: string
path:
type: string
runnable_kind:
$ref: "#/components/schemas/RunnableKind"
required:
- user_or_folder_regex
- user_or_folder_regex_value
- path
- runnable_kind
OpenapiV3Info:
type: object
properties:
title:
type: string
version:
type: string
description:
type: string
terms_of_service:
type: string
contact:
type: object
properties:
name:
type: string
url:
type: string
email:
type: string
license:
type: object
properties:
name:
type: string
identifier:
type: string
url:
type: string
required:
- name
required:
- title
- version
GenerateOpenapiSpec:
type: object
properties:
info:
$ref: "#/components/schemas/OpenapiV3Info"
url:
type: string
openapi_spec_format:
$ref: "#/components/schemas/OpenapiSpecFormat"
http_route_filters:
type: array
items:
$ref: "#/components/schemas/OpenapiHttpRouteFilters"
webhook_filters:
type: array
items:
$ref: "#/components/schemas/WebhookFilters"
HttpMethod:
type: string
enum:
@@ -14785,6 +14930,10 @@ components:
$ref: "#/components/schemas/HttpMethod"
authentication_resource_path:
type: string
summary:
type: string
description:
type: string
is_async:
type: boolean
authentication_method:
@@ -14819,6 +14968,10 @@ components:
type: string
workspaced_route:
type: boolean
summary:
type: string
description:
type: string
static_asset_config:
type: object
properties:
@@ -14866,6 +15019,10 @@ components:
type: string
route_path:
type: string
summary:
type: string
description:
type: string
workspaced_route:
type: boolean
static_asset_config:
+26 -11
View File
@@ -4,7 +4,7 @@ use crate::{
};
use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router};
use http::HeaderMap;
use http::{HeaderMap, Method};
use quick_cache::sync::Cache;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
@@ -141,6 +141,8 @@ impl AIRequestConfig {
self,
provider: &AIProvider,
path: &str,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> Result<RequestBuilder> {
let body = if let Some(user) = self.user {
@@ -153,8 +155,9 @@ impl AIRequestConfig {
let is_azure = matches!(provider, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL
|| matches!(provider, AIProvider::AzureOpenAI);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let url = if is_azure {
let url = if is_azure && method != Method::GET {
if base_url.ends_with("/deployments") {
let model = Self::get_azure_model(&body)?;
format!("{}/{}/{}", base_url, model, path)
@@ -171,9 +174,16 @@ impl AIRequestConfig {
tracing::debug!("AI request URL: {}", url);
let mut request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.body(body);
.request(method, url)
.header("content-type", "application/json");
for (header_name, header_value) in headers.iter() {
if header_name.to_string().starts_with("anthropic-") {
request = request.header(header_name, header_value);
}
}
request = request.body(body);
if is_azure {
request = request.query(&[("api-version", AZURE_API_VERSION)])
@@ -181,9 +191,12 @@ impl AIRequestConfig {
if let Some(api_key) = self.api_key {
if is_azure {
request = request.header("api-key", api_key)
request = request.header("api-key", api_key.clone())
} else {
request = request.header("authorization", format!("Bearer {}", api_key))
request = request.header("authorization", format!("Bearer {}", api_key.clone()))
}
if is_anthropic {
request = request.header("X-API-Key", api_key);
}
}
@@ -339,17 +352,18 @@ pub struct AIConfig {
}
pub fn global_service() -> Router {
Router::new().route("/proxy/*ai", post(global_proxy))
Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy))
}
pub fn workspaced_service() -> Router {
Router::new().route("/proxy/*ai", post(proxy))
Router::new().route("/proxy/*ai", post(proxy).get(proxy))
}
async fn global_proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(ai_path): Path<String>,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
@@ -374,7 +388,7 @@ async fn global_proxy(
let url = format!("{}/{}", base_url, ai_path);
let request = HTTP_CLIENT
.post(url)
.request(method, url)
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.body(body);
@@ -410,6 +424,7 @@ async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, ai_path)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
@@ -492,7 +507,7 @@ async fn proxy(
}
};
let request = request_config.prepare_request(&provider, &ai_path, body)?;
let request = request_config.prepare_request(&provider, &ai_path, method, headers, body)?;
let response = request.send().await.map_err(to_anyhow)?;
+2 -2
View File
@@ -12,7 +12,7 @@ use crate::{
db::{ApiAuthed, DB},
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
utils::{RunnableKind, WithStarredInfoQuery},
utils::WithStarredInfoQuery,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
@@ -59,7 +59,7 @@ use windmill_common::{
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
Pagination, StripPath,
Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
+75 -76
View File
@@ -34,10 +34,11 @@ lazy_static::lazy_static! {
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {
// Remove all cache entries for this token (across all workspaces)
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| {
cached_token != token
});
tracing::info!("Invalidated token from auth cache: {}...", &token[..token.len().min(8)]);
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token);
tracing::info!(
"Invalidated token from auth cache: {}...",
&token[..token.len().min(8)]
);
}
#[derive(Clone)]
@@ -482,6 +483,27 @@ where
}
}
fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
if path_vec.len() >= 5 && path_vec[0] == "" && path_vec[2] == "srch" && path_vec[3] == "w" {
Some(path_vec[4].to_owned())
} else {
None
}
};
workspace_id
}
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
@@ -494,85 +516,62 @@ where
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(ApiAuthed {
email: "".to_owned(),
username: "".to_owned(),
is_admin: false,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
});
return Ok(ApiAuthed::default());
};
let already_authed = parts.extensions.get::<ApiAuthed>();
if let Some(authed) = already_authed {
Ok(authed.clone())
} else {
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_string())
} else {
if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[2] == "srch"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_string())
} else {
None
}
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
parts.extensions.insert(authed.clone());
if authed.scopes.as_ref().is_some_and(|scopes| {
scopes
.iter()
.any(|s| s.starts_with("jobs:") || s.starts_with("run:"))
}) && (path_vec.len() < 3
|| (path_vec[4] != "jobs" && path_vec[4] != "jobs_u"))
{
BRUTE_FORCE_COUNTER.increment().await;
return Err((
StatusCode::UNAUTHORIZED,
format!("Unauthorized scoped token: {:?}", authed.scopes),
));
}
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(authed);
if let Some(authed) = already_authed {
return Ok(authed.clone());
}
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = maybe_get_workspace_id_from_path(&path_vec);
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
if authed.scopes.as_ref().is_some_and(|scopes| {
scopes
.iter()
.any(|s| s.starts_with("jobs:") || s.starts_with("run:"))
}) && (path_vec.len() < 3
|| (path_vec[4] != "jobs" && path_vec[4] != "jobs_u"))
{
BRUTE_FORCE_COUNTER.increment().await;
return Err((
StatusCode::UNAUTHORIZED,
format!("Unauthorized scoped token: {:?}", authed.scopes),
));
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(authed);
}
}
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
+1 -2
View File
@@ -64,7 +64,6 @@ use crate::{
args::RawWebhookArgs,
db::{ApiAuthed, DB},
users::fetch_api_authed,
utils::RunnableKind,
};
use axum::{
@@ -82,7 +81,7 @@ use windmill_common::{
db::UserDB,
error::{JsonResult, Result},
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
utils::{not_found_if_none, paginate, Pagination, StripPath},
utils::{not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
worker::{to_raw_value, CLOUD_HOSTED},
};
+5 -5
View File
@@ -21,7 +21,7 @@ use windmill_common::{
DB,
};
use crate::{db::ApiAuthed, utils::require_super_admin};
use crate::{db::ApiAuthed, utils::{require_devops_role}};
pub fn global_service() -> Router {
Router::new()
@@ -103,7 +103,7 @@ async fn get_config(
Path(name): Path<String>,
Extension(db): Extension<DB>,
) -> error::JsonResult<Option<serde_json::Value>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let config = sqlx::query_as!(Config, "SELECT * FROM config WHERE name = $1", name)
.fetch_optional(&db)
@@ -119,7 +119,7 @@ async fn update_config(
authed: ApiAuthed,
Json(config): Json<serde_json::Value>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
#[cfg(not(feature = "enterprise"))]
if name.starts_with("worker__") {
@@ -157,7 +157,7 @@ async fn delete_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let mut tx = db.begin().await?;
@@ -232,7 +232,7 @@ async fn list_configs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<Config>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let configs = sqlx::query_as!(Config, "SELECT name, config FROM config")
.fetch_all(&db)
.await?;
+1 -1
View File
@@ -815,7 +815,7 @@ async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
Ok(())
}
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
pub struct ApiAuthed {
pub email: String,
pub username: String,
+3 -3
View File
@@ -12,7 +12,7 @@ use crate::db::ApiAuthed;
use crate::triggers::{
get_triggers_count_internal, list_tokens_internal, TriggersCount, TruncatedTokenWithEmail,
};
use crate::utils::{RunnableKind, WithStarredInfoQuery};
use crate::utils::WithStarredInfoQuery;
use crate::{
db::DB,
schedule::clear_schedule,
@@ -43,7 +43,7 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
scripts::Schema,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, StripPath},
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, schedule::push_scheduled_job, PushIsolationLevel};
@@ -477,7 +477,7 @@ async fn create_flow(
false,
None,
true,
nf.tag,
None,
None,
None,
None,
@@ -441,8 +441,8 @@ pub struct BasicAuthAuthentication {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiKeyAuthentication {
api_key_header: String,
api_key_secret: String,
pub api_key_header: String,
pub api_key_secret: String,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)]
+32 -9
View File
@@ -44,7 +44,7 @@ use windmill_common::{
error::{self, JsonResult},
s3_helpers::S3Object,
triggers::TriggerKind,
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
utils::{empty_as_none, not_found_if_none, paginate, require_admin, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
use windmill_git_sync::handle_deployment_metadata;
@@ -114,6 +114,8 @@ struct NewTrigger {
static_asset_config: Option<sqlx::types::Json<S3Object>>,
http_method: HttpMethod,
workspaced_route: Option<bool>,
summary: Option<String>,
description: Option<String>,
is_static_website: bool,
wrap_body: Option<bool>,
raw_string: Option<bool>,
@@ -134,6 +136,8 @@ pub struct HttpTrigger {
pub is_async: bool,
pub authentication_method: AuthenticationMethod,
pub http_method: HttpMethod,
pub summary: Option<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub static_asset_config: Option<sqlx::types::Json<S3Object>>,
pub is_static_website: bool,
@@ -153,6 +157,8 @@ struct EditTrigger {
authentication_method: AuthenticationMethod,
#[serde(deserialize_with = "non_empty_str")]
authentication_resource_path: Option<String>,
summary: Option<String>,
description: Option<String>,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
workspaced_route: Option<bool>,
@@ -167,6 +173,7 @@ pub struct ListTriggerQuery {
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
#[serde(default, deserialize_with = "empty_as_none")]
pub path_start: Option<String>,
}
@@ -188,6 +195,8 @@ async fn list_triggers(
"wrap_body",
"raw_string",
"script_path",
"summary",
"description",
"is_flow",
"http_method",
"edited_by",
@@ -242,6 +251,8 @@ async fn get_trigger(
route_path_key,
workspaced_route,
script_path,
summary,
description,
is_flow,
http_method as "http_method: _",
edited_by,
@@ -317,6 +328,8 @@ async fn create_trigger_inner(
wrap_body,
raw_string,
script_path,
summary,
description,
is_flow,
is_async,
authentication_method,
@@ -328,7 +341,7 @@ async fn create_trigger_inner(
is_static_website
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19
)
"#,
w_id,
@@ -340,6 +353,8 @@ async fn create_trigger_inner(
new_http_trigger.wrap_body.unwrap_or(false),
new_http_trigger.raw_string.unwrap_or(false),
new_http_trigger.script_path,
new_http_trigger.summary,
new_http_trigger.description,
new_http_trigger.is_flow,
new_http_trigger.is_async,
new_http_trigger.authentication_method as _,
@@ -375,7 +390,11 @@ fn check_no_duplicates<'trigger>(
let mut seen = HashSet::with_capacity(new_http_triggers.len());
for (i, trigger) in new_http_triggers.iter().enumerate() {
if !seen.insert((&route_path_key[i], trigger.http_method, trigger.workspaced_route)) {
if !seen.insert((
&route_path_key[i],
trigger.http_method,
trigger.workspaced_route,
)) {
return Err(Error::BadRequest(format!(
"Duplicate HTTP route detected: '{}'. Each HTTP route must have a unique 'route_path'.",
&trigger.route_path
@@ -594,11 +613,13 @@ async fn update_trigger(
email = $13,
is_async = $14,
authentication_method = $15,
summary = $16,
description = $17,
edited_at = now(),
is_static_website = $16
is_static_website = $18
WHERE
workspace_id = $17 AND
path = $18
workspace_id = $19 AND
path = $20
"#,
route_path,
&route_path_key,
@@ -615,6 +636,8 @@ async fn update_trigger(
&authed.email,
ct.is_async,
ct.authentication_method as _,
ct.summary,
ct.description,
ct.is_static_website,
w_id,
path,
@@ -1147,8 +1170,8 @@ async fn route_job(
.map_err(|e| e.into_response())?;
if trigger.script_path.is_empty() && trigger.static_asset_config.is_none() {
return Err(Error::BadRequest(format!(
"Script path of HTTP route at path: {} must not be empty",
return Err(Error::NotFound(format!(
"Runnable path of HTTP route at path: {}",
trigger.path
))
.into_response());
@@ -1198,7 +1221,7 @@ async fn route_job(
let auth_method = try_get_resource_from_db_as::<
crate::http_trigger_auth::AuthenticationMethod,
>(
authed.clone(),
&authed,
Some(user_db.clone()),
&db,
&resource_path,
+3
View File
@@ -103,6 +103,8 @@ mod integration;
#[cfg(feature = "postgres_trigger")]
mod postgres_triggers;
pub mod openapi;
mod approvals;
#[cfg(all(feature = "enterprise", feature = "private"))]
pub mod apps_ee;
@@ -595,6 +597,7 @@ pub async fn run_server(
.nest("/variables", variables::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/oidc", oidc_oss::workspaced_service())
.nest("/openapi", openapi::openapi_service())
.nest("/http_triggers", http_triggers_service)
.nest("/websocket_triggers", websocket_triggers_service)
.nest("/kafka_triggers", kafka_triggers_service)
+5 -1
View File
@@ -1177,7 +1177,11 @@ pub async fn extract_and_store_workspace_id(
pub async fn setup_mcp_server() -> anyhow::Result<(Router, Arc<LocalSessionManager>)> {
let session_manager = Arc::new(LocalSessionManager::default());
let service_config = Default::default();
let service = StreamableHttpService::new(Runner::new, session_manager.clone(), service_config);
let service = StreamableHttpService::new(
|| Ok(Runner::new()),
session_manager.clone(),
service_config,
);
let router = axum::Router::new().nest_service("/", service);
Ok((router, session_manager))
+2 -2
View File
@@ -478,7 +478,7 @@ pub async fn test_mqtt_connection(
test_postgres;
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
&authed,
Some(user_db),
&db,
&mqtt_resource_path,
@@ -1253,7 +1253,7 @@ impl MqttConfig {
}
}
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
&authed,
Some(UserDB::new(db.clone())),
db,
mqtt_resource_path,
+982
View File
@@ -0,0 +1,982 @@
use std::{
collections::{HashMap, HashSet},
fmt::Display,
};
use anyhow::anyhow;
use axum::{
body::Body, extract::Path, http, response::Response, routing::post, Extension, Json, Router,
};
use http::{header, HeaderValue, Method, StatusCode};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{to_value, Map, Value};
use sqlx::PgConnection;
use url::Url;
use windmill_common::{
db::UserDB,
error::{Error, Result},
utils::{deserialize_url, empty_as_none, is_empty, RunnableKind},
DB,
};
use crate::db::ApiAuthed;
#[cfg(feature = "http_trigger")]
use {
crate::{
http_trigger_args::HttpMethod, http_trigger_auth::ApiKeyAuthentication,
http_triggers::AuthenticationMethod, resources::try_get_resource_from_db_as,
},
itertools::Itertools,
};
lazy_static::lazy_static! {
static ref DEFAULT_OPENAPI_INFO_OBJECT: Info = Info {
title: "Windmill API".to_string(),
version: "1.0.0".to_string(),
..Default::default()
};
}
const DEFAULT_OPENAPI_GENERATED_VERSION: &'static str = "3.1.0";
const JWT_SECURITY_SCHEME: &'static str = "JwtAuth";
const BASIC_HTTP_AUTH_SCHEME: &'static str = "BasicHttp";
const DEFAULT_REQUEST_KEY: &'static str = "defaultRequest";
const DEFAULT_ASYNC_RESPONSE_KEY: &'static str = "AsyncResponse";
const DEFAULT_SYNC_RESPONSE_KEY: &'static str = "SyncResponse";
const DEFAULT_PAYLOAD_PARAM_KEY: &'static str = "PayloadParam";
pub fn openapi_service() -> Router {
Router::new()
.route("/generate", post(generate_openapi_spec))
.route("/download", post(download_spec))
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum Format {
JSON,
YAML,
}
impl Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let format = match self {
Format::JSON => "json",
Format::YAML => "yaml",
};
write!(f, "{}", format)
}
}
impl Default for Format {
fn default() -> Self {
Self::YAML
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct Contact {
#[serde(skip_serializing_if = "is_empty")]
name: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_url",
skip_serializing_if = "Option::is_none"
)]
url: Option<Url>,
#[serde(skip_serializing_if = "is_empty")]
email: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct License {
name: String,
#[serde(skip_serializing_if = "is_empty")]
identifier: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_url",
skip_serializing_if = "Option::is_none"
)]
url: Option<Url>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Info {
title: String,
version: String,
#[serde(skip_serializing_if = "is_empty")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
contact: Option<Contact>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<License>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Server {
url: String,
#[serde(skip_serializing_if = "is_empty")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
variables: Option<HashMap<String, Value>>,
}
#[derive(Debug)]
pub enum SecurityScheme {
BearerJwt,
BasicHttp,
ApiKey(String),
}
#[derive(Debug)]
pub struct WebhookConfig {
runnable_kind: RunnableKind,
}
impl WebhookConfig {
pub fn new(runnable_kind: RunnableKind) -> Self {
Self { runnable_kind }
}
}
#[derive(Debug)]
pub struct HttpRouteConfig {
method: Method,
}
impl HttpRouteConfig {
pub fn new(method: Method) -> Self {
Self { method }
}
}
#[derive(Debug)]
pub enum Kind {
Webhook(WebhookConfig),
HttpRoute(HttpRouteConfig),
}
#[derive(Debug)]
pub struct FuturePath {
route_path: String,
kind: Kind,
is_async: Option<bool>,
summary: Option<String>,
description: Option<String>,
security_scheme: Option<SecurityScheme>,
}
impl FuturePath {
pub fn new(
route_path: String,
kind: Kind,
is_async: Option<bool>,
summary: Option<String>,
description: Option<String>,
security_scheme: Option<SecurityScheme>,
) -> FuturePath {
FuturePath { route_path, kind, is_async, summary, description, security_scheme }
}
}
fn from_route_path_to_openapi_path(
route_path: &str,
kind: &Kind,
) -> Result<(Vec<String>, Option<Value>)> {
let mut openapi_path = String::new();
let mut parameters = Vec::new();
for segment in route_path.split('/') {
if segment.starts_with(':') {
let param_name = &segment[1..];
if param_name.is_empty() {
return Err(anyhow!("Empty parameter name in path: {}", route_path).into());
}
openapi_path.push_str(&format!("/{{{}}}", param_name));
parameters.push(serde_json::json!({
"name": param_name,
"in": "path",
"required": true,
"schema": { "type": "string" }
}));
} else if !segment.is_empty() {
openapi_path.push('/');
openapi_path.push_str(segment);
} else {
openapi_path.push('/');
}
}
let parameters_json = if parameters.is_empty() {
None
} else {
Some(Value::Array(parameters))
};
let prefix = match kind {
Kind::HttpRoute(_) => "",
Kind::Webhook(WebhookConfig { runnable_kind }) => match runnable_kind {
RunnableKind::Script => "p",
RunnableKind::Flow => "f",
},
};
let normalized_path = if openapi_path.starts_with('/') {
format!("{prefix}{openapi_path}")
} else {
format!("{}/{}", prefix, openapi_path)
};
let route_paths = if prefix.is_empty() {
vec![normalized_path]
} else {
vec![
format!("/run/{}", &normalized_path),
format!("/run_wait_result/{}", &normalized_path),
]
};
Ok((route_paths, parameters_json))
}
fn get_servers_component(url: &str, kind: &Kind) -> Server {
let url = url.trim_end_matches('/');
let server = match kind {
Kind::HttpRoute(_) => {
Server { url: format!("{}/api/r", url), description: None, variables: None }
}
Kind::Webhook(_) => Server {
url: format!("{}/api/w/{{workspace}}/jobs", url),
variables: Some(HashMap::from([(
"workspace".to_string(),
serde_json::json!({
"default": "test",
"description": "Workspace identifier"
}),
)])),
description: None,
},
};
server
}
fn generate_paths(
paths: Vec<FuturePath>,
url: Option<&Url>,
) -> Result<IndexMap<String, IndexMap<String, Value>>> {
let mut map: IndexMap<String, IndexMap<String, Value>> = IndexMap::new();
let generate_default_request = || {
serde_json::json!({
"$ref": format!("#/components/requestBodies/{DEFAULT_REQUEST_KEY}")
})
};
let generate_response = |is_async: bool| {
let responses = if is_async {
serde_json::json!({
"200": {
"$ref": format!("#/components/responses/{DEFAULT_ASYNC_RESPONSE_KEY}")
}
})
} else {
serde_json::json!(serde_json::json!({
"200": {
"$ref": format!("#/components/responses/{DEFAULT_SYNC_RESPONSE_KEY}")
}
}))
};
responses
};
let get_security_scheme = |security_scheme: Option<&SecurityScheme>| -> Vec<Value> {
if let Some(security_scheme) = security_scheme {
let scheme = match security_scheme {
SecurityScheme::ApiKey(api_key) => header_to_pascal_case(&api_key),
SecurityScheme::BearerJwt => JWT_SECURITY_SCHEME.to_owned(),
SecurityScheme::BasicHttp => BASIC_HTTP_AUTH_SCHEME.to_owned(),
};
vec![serde_json::json!({
scheme: []
})]
} else {
vec![]
}
};
let mut webhooks = HashSet::new();
for path in paths {
if let Kind::Webhook(WebhookConfig { runnable_kind }) = &path.kind {
if !webhooks.insert((path.route_path.clone(), runnable_kind.to_owned())) {
continue;
}
}
let (route_paths, parameters) =
from_route_path_to_openapi_path(&path.route_path, &path.kind)?;
for route_path in route_paths {
let path_object = map.entry(route_path.clone()).or_insert_with(|| {
let mut path_object = IndexMap::new();
if let Some(url) = url {
let servers = get_servers_component(url.as_str(), &path.kind);
path_object.insert("servers".to_string(), to_value(vec![servers]).unwrap());
}
if parameters.is_some() {
path_object.insert(
"parameters".to_string(),
to_value(parameters.clone()).unwrap(),
);
}
path_object
});
let is_async;
let (methods, is_webhook) = match &path.kind {
Kind::Webhook(_) => {
is_async = route_path.starts_with("/run/");
let methods = if is_async {
vec![Method::POST]
} else {
vec![Method::GET, Method::POST]
};
(methods, true)
}
Kind::HttpRoute(HttpRouteConfig { method }) => {
if path_object.get(&method.to_string()).is_some() {
return Err(anyhow!(
"Found duplicate {} method, for route at path: {}",
method,
path.route_path
)
.into());
}
is_async = path.is_async.unwrap_or(true);
(vec![method.to_owned()], false)
}
};
for method in methods {
let mut method_map = IndexMap::new();
if let Some(summary) = path.summary.as_ref().filter(|s| !s.is_empty()) {
method_map.insert("summary", Value::String(summary.to_owned()));
}
if let Some(description) = path.description.as_ref().filter(|s| !s.is_empty()) {
method_map.insert("description", Value::String(description.to_owned()));
}
method_map.insert(
"security",
to_value(get_security_scheme(path.security_scheme.as_ref()))?,
);
if method != Method::GET {
method_map.insert("requestBody", generate_default_request());
} else if is_webhook {
method_map.insert(
"parameters",
Value::Array(vec![serde_json::json!({
"$ref": format!("#/components/parameters/{DEFAULT_PAYLOAD_PARAM_KEY}")
})]),
);
}
method_map.insert("responses", generate_response(is_async));
path_object.insert(method.to_string().to_lowercase(), to_value(&method_map)?);
}
}
}
return Ok(map);
}
pub fn transform_to_minified_postgres_regex(glob: &str) -> String {
let mut regex = String::from("^");
for ch in glob.chars() {
match ch {
'*' => regex.push_str(".*"),
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
regex.push('\\');
regex.push(ch);
}
_ => regex.push(ch),
}
}
regex.push('$');
regex
}
#[derive(Debug, Default)]
pub struct ServerToSet {
pub http_route: bool,
pub webhook_flow: bool,
pub webhook_script: bool,
}
impl ServerToSet {
pub fn new(http_route: bool, webhook_flow: bool, webhook_script: bool) -> ServerToSet {
ServerToSet { http_route, webhook_flow, webhook_script }
}
}
fn header_to_pascal_case(header: &str) -> String {
header
.split(|c: char| c == '-' || c == '_' || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
Some(first) => {
first.to_ascii_uppercase().to_string()
+ chars.as_str().to_ascii_lowercase().as_str()
}
None => String::new(),
}
})
.collect::<String>()
}
#[derive(Debug, Default)]
struct SecuritySchemeToAdd {
basic_http: bool,
bearer_jwt: bool,
api_keys: Vec<(String, Value)>,
}
fn generate_all_security_schemes(future_paths: &[FuturePath]) -> SecuritySchemeToAdd {
let mut to_add = SecuritySchemeToAdd::default();
let mut set = HashSet::new();
for future_path in future_paths {
if !to_add.basic_http
&& matches!(future_path.security_scheme, Some(SecurityScheme::BasicHttp))
{
to_add.basic_http = true
} else if !to_add.bearer_jwt
&& matches!(future_path.security_scheme, Some(SecurityScheme::BearerJwt))
{
to_add.bearer_jwt = true
} else if let Some(SecurityScheme::ApiKey(api_key)) = future_path.security_scheme.as_ref() {
let pascal_case_header = header_to_pascal_case(&api_key);
if !set.insert(pascal_case_header.clone()) {
continue;
}
let scheme = serde_json::json!({
"type": "apiKey",
"in": "header",
"name": api_key
});
to_add.api_keys.push((pascal_case_header, scheme));
}
}
to_add
}
fn generate_components(future_paths: &[FuturePath]) -> Map<String, Value> {
let mut components = Map::new();
if future_paths
.iter()
.any(|path| matches!(path.kind, Kind::Webhook(_)))
{
components.insert(
"parameters".to_owned(),
serde_json::json!({
"PayloadParam": {
"name": "payload",
"in": "query",
"required": true,
"description": "A URL-safe base64-encoded JSON string payload.",
"schema": {
"type": "string"
}
}
}),
);
}
{
let mut security_scheme = Map::new();
let SecuritySchemeToAdd { basic_http, bearer_jwt, api_keys } =
generate_all_security_schemes(future_paths);
if basic_http {
security_scheme.insert(
BASIC_HTTP_AUTH_SCHEME.to_owned(),
serde_json::json!({
"type": "http",
"scheme": "basic"
}),
);
}
if bearer_jwt {
security_scheme.insert(
JWT_SECURITY_SCHEME.to_owned(),
serde_json::json!({
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}),
);
}
for (key, value) in api_keys {
security_scheme.insert(key, value);
}
components.insert("securitySchemes".to_owned(), Value::Object(security_scheme));
}
components.insert("requestBodies".to_owned(), serde_json::json!({
DEFAULT_REQUEST_KEY: {
"description": "This route may or may not require a request body, but its structure and content type are unknown.",
"required": false,
"content": {
"application/json": {}
}
}
}));
components.insert("responses".to_owned(), serde_json::json!({
DEFAULT_ASYNC_RESPONSE_KEY: {
"description": "Returns a job ID as a UUID string.",
"content": {
"text/plain": {
"schema": {
"type": "string",
"format": "uuid",
"examples": [ "550e8400-e29b-41d4-a716-446655440000" ]
}
}
}
},
DEFAULT_SYNC_RESPONSE_KEY: {
"description": "This route may return a response, but its structure and content type are unknown.",
"content": {
"application/octet-stream": {}
}
},
}));
components
}
pub fn generate_openapi_document(
info: Option<&Info>,
url: Option<&Url>,
paths: Vec<FuturePath>,
format: Format,
) -> Result<String> {
let mut openapi_doc: IndexMap<&'static str, Value> = IndexMap::new();
openapi_doc.insert("openapi", to_value(&DEFAULT_OPENAPI_GENERATED_VERSION)?);
openapi_doc.insert(
"info",
to_value(info.unwrap_or(&DEFAULT_OPENAPI_INFO_OBJECT))?,
);
openapi_doc.insert("components", Value::Object(generate_components(&paths)));
openapi_doc.insert("paths", to_value(generate_paths(paths, url)?)?);
let openapi_document = match format {
Format::YAML => serde_yml::to_string(&openapi_doc).map_err(|err| {
anyhow!(
"Could not generate OpenAPI document in YAML format: {}",
err
)
})?,
Format::JSON => serde_json::to_string_pretty(&openapi_doc).map_err(|err| {
anyhow!(
"Could not generate OpenAPI document in JSON format: {}",
err
)
})?,
};
Ok(openapi_document)
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
struct HttpRouteFilter {
folder_regex: String,
path_regex: String,
route_path_regex: String,
}
#[derive(Debug, Deserialize)]
struct WebhookFilter {
user_or_folder_regex: String,
user_or_folder_regex_value: String,
path: String,
runnable_kind: RunnableKind,
}
#[derive(Debug, Deserialize)]
struct GenerateOpenAPI {
info: Option<Info>,
url: Option<Url>,
#[serde(default, deserialize_with = "empty_as_none")]
http_route_filters: Option<Vec<HttpRouteFilter>>,
#[serde(default, deserialize_with = "empty_as_none")]
webhook_filters: Option<Vec<WebhookFilter>>,
#[serde(default)]
openapi_spec_format: Format,
}
#[cfg(feature = "http_trigger")]
async fn http_routes_to_future_paths(
db: &DB,
user_db: UserDB,
authed: &ApiAuthed,
pg_pool: &mut PgConnection,
http_route_filters: Option<&[HttpRouteFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
let mut http_routes = Vec::new();
if let Some(http_route_filters) = http_route_filters {
let path_regex = http_route_filters
.iter()
.map(|filter| {
transform_to_minified_postgres_regex(&format!(
"f/{}/{}",
filter.folder_regex, filter.path_regex
))
})
.collect_vec();
let route_path_regex = http_route_filters
.iter()
.map(|filter| transform_to_minified_postgres_regex(&filter.route_path_regex))
.collect_vec();
#[derive(Debug, Deserialize)]
struct MinifiedHttpTrigger {
route_path: String,
http_method: HttpMethod,
is_async: bool,
workspaced_route: bool,
summary: Option<String>,
description: Option<String>,
authentication_method: AuthenticationMethod,
authentication_resource_path: Option<String>,
}
http_routes = sqlx::query_as!(
MinifiedHttpTrigger,
r#"
SELECT
route_path,
http_method AS "http_method: _",
is_async,
workspaced_route,
summary,
description,
authentication_method AS "authentication_method: _",
authentication_resource_path
FROM
http_trigger
WHERE
path ~ ANY($1) AND
route_path ~ ANY($2) AND
workspace_id = $3
"#,
&path_regex,
&route_path_regex,
&w_id
)
.fetch_all(pg_pool)
.await?;
}
let mut openapi_future_paths = Vec::with_capacity(http_routes.len());
for http_route in http_routes {
let auth_method = match http_route.authentication_method {
AuthenticationMethod::BasicHttp => Some(SecurityScheme::BasicHttp),
AuthenticationMethod::Windmill => Some(SecurityScheme::BearerJwt),
AuthenticationMethod::ApiKey => {
let resource_path = match http_route.authentication_resource_path {
Some(resource_path) => resource_path,
None => {
return Err(Error::BadRequest(
"Missing authentication resource path".to_string(),
));
}
};
let api = try_get_resource_from_db_as::<ApiKeyAuthentication>(
authed,
Some(user_db.clone()),
db,
&resource_path,
w_id,
)
.await?;
Some(SecurityScheme::ApiKey(api.api_key_header))
}
_ => None,
};
let route_path = if http_route.workspaced_route {
format!("{}/{}", w_id, http_route.route_path.trim_start_matches('/'))
} else {
http_route.route_path.clone()
};
let method = match http_route.http_method {
HttpMethod::Get => Method::GET,
HttpMethod::Post => Method::POST,
HttpMethod::Put => Method::PUT,
HttpMethod::Patch => Method::PATCH,
HttpMethod::Delete => Method::DELETE,
};
let future_path = FuturePath::new(
route_path,
Kind::HttpRoute(HttpRouteConfig::new(method)),
Some(http_route.is_async),
http_route.summary,
http_route.description,
auth_method,
);
openapi_future_paths.push(future_path);
}
Ok(openapi_future_paths)
}
#[cfg(not(feature = "http_trigger"))]
async fn http_routes_to_future_paths(
_db: &DB,
_user_db: UserDB,
_authed: &ApiAuthed,
_pg_pool: &mut PgConnection,
_http_route_filters: Option<&[HttpRouteFilter]>,
_w_id: &str,
) -> Result<Vec<FuturePath>> {
Ok(Vec::new())
}
async fn webhook_to_future_paths(
pg_pool: &mut PgConnection,
webhook_filters: Option<&[WebhookFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
let mut openapi_future_paths = Vec::new();
if let Some(webhook_filters) = webhook_filters {
let mut script_webhook_filter = Vec::new();
let mut flow_webhook_filter = Vec::new();
for webhook in webhook_filters {
let full_regex = transform_to_minified_postgres_regex(&format!(
"{}/{}/{}",
&webhook.user_or_folder_regex, &webhook.user_or_folder_regex_value, &webhook.path
));
match webhook.runnable_kind {
RunnableKind::Script => {
script_webhook_filter.push(full_regex);
}
RunnableKind::Flow => {
flow_webhook_filter.push(full_regex);
}
}
}
#[derive(Debug, Deserialize, Clone, Hash)]
struct MinifiedWebhook {
path: String,
description: Option<String>,
summary: Option<String>,
}
let webhook_scripts = sqlx::query_as!(
MinifiedWebhook,
r#"SELECT
path,
summary,
description
FROM
script
WHERE
path ~ ANY($1) AND
workspace_id = $2 AND
archived is FALSE
"#,
&script_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?;
let webhook_flows = sqlx::query_as!(
MinifiedWebhook,
r#"SELECT
path,
summary,
description
FROM
flow
WHERE
path ~ ANY($1) AND
workspace_id = $2 AND
archived is FALSE
"#,
&flow_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?;
openapi_future_paths.reserve_exact(webhook_scripts.len() + webhook_flows.len());
for webhook in webhook_scripts {
openapi_future_paths.push(FuturePath::new(
webhook.path,
Kind::Webhook(WebhookConfig::new(RunnableKind::Script)),
None,
webhook.summary,
webhook.description,
Some(SecurityScheme::BearerJwt),
));
}
for webhook in webhook_flows {
openapi_future_paths.push(FuturePath::new(
webhook.path,
Kind::Webhook(WebhookConfig::new(RunnableKind::Flow)),
None,
webhook.summary,
webhook.description,
Some(SecurityScheme::BearerJwt),
));
}
}
Ok(openapi_future_paths)
}
async fn generate_openapi_future_path(
db: &DB,
user_db: UserDB,
authed: &ApiAuthed,
http_route_filters: Option<&[HttpRouteFilter]>,
webhook_filters: Option<&[WebhookFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
if http_route_filters.is_none() && webhook_filters.is_none() {
return Err(Error::BadRequest(
"Expected http route filter and/or webhook filters".to_string(),
));
}
let mut tx = user_db.clone().begin(authed).await?;
let mut openapi_future_paths =
http_routes_to_future_paths(db, user_db, authed, &mut tx, http_route_filters, w_id).await?;
openapi_future_paths
.append(&mut webhook_to_future_paths(&mut tx, webhook_filters, w_id).await?);
tx.commit().await?;
if openapi_future_paths.is_empty() {
return Err(Error::NotFound(
"No match for the current filter".to_string(),
));
}
Ok(openapi_future_paths)
}
async fn generate_openapi_spec(
Extension(authed): Extension<ApiAuthed>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(generate_openapi): Json<GenerateOpenAPI>,
) -> Result<String> {
let openapi_future_paths = generate_openapi_future_path(
&db,
user_db,
&authed,
generate_openapi.http_route_filters.as_deref(),
generate_openapi.webhook_filters.as_deref(),
&w_id,
)
.await?;
let openapi_document = generate_openapi_document(
generate_openapi.info.as_ref(),
generate_openapi.url.as_ref(),
openapi_future_paths,
generate_openapi.openapi_spec_format,
);
openapi_document
}
async fn download_spec(
Extension(authed): Extension<ApiAuthed>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(generate_openapi): Json<GenerateOpenAPI>,
) -> Result<Response> {
let openapi_future_paths = generate_openapi_future_path(
&db,
user_db,
&authed,
generate_openapi.http_route_filters.as_deref(),
generate_openapi.webhook_filters.as_deref(),
&w_id,
)
.await?;
let openapi_document = generate_openapi_document(
generate_openapi.info.as_ref(),
generate_openapi.url.as_ref(),
openapi_future_paths,
generate_openapi.openapi_spec_format,
)?;
let response = Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
)
.body(Body::from(openapi_document))
.unwrap();
Ok(response)
}
@@ -160,7 +160,7 @@ pub async fn get_pg_connection(
logical_mode: bool,
) -> Result<Client> {
let database =
try_get_resource_from_db_as::<Postgres>(authed, user_db, db, postgres_resource_path, w_id)
try_get_resource_from_db_as::<Postgres>(&authed, user_db, db, postgres_resource_path, w_id)
.await?;
Ok(get_raw_postgres_connection(&database, logical_mode).await?)
@@ -417,7 +417,7 @@ impl PostgresConfig {
};
let database = try_get_resource_from_db_as::<Postgres>(
authed,
&authed,
Some(UserDB::new(db.clone())),
&db,
postgres_resource_path,
+1 -1
View File
@@ -1217,7 +1217,7 @@ async fn update_resource_type(
)
))]
pub async fn try_get_resource_from_db_as<T>(
authed: ApiAuthed,
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
resource_path: &str,
+10 -3
View File
@@ -1354,9 +1354,16 @@ async fn raw_script_by_path_internal(
)
.fetch_all(&db)
.await?;
tracing::info!(
"Script {path} does not exist in workspace {w_id} but these paths do: {:?}",
other_script_o.join(", ")
let other_script_archived = sqlx::query_scalar!(
"SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true",
w_id
)
.fetch_all(&db)
.await?;
tracing::warn!(
"Script {path} does not exist in workspace {w_id} but these paths do, non-archived: {:?} | archived: {:?}",
other_script_o.join(", "),
other_script_archived.join(", ")
)
}
}
-19
View File
@@ -6,8 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::fmt::Display;
use axum::{body::Body, response::Response};
use regex::Regex;
use serde::{Deserialize, Deserializer};
@@ -31,23 +29,6 @@ pub struct WithStarredInfoQuery {
pub with_starred_info: Option<bool>,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunnableKind {
Script,
Flow,
}
impl Display for RunnableKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let runnable_kind = match self {
RunnableKind::Script => "script",
RunnableKind::Flow => "flow"
};
write!(f, "{}", runnable_kind)
}
}
pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
let is_admin = is_super_admin_email(db, email).await?;
+47
View File
@@ -450,6 +450,7 @@ struct EditVariable {
value: Option<String>,
is_secret: Option<bool>,
description: Option<String>,
account: Option<i32>,
}
#[derive(Deserialize)]
@@ -506,6 +507,10 @@ async fn update_variable(
sqlb.set_str("description", &desc);
}
if let Some(account_id) = ns.account {
sqlb.set_str("account", account_id);
}
if let Some(nbool) = ns.is_secret {
let old_secret = sqlx::query_scalar!(
"SELECT is_secret from variable WHERE path = $1 AND workspace_id = $2",
@@ -523,6 +528,21 @@ async fn update_variable(
sqlb.set_str("is_secret", nbool);
}
sqlb.returning("path");
// Get old account_id if we're updating the account field
let old_account_id = if ns.account.is_some() {
sqlx::query_scalar!(
"SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
} else {
None
};
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
if let Some(npath) = ns.path {
@@ -575,6 +595,33 @@ async fn update_variable(
None,
)
.await?;
// Clean up old account if it's no longer referenced and different from new account
if let Some(old_acc_id) = old_account_id {
if ns.account.is_some() && ns.account != Some(old_acc_id) {
// Check if old account is still referenced by other variables or resources
let account_still_used = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
old_acc_id,
&w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(true);
if !account_still_used {
// Delete the orphaned account
sqlx::query!(
"DELETE FROM account WHERE id = $1 AND workspace_id = $2",
old_acc_id,
&w_id
)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
handle_deployment_metadata(
@@ -548,6 +548,8 @@ pub(crate) async fn tarball_workspace(
authentication_resource_path,
script_path,
is_flow,
summary,
description,
edited_by,
edited_at,
email,
+1
View File
@@ -80,6 +80,7 @@ backon.workspace = true
openidconnect = { workspace = true, optional = true }
strum.workspace = true
strum_macros.workspace = true
url.workspace = true
semver.workspace = true
croner = "2.0.6"
+54 -2
View File
@@ -22,11 +22,14 @@ use croner::Cron;
use rand::{distr::Alphanumeric, rng, Rng};
use reqwest::Client;
use semver::Version;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{de::Error as SerdeDeserializerError, Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{Pool, Postgres};
use std::borrow::Cow;
use std::fmt::Display;
use std::{fs::DirBuilder as SyncDirBuilder, str::FromStr};
use tokio::fs::DirBuilder as AsyncDirBuilder;
use url::Url;
pub const MAX_PER_PAGE: usize = 10000;
pub const DEFAULT_PER_PAGE: usize = 1000;
@@ -81,7 +84,7 @@ lazy_static::lazy_static! {
}
Mode::Worker
} else if &x == "agent" {
println!("Binary is in 'agent' mode");
println!("Binary is in 'agent' mode with BASE_INTERNAL_URL={}", std::env::var("BASE_INTERNAL_URL").unwrap_or_default());
if std::env::var("BASE_INTERNAL_URL").is_err() {
panic!("BASE_INTERNAL_URL is required in agent mode")
}
@@ -523,6 +526,18 @@ impl<T> IsEmpty for Vec<T> {
}
}
impl<T> IsEmpty for Option<T>
where
T: IsEmpty,
{
fn is_empty(&self) -> bool {
match self {
Some(v) => v.is_empty(),
None => true,
}
}
}
pub fn empty_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
@@ -532,6 +547,26 @@ where
Ok(option.filter(|s| !s.is_empty()))
}
pub fn is_empty<T>(value: &T) -> bool
where
T: IsEmpty,
{
value.is_empty()
}
pub fn deserialize_url<'de, D: Deserializer<'de>>(
de: D,
) -> std::result::Result<Option<Url>, D::Error> {
let intermediate = <Option<Cow<'de, str>>>::deserialize(de)?;
match intermediate.as_deref() {
None | Some("") => Ok(None),
Some(non_empty_string) => Url::parse(non_empty_string)
.map(Some)
.map_err(D::Error::custom),
}
}
pub async fn fetch_mute_workspace(_db: &DB, workspace_id: &str) -> Result<bool> {
match sqlx::query!(
"SELECT mute_critical_alerts FROM workspace_settings WHERE workspace_id = $1",
@@ -783,3 +818,20 @@ impl<F: Future> Future for WarnAfterFuture<F> {
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum RunnableKind {
Script,
Flow,
}
impl Display for RunnableKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let runnable_kind = match self {
RunnableKind::Script => "script",
RunnableKind::Flow => "flow",
};
write!(f, "{}", runnable_kind)
}
}
+17 -12
View File
@@ -43,12 +43,11 @@ use crate::{
OccupancyMetrics,
},
handle_child::handle_child,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV,
POWERSHELL_CACHE_DIR, POWERSHELL_PATH, PROXY_ENVS, TZ_ENV,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, POWERSHELL_CACHE_DIR,
POWERSHELL_PATH, PROXY_ENVS, TZ_ENV,
};
use windmill_common::client::AuthedClient;
#[cfg(windows)]
use crate::SYSTEM_ROOT;
@@ -275,6 +274,19 @@ exit $exit_status
)))
}
#[cfg(feature = "dind")]
async fn rm_container(client: &bollard::Docker, container_id: &str) {
if let Err(e) = client
.remove_container(
container_id,
Some(RemoveContainerOptions { force: true, ..Default::default() }),
)
.await
{
tracing::error!("Error removing container: {:?}", e);
}
}
#[cfg(feature = "dind")]
async fn handle_docker_job(
job_id: Uuid,
@@ -446,19 +458,12 @@ async fn handle_docker_job(
}
}
}
rm_container(&client, &container_id).await;
return Err(e);
}
if let Err(e) = client
.remove_container(
&container_id,
Some(RemoveContainerOptions { force: true, ..Default::default() }),
)
.await
{
tracing::error!("Error removing container: {:?}", e);
}
rm_container(&client, &container_id).await;
let result = result.unwrap();
@@ -674,7 +674,14 @@ except BaseException as e:
// ^^^^^^ ^
// We also want this be priorotized, that's why we insert it to the beginning
}
paths.iter().join(":")
#[cfg(windows)]
{
paths.iter().join(";")
}
#[cfg(not(windows))]
{
paths.iter().join(":")
}
};
#[cfg(windows)]
+2 -2
View File
@@ -4009,7 +4009,7 @@ async fn flow_to_payload(
w_id: &str,
db: &DB,
) -> Result<JobPayloadWithTag, Error> {
let FlowVersionInfo { version, on_behalf_of_email, edited_by, .. } =
let FlowVersionInfo { version, on_behalf_of_email, edited_by, tag, .. } =
get_latest_flow_version_info_for_path(db, w_id, &path, true).await?;
let on_behalf_of = if let Some(email) = on_behalf_of_email {
Some(OnBehalfOf { email, permissioned_as: username_to_permissioned_as(&edited_by) })
@@ -4018,7 +4018,7 @@ async fn flow_to_payload(
};
let payload =
JobPayload::Flow { path, dedicated_worker: None, apply_preprocessor: false, version };
Ok(JobPayloadWithTag { payload, tag: None, delete_after_use, timeout: None, on_behalf_of })
Ok(JobPayloadWithTag { payload, tag, delete_after_use, timeout: None, on_behalf_of })
}
async fn script_to_payload(
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.496.3";
export const VERSION = "v1.499.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -63,7 +63,7 @@ export {
// }
// });
export const VERSION = "1.496.3";
export const VERSION = "1.499.0";
const command = new Command()
.name("wmill")
+3 -1
View File
@@ -167,6 +167,8 @@
'')
(pkgs.writeScriptBin "wm" ''
cd ./frontend
npm install
npm run generate-backend-client
npm run dev $*
'')
(pkgs.writeScriptBin "wm-minio" ''
@@ -187,7 +189,6 @@
echo "bucket: wmill"
echo "endpoint: http://localhost:9000"
'')
];
inherit PKG_CONFIG_PATH RUSTY_V8_ARCHIVE;
@@ -200,6 +201,7 @@
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
DENO_PATH = "${pkgs.deno}/bin/deno";
GO_PATH = "${pkgs.go}/bin/go";
PHP_PATH = "${pkgs.php}/bin/php";
BUN_PATH = "${pkgs.bun}/bin/bun";
UV_PATH = "${pkgs.uv}/bin/uv";
NU_PATH = "${pkgs.nushell}/bin/nu";
+95 -43
View File
@@ -1,21 +1,83 @@
# Developing
# Windmill's build guide
[Using Nix](#nix), otherwise:
## Using Nix (Recommended)
In the `frontend/` directory:
Nix will manage all environment variables, packages and other configuration that you would usually do manually.
- install the dependencies with `npm install` (or `pnpm install` or `yarn`)
- generate the windmill client:
**Prerequisites**
- Install [Nix](https://github.com/DeterminateSystems/nix-installer).
- Install Docker.
- Optionally install [direnv](https://direnv.net/docs/installation.html).
That's it! You are ready to go.
> Using **direnv** is highly recommended, since it can load shell automatically based on your CWD. It also can give you hints.
### Development
```bash
# enter a dev shell containing all necessary packages. `direnv allow` if direnv is installed.
nix develop
## or ignore if you have `direnv`
# Start db (if not started already)
sudo docker compose up db -d
# run the frontend.
wm
# In an other shell:
#
nix develop
## or ignore if you have `direnv`
cd backend
# You don't need to install anything extra. All dependencies are already in place!
cargo run --features all_languages
```
The default proxy is setup to use the local backend: <http://localhost:8000>.
### wm-* Commands
Nix shell provides you with several helper commands prefixed with `wm-`
```bash
# Start minio server (implements S3)
wm-minio
# Note: You will need access to EE private repo in order to compile, don't forget "enterprise" and "parquet" freatures as well.
# Generate keys for local dev.
wm-minio-keys
# Minio data as well as generated keys are stored in `backend/.minio-data`
```
You can read about all others commands individually in [flake.nix](../flake.nix).
### dev.nu
In some places we have `dev.nu` files. They can help you developing and testing specific features. Their functionality depends on context, but you can get more info by running it with `--help` flag.
### Running on NixOS
It is recommended to use [nix-alien](https://github.com/thiagokokada/nix-alien) for running compiled windmill binaries. We need this because sometimes windmill may fetch unpatched binaries that are not compatible with NixOS.
## Traditional instructions
### Frontend only
```bash
cd frontend/
# Install dependencies.
npm install # or pnpm or yarn or bun
# Generate windmill client.
npm run generate-backend-client
## on mac use
npm run generate-backend-client-mac
```
Once the dependencies are installed, just start the dev server:
```bash
# Start dev server
npm run dev
```
@@ -27,9 +89,9 @@ You can configure another proxy to use like so:
REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev
```
## Use a Local backend
### Use a Local backend
### 1. Backend is run by docker
#### 1. Backend is run by docker
```bash
docker build . -t windmill
@@ -40,7 +102,23 @@ docker compose up db windmill_server windmill_worker
REMOTE=http://localhost REMOTE_LSP=http://localhost npm run dev
```
### 2. Backend is run by cargo
#### 2. Backend is run by docker, but built from the local source code
Sometimes it is important to build docker image for your branch locally. It is crucial part of testing, since local environment may differ from the containerized one.
That's why we provide [docker/dev.nu](../docker/dev.nu). It is helper that can build images locally and execute them.
it can build the image and run on local repository.
```bash
# Issue the build
docker/dev.nu up --features "python,static_frontend" docker/DockerfileNsjail --rebuild
# Will create and run `main__nsjail__python-static_frontend`
```
If you develop wasm parser for new language you can also pass `--wasm-pkg <language>` and it will include local parser to the image. For more information please see the script directly or run it with `--help` flag.
#### 3. Backend is run by cargo
**Prerequisites**
@@ -87,13 +165,7 @@ In the frontend folder:
REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev
```
## Building
```bash
NODE_OPTIONS=--max_old_space_size=8096 npm run build
```
## Formatting
### Formatting
This project uses [prettier](https://prettier.io/docs/en/install.html) and
[prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte), be
@@ -116,15 +188,15 @@ Recommended config for VS Code:
- turn _format on save_ on
## Building
### Building frontend
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
```bash
NODE_OPTIONS=--max_old_space_size=8096 npm run build
```
The output is in the `build` folder.
@@ -139,27 +211,7 @@ which will generate an index.html and allow you to serve the frontend with any s
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.
## Nix
Windmill use [nix flakes](https://nixos.wiki/wiki/Flakes):
```bash
nix run github:windmill-labs/windmill
```
### Development
```bash
nix develop # enter a dev shell containing all necessary packages.
wm-setup # build the frontend and setup the database.
wm # run the frontend.
# In an other shell:
nix develop
cd backend
cargo run
```
### Updating [`flake.nix`](../flake.nix)
## Updating [`flake.nix`](../flake.nix)
```bash
nix flake update # update the lock file.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.496.3",
"version": "1.499.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.496.3",
"version": "1.499.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.496.3",
"version": "1.499.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+2 -2
View File
@@ -738,8 +738,7 @@
/>
{:else if itemsType?.type == 'resource' && itemsType?.resourceType && resourceTypes?.includes(itemsType.resourceType)}
<ObjectResourceInput
value={v ? `$res:${v}` : undefined}
bind:path={value[i]}
bind:value={value[i]}
format={'resource-' + itemsType?.resourceType}
defaultValue={undefined}
/>
@@ -855,6 +854,7 @@
{:else if inputCat == 'resource-object' && resourceTypes == undefined}
<span class="text-2xs text-tertiary">Loading resource types...</span>
{:else if inputCat == 'resource-object' && (resourceTypes == undefined || (format && format?.split('-').length > 1 && resourceTypes.includes(format?.substring('resource-'.length))))}
<!-- {JSON.stringify(value)} -->
<ObjectResourceInput
{defaultValue}
selectFirst={!noDefaultOnSelectFirst}
@@ -5,7 +5,7 @@
import { ExternalLink, Loader2, X } from 'lucide-svelte'
import { SettingService, WorkerService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { superadmin } from '$lib/stores'
import { superadmin, devopsRole } from '$lib/stores'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { CUSTOM_TAGS_SETTING } from '$lib/consts'
import { base } from '$lib/base'
@@ -80,10 +80,10 @@
loadCustomTags()
sendUserToast('Tag added')
}}
disabled={newTag.trim() == '' || !$superadmin}
disabled={newTag.trim() == '' || !($superadmin || $devopsRole)}
>
Add {#if !$superadmin}
<span class="text-2xs text-tertiary">superadmin only</span>
Add {#if !($superadmin || $devopsRole)}
<span class="text-2xs text-tertiary">superadmin or devops only</span>
{/if}
</Button>
<span class="text-sm text-primary"
@@ -8,7 +8,7 @@
loadingSave = false,
newFlow = false,
dropdownItems = []
} = $props<{
}: {
loading?: boolean
loadingSave?: boolean
newFlow?: boolean
@@ -16,7 +16,7 @@
label: string
onClick: () => void
}>
}>()
} = $props()
const dispatch = createEventDispatcher()
@@ -177,6 +177,9 @@
if (arg.type) {
propertyType = arg.type
}
if (arg.expr != undefined) {
arg.expr = undefined
}
}
}
@@ -332,6 +335,8 @@
untrack(() => updatePropertyType())
})
$effect(() => {
arg.value
arg.expr
inputCat && propertyType && arg && untrack(() => onArgChange())
})
$effect(() => {
@@ -4,40 +4,43 @@
import S3ObjectPicker from './S3ObjectPicker.svelte'
import type SimpleEditor from './SimpleEditor.svelte'
export let format: string
export let value: any
export let disablePortal = false
export let showSchemaExplorer = false
export let selectFirst = false
export let defaultValue: any
export let editor: SimpleEditor | undefined = undefined
function isString(value: any) {
return typeof value === 'string' || value instanceof String
}
export let path: string = ''
function resourceToValue() {
if (path && path != '') {
value = `$res:${path}`
} else {
value = undefined
}
interface Props {
format: string
value: any
disablePortal?: boolean
showSchemaExplorer?: boolean
selectFirst?: boolean
defaultValue: any
editor?: SimpleEditor | undefined
path?: string
}
let {
format,
value = $bindable(),
disablePortal = false,
showSchemaExplorer = false,
selectFirst = false,
defaultValue,
editor = $bindable(undefined)
}: Props = $props()
function isResource() {
return isString(value) && value.length >= '$res:'.length
}
function valueToPath() {
if (isResource()) {
path = value.substr('$res:'.length)
return value.substr('$res:'.length)
}
}
$: value && valueToPath()
</script>
<!-- {JSON.stringify({ value })} -->
<div class="flex flex-row w-full flex-wrap gap-x-2 gap-y-0.5">
{#if format === 'resource-s3_object'}
<S3ObjectPicker bind:value />
@@ -45,12 +48,17 @@
<ResourcePicker
{selectFirst}
{disablePortal}
on:change={(e) => {
path = e.detail
resourceToValue()
}}
on:clear
bind:value={path}
bind:value={
() => valueToPath(),
(v) => {
if (v == undefined) {
value = undefined
} else {
value = `$res:${v}`
}
}
}
initialValue={typeof defaultValue == 'string' && defaultValue.startsWith('$res:')
? defaultValue.substr('$res:'.length)
: defaultValue}
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
@@ -34,7 +32,7 @@
}
}
let enabled = $derived(value != undefined)
run(() => {
$effect(() => {
changeDomain(value?.['domain'], value?.['custom'])
})
</script>
@@ -10,23 +10,44 @@
import { Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { isDbType } from './apps/components/display/dbtable/utils'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import Select from './Select.svelte'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
export let initialValue: string | undefined = undefined
export let value: string | undefined = initialValue
export let valueType: string | undefined = undefined
export let resourceType: string | undefined = undefined
export let disabled = false
export let disablePortal = false
export let showSchemaExplorer = false
export let selectFirst = false
export let expressOAuthSetup = false
export let defaultValues: Record<string, any> | undefined = undefined
export let placeholder: string | undefined = undefined
interface Props {
initialValue?: string | undefined
value?: string | undefined
valueType?: string | undefined
resourceType?: string | undefined
disabled?: boolean
disablePortal?: boolean
showSchemaExplorer?: boolean
selectFirst?: boolean
expressOAuthSetup?: boolean
defaultValues?: Record<string, any> | undefined
placeholder?: string | undefined
}
let {
initialValue = $bindable(undefined),
value = $bindable(undefined),
valueType = $bindable(undefined),
resourceType = undefined,
disabled = false,
disablePortal = false,
showSchemaExplorer = false,
selectFirst = false,
expressOAuthSetup = false,
defaultValues = undefined,
placeholder = undefined
}: Props = $props()
if (initialValue && value == undefined) {
console.log('initialValue', initialValue)
value = initialValue
}
onMount(() => {
setTimeout(() => {
@@ -36,26 +57,36 @@
}, 500)
})
let valueSelect =
initialValue || value
? {
value: value ?? initialValue,
label: value ?? initialValue,
type: valueType
}
: undefined
// let valueSelect = $state(
// initialValue || value
// ? {
// value: value ?? initialValue,
// label: value ?? initialValue,
// type: valueType
// }
// : undefined
// )
$: if (value === undefined && initialValue) {
value = initialValue
}
$effect(() => {
if (value === undefined) {
if (initialValue) {
console.log('initialValue', initialValue)
value = initialValue
} else {
console.log('no value')
}
} else {
console.log('value', value)
}
})
let collection = valueSelect ? [valueSelect] : []
let collection = $state(value ? [{ value, label: value, type: valueType }] : [])
export async function askNewResource() {
appConnect?.open?.(resourceType)
}
let loading = true
let loading = $state(true)
async function loadResources(resourceType: string | undefined) {
loading = true
try {
@@ -84,10 +115,10 @@
nc.push({ value: value ?? initialValue!, label: value ?? initialValue!, type: '' })
}
collection = nc
if (collection.length == 1 && selectFirst && valueSelect == undefined) {
if (collection.length == 1 && selectFirst && value == undefined) {
console.log('selectFirst', collection[0].value)
value = collection[0].value
valueType = collection[0].type
valueSelect = collection[0]
}
} catch (e) {
sendUserToast('Failed to load resource types', true)
@@ -96,12 +127,16 @@
loading = false
}
$: $workspaceStore && loadResources(resourceType)
$effect(() => {
$workspaceStore && loadResources(resourceType)
})
$: dispatchIfMounted('change', value)
$effect(() => {
dispatchIfMounted('change', value)
})
let appConnect: AppConnect
let resourceEditor: ResourceEditorDrawer
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
</script>
<AppConnect
@@ -109,11 +144,6 @@
await loadResources(resourceType)
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
valueSelect = {
value: e.detail,
label: e.detail,
type: valueType ?? ''
}
}}
bind:this={appConnect}
{expressOAuthSetup}
@@ -125,11 +155,11 @@
if (e.detail) {
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
// valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
}
}}
/>
<!-- {JSON.stringify({ value, collection })} -->
<div class="flex flex-col w-full items-start min-h-9">
<div class="flex flex-row w-full items-center">
{#if collection?.length > 0}
@@ -137,18 +167,16 @@
{disabled}
{disablePortal}
bind:value={
() => valueSelect?.value,
() => value,
(v) => {
valueSelect = collection.find((x) => x.value === v)
value = v
valueType = valueSelect?.type
valueType = collection.find((x) => x?.value == v)?.type
}
}
onClear={() => {
initialValue = undefined
value = undefined
valueType = undefined
valueSelect = undefined
dispatch('clear')
}}
items={collection}
@@ -239,6 +239,21 @@
})
$effect.pre(() => {
;[schema, args]
if (args && typeof args == 'object') {
let oneShowExpr = false
for (const key of fields) {
if (schema.properties?.[key.value]?.showExpr) {
oneShowExpr = true
}
}
if (!oneShowExpr) {
return
}
for (const key in args) {
args[key]
}
}
untrack(() => handleHiddenFields(schema, args ?? {}))
})
$effect.pre(() => {
@@ -232,6 +232,9 @@
})
async function loadTriggers() {
if (!initialPath) {
return
}
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path: initialPath
@@ -1082,6 +1085,7 @@
<div class="mt-3">
<AIFormSettings
bind:prompt={script.schema.prompt_for_ai as string | undefined}
type="script"
/>
</div>
{/if}
+5 -2
View File
@@ -114,7 +114,7 @@
}
return items2
})
let valueEntry = $derived(value && processedItems?.find((item) => item.value === value))
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
function setValue(item: ProcessedItem) {
if (item.__is_create && onCreateItem) {
@@ -257,7 +257,10 @@
itemIndex === keyArrowPos ? 'bg-surface-hover' : '',
item.value === value ? 'bg-surface-selected' : 'hover:bg-surface-hover'
)}
onclick={() => setValue(item)}
onclick={(e) => {
e.stopImmediatePropagation()
setValue(item)
}}
>
{item.label}
</button>
@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler, preventDefault } from 'svelte/legacy'
const bubble = createBubbler()
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
@@ -6,7 +9,7 @@
import LogViewer from './LogViewer.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick } from 'svelte'
import { onDestroy, tick, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
@@ -15,23 +18,30 @@
import AnsiUp from 'ansi_up'
import { scroll_into_view_if_needed_polyfill } from './multiselect/utils'
import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte'
import Select from './Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
export let searchTerm: string
export let queryParseErrors: string[] = []
interface Props {
searchTerm: string
queryParseErrors?: string[]
}
let minTs: undefined | string = undefined
let maxTs: undefined | string = undefined
let { searchTerm, queryParseErrors = $bindable() }: Props = $props()
let minTsManual: undefined | string = undefined
let maxTsManual: undefined | string = undefined
let minTs: undefined | string = $state(undefined)
let maxTs: undefined | string = $state(undefined)
let max_lines: undefined | number = undefined
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
let max_lines: undefined | number = $state(undefined)
// let lastSeen: undefined | string = undefined
let withError = false
let autoRefresh = true
let loading = false
let withError = $state(false)
let autoRefresh = $state(true)
let loading = $state(false)
type LogFile = {
ts: number
@@ -45,15 +55,13 @@
type ByWorkerGroup = Record<string, ByHostname>
type ByMode = Record<string, ByWorkerGroup>
let timeout: NodeJS.Timeout | undefined = undefined
let timeout: NodeJS.Timeout | undefined = $state(undefined)
let allLogs: ByMode | undefined = undefined
let manualPicker: ManuelDatePicker | undefined = undefined
let allLogs: ByMode | undefined = $state(undefined)
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
let upTo: undefined | string = undefined
let upToIsLatest = true
$: minTsManual || maxTsManual || onManualChanges()
let upTo: undefined | string = $state(undefined)
let upToIsLatest = $state(true)
function onManualChanges() {
getAllLogs(minTsManual ?? maxTs, maxTsManual)
@@ -161,9 +169,20 @@
})
}
let selected: [string, string, string] | undefined = undefined
type Selected = { mode: string; workerGroup: string; hostname: string }
let initialSelected =
$page.url.searchParams.get('mode') &&
$page.url.searchParams.get('workerGroup') &&
$page.url.searchParams.get('hostname')
? {
mode: $page.url.searchParams.get('mode')!,
workerGroup: $page.url.searchParams.get('workerGroup')!,
hostname: $page.url.searchParams.get('hostname')!
}
: undefined
let selected: Selected | undefined = $state(initialSelected)
let logsContent: Record<string, { content?: string; error?: string }> = {}
let logsContent: Record<string, { content?: string; error?: string }> = $state({})
export async function getLogFile(hostname: string, path: string) {
if (logsContent[path]) {
return
@@ -178,11 +197,11 @@
getAllLogs(undefined, undefined)
function getLogs(selected: [string, string, string], upTo: string | undefined) {
function getLogs(selected: Selected, upTo: string | undefined) {
if (!selected) {
return []
}
let logs = allLogs?.[selected[0]]?.[selected[1]]?.[selected[2]]
let logs = allLogs?.[selected.mode]?.[selected.workerGroup]?.[selected.hostname]
if (!logs) {
return []
}
@@ -192,7 +211,7 @@
logs = nlogs.slice(nlogs.length - 5, undefined)
getFiles(
selected[2],
selected.hostname,
logs.map((x) => x.file_path)
)
}
@@ -204,11 +223,11 @@
scrollToBottom()
}
function getLatestUpTo(selected: [string, string, string]): any {
function getLatestUpTo(selected: Selected): any {
if (!selected) {
return undefined
}
let logs = allLogs?.[selected[0]]?.[selected[1]]?.[selected[2]]
let logs = allLogs?.[selected.mode]?.[selected.workerGroup]?.[selected.hostname]
if (!logs) {
return undefined
}
@@ -277,23 +296,31 @@
}
}
let logs: any
let logs: any = $state()
let debounceTimeout: NodeJS.Timeout | undefined = undefined
const debouncePeriod: number = 400
let loadingLogs = false
let loadingLogCounts = false
let loadingLogs = $state(false)
let loadingLogCounts = $state(false)
let countsPerHost: any
let sumOtherDocCount: number = 0
let countsPerHost: any = $state()
let sumOtherDocCount: number = $state(0)
async function searchLogs(
searchTerm: string,
selected: [string, string, string] | undefined,
selected: Selected | undefined,
minTs: string | undefined,
maxTs: string | undefined,
allLogs: ByMode | undefined
) {
const params = new URLSearchParams()
if (searchTerm) params.set('searchTerm', searchTerm)
if (minTs) params.set('minTs', minTs)
if (maxTs) params.set('maxTs', maxTs)
if (selected?.mode) params.set('mode', selected.mode)
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
if (selected?.hostname) params.set('hostname', selected.hostname)
goto(`?${params.toString()}`)
if (searchTerm.trim() === '') {
debounceTimeout && clearTimeout(debounceTimeout)
logs = undefined
@@ -333,9 +360,9 @@
if (selected) {
logs = await IndexSearchService.searchLogsIndex({
searchQuery: searchTerm,
mode: selected[0],
workerGroup: selected[1] != '' ? selected[1] : undefined,
hostname: selected[2],
mode: selected.mode,
workerGroup: selected.workerGroup != '' ? selected.workerGroup : undefined,
hostname: selected.hostname,
minTs,
maxTs
})
@@ -348,10 +375,10 @@
const ansi_up = new AnsiUp()
ansi_up.use_classes = true
let logDrawer: Drawer
let logDrawerOpen: boolean
let content: string = ''
let hitLineNumber: number | undefined = undefined
let logDrawer: Drawer | undefined = $state(undefined)
let logDrawerOpen: boolean = $state(false)
let content: string = $state('')
let hitLineNumber: number | undefined = $state(undefined)
async function seeLogContext(
lineNumber: number,
@@ -370,8 +397,6 @@
if (el) scroll_into_view_if_needed_polyfill(el, false)
}
$: searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
function allLogsOrQueryResults(allLogs: ByMode, countsPerHost: any): ByMode {
if (countsPerHost == undefined) {
return allLogs
@@ -395,6 +420,28 @@
return ret
}
function getSelectItems(
allLogs: ByMode,
countsPerHost: any
): { label: string; value: Selected }[] {
return Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)).flatMap(([mode, o1]) =>
Object.entries(o1).flatMap(([wg, o2]) =>
Object.keys(o2).map((hn) => ({
label: hn,
value: { mode, workerGroup: wg, hostname: hn }
}))
)
)
}
$effect(() => {
minTsManual || maxTsManual || untrack(() => onManualChanges())
})
$effect(() => {
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
})
</script>
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
@@ -424,7 +471,7 @@
</Drawer>
<SplitPanesOrColumnOnMobile>
<svelte:fragment slot="left-pane">
{#snippet left_pane()}
<div class="p-1">
<div
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
@@ -551,6 +598,16 @@
>
</div>
{/if}
<div class="mr-0.5 mb-2">
<Select
bind:value={selected}
items={getSelectItems(allLogs, countsPerHost)}
onClear={() => {
selected = undefined
}}
placeholder="Select a service"
/>
</div>
{#each Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]}
<div class="w-full pb-8">
<h2 class="pb-2 text-2xl">{mode}s</h2>
@@ -561,7 +618,7 @@
{/if}
<div class="divide-y flex flex-col">
{#each Object.entries(o2).filter(([hn, files]) => {
if (selected && selected[0] === mode && selected[1] === wg && selected[2] === hn) {
if (selected && selected.mode === mode && selected.workerGroup === wg && selected.hostname === hn) {
return true
}
const hostKey = `${mode},${wg},${hn}`
@@ -571,17 +628,17 @@
return true
}) as [hn, files]}
{@const hostKey = `${mode},${wg},${hn}`}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex items-baseline justify-between rounded px-1 hover:bg-surface-hover cursor-pointer {selected &&
selected[0] == mode &&
selected[1] == wg &&
selected[2] == hn
selected.mode == mode &&
selected.workerGroup == wg &&
selected.hostname == hn
? 'bg-surface-secondary'
: ''}"
on:click={() => {
selected = [mode, wg, hn]
onclick={() => {
selected = { mode, workerGroup: wg, hostname: hn }
upToIsLatest = true
upTo = getLatestUpTo(selected)
scrollToBottom()
@@ -631,8 +688,8 @@
{/if}
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="right-pane">
{/snippet}
{#snippet right_pane()}
<div class="relative h-full flex flex-col gap-1 pb-2">
{#if selected}
{#if !loadingLogs && logs == undefined}
@@ -707,9 +764,9 @@
>
{/if}
{:else if logsContent[file.file_path].content}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|preventDefault class="pr-2"
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={preventDefault(bubble('click'))} class="pr-2"
><LogViewer
noAutoScroll
noMaxH
@@ -735,7 +792,7 @@
<div class="flex grow text-xs justify-center px-2 items-center gap-2">
{#if upTo}
<button
on:click={() => {
onclick={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() - 5 * 60 * 1000).toISOString()
@@ -764,7 +821,7 @@
>
{#if upTo}
<button
on:click={() => {
onclick={() => {
if (upTo) {
upToIsLatest = false
upTo = new Date(new Date(upTo).getTime() + 5 * 60 * 1000).toISOString()
@@ -778,7 +835,7 @@
<div>
<button
class="text-xs"
on:click={() => {
onclick={() => {
upTo = new Date().toISOString()
upToIsLatest = true
}}>now</button
@@ -790,5 +847,5 @@
<div class="flex justify-center items-center pt-8">Select a host to see its logs</div>
{/if}</div
>
</svelte:fragment>
{/snippet}
</SplitPanesOrColumnOnMobile>
@@ -115,7 +115,7 @@
tailwindClasses = [],
class: className = '',
loadAsync = false
} = $props<{
}: {
lang: string
code?: string
hash?: string
@@ -137,7 +137,7 @@
tailwindClasses?: string[]
class?: string
loadAsync?: boolean
}>()
} = $props()
const dispatch = createEventDispatcher()
@@ -156,11 +156,18 @@
}
}
export function setCode(ncode: string): void {
export function setCode(ncode: string, formatCode?: boolean): void {
if (ncode != code) {
code = ncode
}
editor?.setValue(ncode)
if (formatCode) {
format()
}
}
export function formatCode(): void {
format()
}
function updateCode() {
@@ -336,7 +343,7 @@
// })
// }
try {
model = meditor.createModel(code, lang, mUri.parse(uri))
model = meditor.createModel(code ?? '', lang, mUri.parse(uri))
} catch (err) {
console.log('model already existed', err)
const nmodel = meditor.getModel(mUri.parse(uri))
@@ -354,7 +361,7 @@
}
try {
editor = meditor.create(divEl as HTMLDivElement, {
...editorConfig(code, lang, automaticLayout, fixedOverflowWidgets),
...editorConfig(code ?? '', lang, automaticLayout, fixedOverflowWidgets),
model,
lineDecorationsWidth: 6,
lineNumbersMinChars: 2,
@@ -539,7 +546,7 @@
})
}
let previousExtraLib = undefined
let previousExtraLib: string | undefined = undefined
function loadExtraLib() {
if (lang == 'javascript') {
const stdLib = { content: libStdContent, filePath: 'es6.d.ts' }
@@ -602,11 +609,11 @@
}
}
updatePlaceholderVisibility(code)
updatePlaceholderVisibility(code ?? '')
</script>
<EditorTheme />
{#if editor && suggestion && code.length === 0}
{#if editor && suggestion && code?.length === 0}
<div
class="absolute top-[0.05rem] left-[2.05rem] z-10 text-sm text-[#0007] italic font-mono dark:text-[#ffffff56] text-ellipsis overflow-hidden whitespace-nowrap"
style={`max-width: calc(${width}px - 2.05rem)`}
@@ -7,13 +7,13 @@
onTrigger,
children,
showAnimation = true
} = $props<{
}: {
id: string | undefined
description: string | undefined
onTrigger?: (value?: string) => void // Function to call when the trigger is activated, if not provided, the component is discoverable for information purposes only
children?: () => any
showAnimation?: boolean
}>()
} = $props()
let isAnimating = $state(false)
+19 -19
View File
@@ -9,7 +9,7 @@
import { createEventDispatcher } from 'svelte'
import { sendUserToast } from '$lib/toast'
import { emptyString, pluralize } from '$lib/utils'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { enterpriseLicense, superadmin, devopsRole } from '$lib/stores'
import Tooltip from './Tooltip.svelte'
import Editor from './Editor.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -155,7 +155,7 @@
let vcpus_memory = $derived(computeVCpuAndMemory(workers))
let selected = $derived(nconfig?.dedicated_worker != undefined ? 'dedicated' : 'normal')
$effect(() => {
$superadmin && listWorkspaces()
($superadmin || $devopsRole) && listWorkspaces()
})
</script>
@@ -209,7 +209,7 @@
<Drawer bind:this={drawer} size="800px">
<DrawerContent
on:close={() => drawer?.closeDrawer()}
title={$superadmin ? `Edit worker config '${name}'` : `Worker config '${name}'`}
title={($superadmin || $devopsRole) ? `Edit worker config '${name}'` : `Worker config '${name}'`}
>
{#if !$enterpriseLicense}
<Alert type="warning" title="Worker management UI is EE only">
@@ -433,7 +433,7 @@
{:else if selected == 'dedicated'}
{#if nconfig?.dedicated_worker != undefined}
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
placeholder="<workspace>:<script path>"
type="text"
onchange={() => {
@@ -442,7 +442,7 @@
}}
bind:value={nconfig.dedicated_worker}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="py-2"
><Alert
type="info"
@@ -471,11 +471,11 @@
<div class="flex gap-1 items-center">
<input
type="text"
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
placeholder="/path/to/python3.X/site-packages"
bind:value={nconfig.additional_python_paths![i]}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -497,7 +497,7 @@
</div>
{/each}
{/if}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -523,12 +523,12 @@
{#each nconfig.pip_local_dependencies as _, i}
<div class="flex gap-1 items-center">
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
type="text"
placeholder="httpx"
bind:value={nconfig.pip_local_dependencies[i]}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -550,7 +550,7 @@
</div>
{/each}
{/if}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -584,7 +584,7 @@
{#each customEnvVars as envvar, i}
<div class="flex gap-1 items-center">
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
type="text"
placeholder="ENV_VAR_NAME"
bind:value={envvar.key}
@@ -593,7 +593,7 @@
}}
/>
<ToggleButtonGroup
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
class="w-128"
bind:selected={envvar.type}
on:selected={(e) => {
@@ -610,13 +610,13 @@
</ToggleButtonGroup>
<input
type="text"
disabled={!$superadmin || envvar.type === 'dynamic'}
disabled={!($superadmin || $devopsRole) || envvar.type === 'dynamic'}
placeholder={envvar.type === 'dynamic'
? 'value read from worker env var'
: 'static value'}
bind:value={envvar.value}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -639,7 +639,7 @@
{/if}
</div>
{/each}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -657,7 +657,7 @@
</div>
{/if}
</div>
{#if !superadmin}
{#if !($superadmin || $devopsRole)}
<div class="flex flex-wrap items-center gap-1 pt-2">
<Button
variant="contained"
@@ -745,7 +745,7 @@
>
{/if}
<Editor
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
@@ -824,7 +824,7 @@
}}
disabled={(!dirty && nconfig?.dedicated_worker == undefined) ||
!$enterpriseLicense ||
!$superadmin}
!($superadmin || $devopsRole)}
>
Apply changes
</Button>
@@ -0,0 +1,45 @@
<script lang="ts">
import { WorkerService } from '$lib/gen'
import { ExternalLink, Loader2 } from 'lucide-svelte'
type Props = {
worker: string
minTs?: string
}
let { worker, minTs }: Props = $props()
let instances = $state(undefined) as
| Record<string, { hostname: string; worker_group: string }>
| undefined
async function loadWorkers() {
const res = await WorkerService.listWorkers({})
instances = res.reduce(
(acc, worker) => {
acc[worker.worker] = { hostname: worker.worker_instance, worker_group: worker.worker_group }
return acc
},
{} as Record<string, { hostname: string; worker_group: string }>
)
}
loadWorkers()
</script>
{#if instances == undefined}
<Loader2 />
{:else if instances[worker]}
host: {instances[worker].hostname}
<br />
worker group: {instances[worker].worker_group}
<br />
<a
href="/service_logs?mode=worker&workerGroup={instances[worker]
.worker_group}&hostname={instances[worker].hostname}{minTs
? `&minTs=${encodeURIComponent(minTs)}`
: ''}"
target="_blank"
>
service logs <ExternalLink size={14} class="inline-block" />
</a>
{/if}
@@ -10,12 +10,12 @@
noLabel = false,
nullTag = undefined,
disabled = false
} = $props<{
}: {
tag: string | undefined
noLabel?: boolean
nullTag?: string | undefined
disabled?: boolean
}>()
} = $props()
loadWorkerGroups()
@@ -1,20 +1,36 @@
<script lang="ts">
import InputValue from './InputValue.svelte'
import type { RichConfiguration } from '../../types'
import { untrack } from 'svelte'
export let id: string
export let extraKey: string = ''
export let key: string
export let resolvedConfig: any | { type: 'oneOf'; configuration: any; selected: string }
export let configuration: RichConfiguration
export let initialConfig: RichConfiguration | undefined = undefined
$: configuration?.type == 'oneOf' && handleSelected(configuration.selected)
interface Props {
id: string
extraKey?: string
key: string
resolvedConfig: any | { type: 'oneOf'; configuration: any; selected: string }
configuration: RichConfiguration
initialConfig?: RichConfiguration | undefined
}
let {
id,
extraKey = '',
key,
resolvedConfig = $bindable(),
configuration,
initialConfig = undefined
}: Props = $props()
function handleSelected(selected: string) {
if (resolvedConfig?.selected != undefined && resolvedConfig?.selected != selected) {
resolvedConfig.selected = selected
}
}
$effect(() => {
configuration?.type == 'oneOf' &&
configuration.selected &&
untrack(() => handleSelected(configuration.selected))
})
</script>
{#if configuration?.type == 'oneOf' && resolvedConfig?.type == 'oneOf'}
@@ -7,11 +7,11 @@
import InputValue from '../helpers/InputValue.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
let { id, configuration, render } = $props<{
let { id, configuration, render }: {
id: string
configuration: RichConfigurations
render: boolean
}>()
} = $props()
const { componentControl, worldStore, selectedComponent, connectingInput, mode } =
getContext<AppViewerContext>('AppViewerContext')
@@ -48,7 +48,6 @@
})
function onFocus() {
console.log('focus 2', id)
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
@@ -24,14 +24,14 @@
let { id, componentContainerHeight, customCss = undefined, render, nodes }: Props = $props()
let resolvedConditions = $derived(
let resolvedConditions = $state(
nodes.reduce((acc, node) => {
acc[node.id] = acc[node.id] || []
return acc
}, {})
)
let resolvedNext = $derived(
let resolvedNext = $state(
nodes.reduce((acc, node) => {
acc[node.id] = acc[node.id] || false
return acc
@@ -165,6 +165,8 @@
})
</script>
<!-- {JSON.stringify(resolvedConditions)}
{JSON.stringify(resolvedNext)} -->
{#if Object.keys(resolvedConditions).length === nodes.length}
{#each nodes ?? [] as node (node.id)}
{#each node.next ?? [] as next, conditionIndex}
@@ -298,7 +298,12 @@
}
} catch {}
} else {
$focusedGrid = undefined
const drawerAlreadyHandledFocusedGrid =
item?.data.type === 'drawercomponent' &&
$focusedGrid?.parentComponentId === befSelected
if (!drawerAlreadyHandledFocusedGrid) {
$focusedGrid = undefined
}
}
}
}
@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import Dropdown from '$lib/components/DropdownV2.svelte'
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
@@ -7,18 +10,24 @@
import { isDebugging } from './settingsPanel/decisionTree/utils'
import { X, Bug } from 'lucide-svelte'
export let nodes: DecisionTreeNode[] = []
export let id: string
export let isSmall = false
export let componentIsDebugging = false
interface Props {
nodes?: DecisionTreeNode[]
id: string
isSmall?: boolean
componentIsDebugging?: boolean
}
$: componentIsDebugging = isDebugging($debuggingComponents, id)
let { nodes = [], id, isSmall = false, componentIsDebugging = $bindable(false) }: Props = $props()
$effect(() => {
componentIsDebugging = isDebugging($debuggingComponents, id)
})
const { componentControl, debuggingComponents, worldStore } =
getContext<AppViewerContext>('AppViewerContext')
const dispatch = createEventDispatcher()
let currentNodeId: string = $worldStore.outputsById[id]?.currentNodeId?.peak() ?? 'a'
let currentNodeId: string = $state($worldStore.outputsById[id]?.currentNodeId?.peak() ?? 'a')
function subscribeToCurrentNode(id: string) {
return $worldStore.outputsById[id]?.currentNodeId?.subscribe(
@@ -49,9 +58,11 @@
}
}
$: onDebugNode($debuggingComponents[id])
$effect(() => {
onDebugNode($debuggingComponents[id])
})
let renderCount: number = 0
let renderCount: number = $state(0)
let lastNodes: DecisionTreeNode[] = nodes
function onNodesChange(newNodes: DecisionTreeNode[]) {
@@ -67,7 +78,9 @@
}
}
$: onNodesChange(nodes)
$effect(() => {
onNodesChange(nodes)
})
async function getDropdownItems() {
return [
@@ -97,7 +110,7 @@
{#key renderCount}
<Dropdown items={getDropdownItems} class="w-fit h-auto" usePointerDownOutside={true}>
<svelte:fragment slot="buttonReplacement">
{#snippet buttonReplacement()}
<button
title={'Debug tabs'}
class={classNames(
@@ -106,15 +119,15 @@
? ' hover:bg-red-300 hover:text-red-800'
: 'text-blue-600 hover:bg-blue-300 hover:text-blue-800'
)}
on:click={() => dispatch('triggerInlineEditor')}
on:pointerdown|stopPropagation
onclick={() => dispatch('triggerInlineEditor')}
onpointerdown={stopPropagation(bubble('pointerdown'))}
>
{#if componentIsDebugging}
<div class="flex flex-row items-center gap-2">
{`${isSmall ? '' : 'Debugging node'} ${nodes[$debuggingComponents[id] ?? 0]?.id}`}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
on:click={() => {
onclick={() => {
$componentControl?.[id]?.setTab?.(0)
$debuggingComponents = Object.fromEntries(
@@ -131,6 +144,6 @@
>{`Debug nodes (current node: ${currentNodeId})`}</div
>{/if}
</button>
</svelte:fragment>
{/snippet}
</Dropdown>
{/key}
@@ -48,6 +48,7 @@
$: containerHeight = getContainerHeight(items, yPerPx, getComputedCols)
const onResize = throttle(() => {
if (!getComputedCols) return
items = specifyUndefinedColumns(items, getComputedCols, cols)
dispatch('resize', {
cols: getComputedCols,
@@ -75,6 +76,7 @@
xPerPx = width / getComputedCols!
if (!containerWidth) {
if (!getComputedCols) return
items = specifyUndefinedColumns(items, getComputedCols, cols)
dispatch('mount', {
@@ -233,12 +233,13 @@
<button
id={item}
on:pointerdown={async (e) => {
e.preventDefault()
const id = addComponent(item)
dndTimeout && clearTimeout(dndTimeout)
dndTimeout = setTimeout(async () => {
await tick()
$dndItem[id]?.(e.clientX, e.clientY, $yTop)
}, 75)
}, 100)
window.addEventListener('pointerup', (e) => {
dndTimeout && clearTimeout(dndTimeout)
dndTimeout = undefined
@@ -48,7 +48,6 @@
async function refreshScript(runnable: RunnableByPath) {
try {
let { schema } = await getScriptByPath(runnable.path)
console.log('schema1', schema)
if (!deepEqual(runnable.schema, schema)) {
runnable.schema = schema
if (!runnable.schema.order) {
@@ -351,7 +351,6 @@
parameters this component is attached to.
</Tooltip>
</div>
<InputsSpecsEditor
id={component.id}
shouldCapitalize={false}
@@ -57,7 +57,13 @@
</div>
</Pane>
<Pane size={40}>
<div class="h-full w-full bg-surface p-4 flex flex-col gap-6">
<div
class="h-full w-full bg-surface p-4 flex flex-col gap-6"
on:keydown={(e) => {
// Prevent keyboard events from bubbling to SvelteFlow
e.stopPropagation()
}}
>
{#if selectedNode}
<Section label="Conditions" class="w-full flex flex-col gap-2">
<svelte:fragment slot="action">
@@ -92,6 +98,10 @@
renderCount++
}, 300)()
}}
on:keydown={(e) => {
// Prevent keyboard events from bubbling to SvelteFlow
e.stopPropagation()
}}
/>
</Label>
@@ -5,12 +5,12 @@
import { twMerge } from 'tailwind-merge'
let { items, value = $bindable(), title, tooltip } = $props<{
let { items, value = $bindable(), title, tooltip }: {
items: string[]
value: string[] | undefined
title: string
tooltip: string
}>()
} = $props()
let width = $state(0)
const inputWidth = 280
@@ -9,28 +9,30 @@
import Alert from '$lib/components/common/alert/Alert.svelte'
import { twMerge } from 'tailwind-merge'
import type { AppViewerContext, RichConfiguration } from '../../types'
import { getContext, tick } from 'svelte'
import { getContext, tick, untrack } from 'svelte'
import { deleteGridItem } from '../appUtils'
import type { AppComponent } from '../component'
export let conditions: RichConfiguration[] = []
export let component: AppComponent
interface Props {
conditions?: RichConfiguration[]
component: AppComponent
}
let items = conditions.slice(0, -1).map((condition, index) => {
return { value: condition, id: generateRandomString(), originalIndex: index }
let { conditions = $bindable([]), component = $bindable() }: Props = $props()
let items = $state(
conditions.slice(0, -1).map((condition, index) => {
return { value: condition, id: generateRandomString(), originalIndex: index }
})
)
$effect(() => {
const nItems = items
.map((item) => item.value)
.concat([{ type: 'evalv2', expr: 'true', fieldType: 'boolean', connections: [] }])
untrack(() => (conditions = nItems))
})
$: conditions = items
.map((item) => item.value)
.concat([
{
type: 'evalv2',
expr: 'true',
fieldType: 'boolean',
connections: []
}
])
const { app, runnableComponents, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
@@ -141,8 +143,8 @@
flipDurationMs: 200,
dropTargetStyle: {}
}}
on:consider={handleConsider}
on:finalize={handleFinalize}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each items as item, index (item.id)}
{@const condition = item.value}
@@ -168,14 +170,14 @@
</div>
<div class="flex flex-col justify-center gap-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={() => deleteSubgrid(index)}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => deleteSubgrid(index)}>
<X size={16} />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div use:dragHandle class="w-4 h-4 handle" aria-label="drag-handle">
<GripVertical size={16} />
</div>
@@ -51,7 +51,7 @@
{#if inputSpecs}
<div class="w-full flex flex-col gap-4">
{#each Object.keys(finalInputSpecsConfiguration) as k}
{#each Object.keys(finalInputSpecsConfiguration) as k (k)}
{#if overridenByComponent.includes(k)}
<div>
<span class="text-xs font-semibold truncate text-primary">
@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import type { InputType, StaticInput, StaticOptions } from '../../../inputType'
import ArrayStaticInputEditor from '../ArrayStaticInputEditor.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
@@ -26,32 +29,61 @@
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
export let componentInput: StaticInput<any> | undefined
export let fieldType: InputType | undefined = undefined
export let subFieldType: InputType | undefined = undefined
export let selectOptions: StaticOptions['selectOptions'] | undefined = undefined
export let placeholder: string | undefined = undefined
export let format: string | undefined = undefined
export let id: string | undefined
interface Props {
componentInput: StaticInput<any> | undefined
fieldType?: InputType | undefined
subFieldType?: InputType | undefined
selectOptions?: StaticOptions['selectOptions'] | undefined
placeholder?: string | undefined
format?: string | undefined
id: string | undefined
}
let {
componentInput = $bindable(),
fieldType = undefined,
subFieldType = undefined,
selectOptions = undefined,
placeholder = undefined,
format = undefined,
id
}: Props = $props()
const appContext = getContext<AppViewerContext>('AppViewerContext')
$: componentInput && appContext?.onchange?.()
let s3FileUploadRawMode = false
let s3FilePicker: S3FilePicker | undefined = undefined
$effect(() => {
componentInput && appContext?.onchange?.()
})
let s3FileUploadRawMode = $state(false)
let s3FilePicker: S3FilePicker | undefined = $state(undefined)
</script>
{#key subFieldType}
{#if componentInput?.type === 'static'}
{#if fieldType === 'number' || fieldType === 'integer'}
<input on:keydown|stopPropagation type="number" bind:value={componentInput.value} />
<input
onkeydown={stopPropagation(bubble('keydown'))}
type="number"
bind:value={componentInput.value}
/>
{:else if fieldType === 'textarea'}
<textarea use:autosize on:keydown|stopPropagation bind:value={componentInput.value}
<textarea
use:autosize
onkeydown={stopPropagation(bubble('keydown'))}
bind:value={componentInput.value}
></textarea>
{:else if fieldType === 'date'}
<input on:keydown|stopPropagation type="date" bind:value={componentInput.value} />
<input
onkeydown={stopPropagation(bubble('keydown'))}
type="date"
bind:value={componentInput.value}
/>
{:else if fieldType === 'time'}
<input on:keydown|stopPropagation type="time" bind:value={componentInput.value} />
<input
onkeydown={stopPropagation(bubble('keydown'))}
type="time"
bind:value={componentInput.value}
/>
{:else if fieldType === 'datetime'}
<DateTimeInput bind:value={componentInput.value} />
{:else if fieldType === 'boolean'}
@@ -60,7 +92,7 @@
{#if subFieldType === 'db-table'}
<DBTableSelect bind:componentInput {selectOptions} {id} />
{:else}
<select on:keydown|stopPropagation bind:value={componentInput.value}>
<select onkeydown={stopPropagation(bubble('keydown'))} bind:value={componentInput.value}>
{#each selectOptions ?? [] as option}
{#if typeof option == 'string'}
<option value={option}>
@@ -114,7 +146,7 @@
{#if componentInput?.value && typeof componentInput?.value == 'object' && 'label' in componentInput?.value && (componentInput.value?.['value'] == undefined || typeof componentInput.value?.['value'] == 'string')}
<div class="flex flex-col gap-1 w-full">
<input
on:keydown|stopPropagation
onkeydown={stopPropagation(bubble('keydown'))}
placeholder="Label"
type="text"
bind:value={componentInput.value['label']}
@@ -395,13 +427,21 @@
</div>
</div>
{:else if fieldType === 'app-path'}
<AppPicker bind:value={componentInput.value} />
<AppPicker
bind:value={
() => componentInput!.value,
(v) => {
componentInput!.value = v
componentInput = $state.snapshot(componentInput)
}
}
/>
{:else}
<div class="flex gap-1 relative w-full">
<textarea
rows="1"
use:autosize
on:keydown|stopPropagation
onkeydown={stopPropagation(bubble('keydown'))}
placeholder={placeholder ?? 'Static value'}
bind:value={componentInput.value}
class="!pr-12"
@@ -34,7 +34,6 @@
subGridIndexKey,
type GridShadow
} from '../editor/appUtils'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
const dispatch = createEventDispatcher()
@@ -86,6 +85,7 @@
let yPerPx = rowHeight
const onResize = throttle(() => {
if (!getComputedCols) return
sortedItems = specifyUndefinedColumns(sortedItems, getComputedCols, cols)
dispatch('resize', {
cols: getComputedCols,
@@ -114,7 +114,7 @@
}
xPerPx = width / getComputedCols!
if (!containerWidth) {
if (!containerWidth && getComputedCols) {
sortedItems = specifyUndefinedColumns(sortedItems, getComputedCols, cols)
dispatch('mount', {
@@ -158,9 +158,7 @@
? items.map((item) => {
return {
...item,
[getComputedCols as number]: structuredClone(
stateSnapshot(item[getComputedCols as number])
)
[getComputedCols as number]: { ...item[getComputedCols as number] }
}
})
: []
@@ -8,7 +8,7 @@ import {
} from './matrix'
import { getRowsCount } from './other'
export function getItemById(id, items) {
export function getItemById<T>(id: string, items: FilledItem<T>[]) {
return items.find((value) => value.id === id)
}
@@ -25,7 +25,7 @@ export function isEmpty(matrix: any[][], x: number, y: number, w: number, h: num
return true
}
function distance(a, b): number {
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.abs(a.x - b.x + 0.25) + Math.abs(a.y - b.y + 0.25)
}
@@ -65,11 +65,12 @@ export function findFreeSpaceForItem<T>(matrix: FilledItem<T>[][], item: ItemLay
}
}
const getItem = (item, col) => {
return { ...item[col] }
}
const updateItem = (elements, active, position, col) => {
const updateItem = <T>(
elements: FilledItem<T>[],
active: FilledItem<T>,
position: { x: number; y: number },
col: number
) => {
return elements.map((value) => {
if (value.id === active.id) {
return { ...value, [col]: { ...value[col], ...position } }
@@ -119,9 +120,9 @@ const updateItem = (elements, active, position, col) => {
// return tempItems
// }
export function moveItem(active, items, cols) {
export function moveItem<T>(active: FilledItem<T>, items: FilledItem<T>[], cols: number) {
// Get current item from the breakpoint
const item = getItem(active, cols)
const item = { ...active[cols] }
// console.log(JSON.stringify(item), JSON.stringify(active), JSON.stringify(cols), 3, cols)
// Create matrix from the items expect the active
@@ -219,7 +220,7 @@ export function adjust<T>(items: FilledItem<T>[], col) {
return res
}
export function getUndefinedItems(items, col, breakpoints) {
export function getUndefinedItems<T>(items: FilledItem<T>[], col: number) {
return items
.map((value) => {
if (!value[col]) {
@@ -229,7 +230,11 @@ export function getUndefinedItems(items, col, breakpoints) {
.filter(Boolean)
}
export function getClosestColumn(items, item, col, breakpoints) {
export function getClosestColumn<T>(
item: FilledItem<T>,
col: number,
breakpoints: [number, number][]
) {
return breakpoints
.map(([_, column]) => item[column] && column)
.filter(Boolean)
@@ -240,31 +245,36 @@ export function getClosestColumn(items, item, col, breakpoints) {
})
}
export function specifyUndefinedColumns(items, col, breakpoints) {
export function specifyUndefinedColumns<T>(
items: FilledItem<T>[],
col: number,
breakpoints: [number, number][]
) {
let matrix = makeMatrixFromItems(items, getRowsCount(items, col), col)
const getUndefinedElements = getUndefinedItems(items, col, breakpoints)
const getUndefinedElements = getUndefinedItems(items, col)
let newItems = [...items]
getUndefinedElements.forEach((elementId) => {
const getElement = items.find((item) => item.id === elementId)
if (getElement) {
const closestColumn = getClosestColumn(getElement, col, breakpoints)
const closestColumn = getClosestColumn(items, getElement, col, breakpoints)
const position = findFreeSpaceForItem(matrix, getElement[closestColumn])
const position = findFreeSpaceForItem(matrix, getElement[closestColumn])
const newItem = {
...getElement,
[col]: {
...getElement[closestColumn],
...position
const newItem = {
...getElement,
[col]: {
...getElement[closestColumn],
...position
}
}
newItems = newItems.map((value) => (value.id === elementId ? newItem : value))
matrix = makeMatrixFromItems(newItems, getRowsCount(newItems, col), col)
}
newItems = newItems.map((value) => (value.id === elementId ? newItem : value))
matrix = makeMatrixFromItems(newItems, getRowsCount(newItems, col), col)
})
return newItems
}
@@ -1,65 +1,78 @@
import type { FilledItem } from "../types";
import type { FilledItem } from '../types'
export const makeMatrix: (w: number, h: number) => any[][] = (rows, cols) => Array.from(Array(rows), () => new Array(cols)); // make 2d array
export const makeMatrix: (w: number, h: number) => any[][] = (rows, cols) =>
Array.from(Array(rows), () => new Array(cols)) // make 2d array
export function makeMatrixFromItems<T>(items: FilledItem<T>[], row: number, col: number): FilledItem<T>[][] {
let matrix = makeMatrix(row, col);
export function makeMatrixFromItems<T>(
items: FilledItem<T>[],
row: number,
col: number
): FilledItem<T>[][] {
let matrix = makeMatrix(row, col)
for (var i = 0; i < items.length; i++) {
const value = items[i][col];
if (value) {
const { x, y, h } = value;
const id = items[i].id;
const w = Math.min(col, value.w);
for (var i = 0; i < items.length; i++) {
const value = items[i][col]
if (value) {
const { x, y, h } = value
const id = items[i].id
const w = Math.min(col, value.w)
for (var j = y; j < y + h; j++) {
const row = matrix[j];
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id };
}
}
}
}
return matrix;
for (var j = y; j < y + h; j++) {
const row = matrix[j]
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id }
}
}
}
}
return matrix
}
export function findCloseBlocks<T>(matrix: FilledItem<T>[][], curObject) {
const { h, x, y } = curObject;
const { h, x, y } = curObject
const w = Math.min(matrix[0].length, curObject.w);
const tempR = matrix.slice(y, y + h);
const w = Math.min(matrix[0].length, curObject.w)
const tempR = matrix.slice(y, y + h)
let result: string[] = [];
for (var i = 0; i < tempR.length; i++) {
let tempA = tempR[i].slice(x, x + w);
result = [...result, ...tempA.map((val) => val?.id).filter((id) => id !== undefined && id !== curObject.id)];
}
let result: string[] = []
for (var i = 0; i < tempR.length; i++) {
let tempA = tempR[i].slice(x, x + w)
result = [
...result,
...tempA.map((val) => val?.id).filter((id) => id !== undefined && id !== curObject.id)
]
}
return [...new Set(result)];
return [...new Set(result)]
}
export function makeMatrixFromItemsIgnore(items, ignoreList, _row, _col) {
let matrix = makeMatrix(_row, _col);
for (var i = 0; i < items.length; i++) {
const value = items[i][_col];
const id = items[i].id;
const { x, y, h } = value;
const w = Math.min(_col, value.w);
export function makeMatrixFromItemsIgnore<T>(
items: FilledItem<T>[],
ignoreList: string[],
row: number,
col: number
) {
let matrix = makeMatrix(row, col)
for (var i = 0; i < items.length; i++) {
const value = items[i][col]
const id = items[i].id
const { x, y, h } = value
const w = Math.min(col, value.w)
if (ignoreList.indexOf(id) === -1) {
for (var j = y; j < y + h; j++) {
const row = matrix[j];
if (row) {
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id };
}
}
}
}
}
return matrix;
if (ignoreList.indexOf(id) === -1) {
for (var j = y; j < y + h; j++) {
const row = matrix[j]
if (row) {
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id }
}
}
}
}
}
return matrix
}
export function findItemsById<T>(closeBlocks: string[], items: FilledItem<T>[]) {
return items.filter((value) => closeBlocks.indexOf(value.id) !== -1);
return items.filter((value) => closeBlocks.indexOf(value.id) !== -1)
}
@@ -4,6 +4,7 @@
import Tooltip from '../Tooltip.svelte'
export let prompt: string | undefined = undefined
export let type: 'flow' | 'script' = 'script'
</script>
<div class="flex flex-col gap-2">
@@ -18,7 +19,7 @@
}
}}
options={{
right: 'Enable filling script inputs with AI'
right: `Enable filling ${type} inputs with AI`
}}
/>
{#if prompt !== undefined}
@@ -333,11 +333,8 @@
</script>
<div class="relative w-full px-2 scroll-pb-2">
<div
class="absolute top-0 left-0 w-full h-full min-h-12 px-4 text-sm pt-1 pointer-events-none"
style="line-height: 1.72"
>
<span class="break-words" style="white-space: pre-wrap;">
<div class="textarea-input absolute top-0 left-0 pointer-events-none">
<span class="break-words">
{@html getHighlightedText(aiChatManager.instructions)}
</span>
</div>
@@ -355,7 +352,7 @@
}, 200)
}}
placeholder={isFirstMessage ? 'Ask anything' : 'Ask followup'}
class="resize-none bg-transparent caret-black dark:caret-white"
class="textarea-input resize-none bg-transparent caret-black dark:caret-white"
style={aiChatManager.instructions.length > 0
? 'color: transparent; -webkit-text-fill-color: transparent;'
: ''}
@@ -381,3 +378,17 @@
/>
</div>
{/if}
<style>
.textarea-input {
padding: 0.25rem 1rem;
border: 1px solid transparent;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-words;
width: 100%;
min-height: 3rem;
}
</style>
@@ -37,6 +37,62 @@ export const AI_DEFAULT_MODELS: Record<AIProvider, string[]> = {
customai: []
}
export interface ModelResponse {
id: string
object: string
created: number
owned_by: string
lifecycle_status: string
capabilities: {
completion: boolean
chat_completion: boolean
}
}
export async function fetchAvailableModels(
resourcePath: string,
workspace: string,
provider: AIProvider
): Promise<string[]> {
const models = await fetch(`${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/models`, {
headers: {
'X-Resource-Path': resourcePath,
'X-Provider': provider,
...(provider === 'anthropic' ? { 'anthropic-version': '2023-06-01' } : {})
}
})
if (!models.ok) {
console.error('Failed to fetch models for provider', provider, models)
throw new Error(`Failed to fetch models for provider ${provider}`)
}
const data = (await models.json()) as { data: ModelResponse[] }
if (data.data.length > 0) {
switch (provider) {
case 'openai':
return data.data
.filter(
(m) => m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('codex')
)
.map((m) => m.id)
case 'azure_openai':
return data.data
.filter(
(m) =>
(m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('codex')) &&
m.lifecycle_status !== 'deprecated' &&
(m.capabilities.completion || m.capabilities.chat_completion)
)
.map((m) => m.id)
case 'googleai':
return data.data.map((m) => m.id.split('/')[1])
default:
return data.data.map((m) => m.id)
}
}
return data?.data.map((m) => m.id) ?? []
}
function getModelMaxTokens(model: string) {
if (model.startsWith('gpt-4.1')) {
return 32768
@@ -17,22 +17,37 @@
import TriggerableByAI from '../TriggerableByAI.svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
export let loading: boolean
export let disableStaticInputs = false
export let disableTutorials = false
export let disableAi = false
export let disableSettings = false
export let disabledFlowInputs = false
export let smallErrorHandler = false
export let newFlow: boolean = false
export let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
interface Props {
loading: boolean
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
disabledFlowInputs?: boolean
smallErrorHandler?: boolean
newFlow?: boolean
savedFlow?:
| (Flow & {
draft?: Flow | undefined
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
}
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined
let {
loading,
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
disabledFlowInputs = false,
smallErrorHandler = false,
newFlow = false,
savedFlow = undefined,
onDeployTrigger = () => {}
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),

Some files were not shown because too many files have changed in this diff Show More