mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-20 16:02:28 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530a72ba83 | ||
|
|
2a334421e8 | ||
|
|
c7c2efbbe5 | ||
|
|
b1c4f8b29d | ||
|
|
14005fe4c1 | ||
|
|
4326bb8dc9 | ||
|
|
18d12525d2 | ||
|
|
47d1ef0f1c | ||
|
|
86b5fab4dc | ||
|
|
a58b0ffd06 | ||
|
|
38a050b0f4 | ||
|
|
c088322159 | ||
|
|
079af9292c | ||
|
|
0f560bdc41 | ||
|
|
f97a61ecc8 | ||
|
|
be0c8ddae0 | ||
|
|
959280bf5a | ||
|
|
38b06bf3a7 | ||
|
|
f5f2f8f344 | ||
|
|
aba8c01d7f | ||
|
|
28a0209568 | ||
|
|
0cdff8acd1 | ||
|
|
e4534cabf5 | ||
|
|
5bdbaf149b | ||
|
|
51b3823f7b | ||
|
|
74de2397ce | ||
|
|
2d0e65b7ca | ||
|
|
84808e2694 | ||
|
|
8407ac148b | ||
|
|
7490e883d7 | ||
|
|
ad2de83354 | ||
|
|
8b7aefb3bc | ||
|
|
0f63d03093 | ||
|
|
38eb71bdf5 | ||
|
|
26bec054a3 | ||
|
|
d0ebb66d0d | ||
|
|
bc9893402b | ||
|
|
13ac13e0b7 | ||
|
|
1c6a7c8cd0 | ||
|
|
8e8e1a3129 | ||
|
|
babf046871 | ||
|
|
02a4949fd8 | ||
|
|
d2fa2e6464 | ||
|
|
d457bf5c80 | ||
|
|
2bc06b72d2 | ||
|
|
b106de5438 | ||
|
|
ed6d018253 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+35
@@ -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"
|
||||
}
|
||||
+26
-14
@@ -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"
|
||||
}
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
+25
-13
@@ -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"
|
||||
}
|
||||
+93
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
Generated
+301
-265
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -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 @@
|
||||
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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -63,7 +63,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.496.3";
|
||||
export const VERSION = "1.499.0";
|
||||
|
||||
const command = new Command()
|
||||
.name("wmill")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.496.3",
|
||||
"version": "1.499.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
-1
@@ -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}
|
||||
|
||||
+11
-1
@@ -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">
|
||||
|
||||
+58
-18
@@ -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
Reference in New Issue
Block a user