diff --git a/.github/workflows/aider-after-review.yaml b/.github/workflows/aider-after-review.yaml index 8347b1f9f2..87d48ddb4c 100644 --- a/.github/workflows/aider-after-review.yaml +++ b/.github/workflows/aider-after-review.yaml @@ -5,12 +5,42 @@ on: types: [submitted] jobs: - auto-fix-review: + check-membership: if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]') - runs-on: ubicloud-standard-8 + runs-on: ubicloud-standard-2 + outputs: + is_member: ${{ steps.check-membership.outputs.is_member }} + steps: + - name: Check organization membership + id: check-membership + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + REVIEWER: ${{ github.event.review.user.login }} + ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }} + run: | + ORG="windmill-labs" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $ORG_ACCESS_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/$ORG/members/$REVIEWER") + + if [ "$STATUS" -eq 204 ]; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "is_member=false" >> $GITHUB_OUTPUT + fi + + check-and-prepare: + needs: check-membership + if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]') && needs.check-membership.outputs.is_member == 'true' + runs-on: ubicloud-standard-2 permissions: contents: write pull-requests: write + outputs: + prompt_content: ${{ steps.prepare_prompt.outputs.prompt_content }} env: GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} @@ -19,200 +49,46 @@ jobs: WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} steps: - - name: Harden Runner - uses: step-security/harden-runner@v2 - with: - egress-policy: audit - - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Configure Git User - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Checkout PR Branch + - name: Acknowledge Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | - echo "PR review trigger: Checking out PR branch..." - PR_NUMBER=${{ github.event.pull_request.number }} - PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY) - if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then - echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI." - exit 1 - fi - echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER" - git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags - git checkout "$PR_HEAD_REF" - echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)" + echo "Commenting on PR #${{ github.event.pull_request.number }} to acknowledge the /aider command." + gh pr comment ${{ github.event.pull_request.number }} --body "🤖 Aider is starting to work on your request. Please be patient, this might take a few minutes." --repo $GITHUB_REPOSITORY - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install Aider and Dependencies - run: | - python -m pip install aider-install; aider-install - pip install -U google-generativeai - sudo apt-get update && sudo apt-get install -y jq - - - name: Generate Prompt from Review - id: generate_prompt + - name: Prepare prompt for Aider + id: prepare_prompt shell: bash + env: + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REVIEW_BODY: ${{ github.event.review.body }} run: | - mkdir -p .github/aider - PROMPT_FILE_PATH=".github/aider/review-prompt.txt" + REVIEW_BODY_ESCAPED="${REVIEW_BODY//\\/\\\\}" + REVIEW_BODY_ESCAPED="${REVIEW_BODY_ESCAPED//\"/\\\"}" - # Get PR review body - REVIEW_BODY="${{ github.event.review.body }}" - REVIEW_BODY_Q=$(printf '%q' "$REVIEW_BODY") - - PR_NUMBER="${{ github.event.pull_request.number }}" - - # Get PR description for context NOT USED FOR NOW - # PR_DETAILS=$(gh pr view $PR_NUMBER --json title,body --repo $GITHUB_REPOSITORY) - # PR_TITLE=$(echo "$PR_DETAILS" | jq -r .title) - # PR_BODY=$(echo "$PR_DETAILS" | jq -r .body) - - # Get all PR review comments ALL_REVIEW_COMMENTS=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments \ - | jq '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]') + /repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments) + + FORMATTED_COMMENTS=$(jq -r '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]' <<< "$ALL_REVIEW_COMMENTS") BASE_PROMPT="Fix the following issues in the PR based on the review feedback. The review body is prepended with REVIEW. The review comments are prepended with REVIEW_COMMENTS. The review body and comments are separated by a blank line." - COMPLETE_PROMPT=$(printf "%s\nREVIEW:\n%s\nREVIEW_COMMENTS:\n%s" \ - "$BASE_PROMPT" "$REVIEW_BODY_Q" "$ALL_REVIEW_COMMENTS") - echo "$COMPLETE_PROMPT" > "$PROMPT_FILE_PATH" - echo "PROMPT_FILE_PATH=$PROMPT_FILE_PATH" >> $GITHUB_OUTPUT - - name: Probe Chat for Relevant Files - id: probe_files - env: - PROMPT_CONTENT_FILE: ${{ steps.generate_prompt.outputs.PROMPT_FILE_PATH }} - run: | - echo "Running probe-chat to find relevant files..." - if [[ ! -f "$PROMPT_CONTENT_FILE" ]]; then - echo "::error::Prompt file $PROMPT_CONTENT_FILE not found!" - exit 1 - fi - PROMPT_CONTENT=$(cat "$PROMPT_CONTENT_FILE") - if [ -z "$PROMPT_CONTENT" ]; then - echo "::error::Prompt content is empty!" - exit 1 - fi + COMPLETE_PROMPT="${BASE_PROMPT}"$'\n'"REVIEW:"$'\n'"${REVIEW_BODY_ESCAPED}"$'\n'"REVIEW_COMMENTS:"$'\n'"${FORMATTED_COMMENTS}" - PROMPT_ESCAPED=$(jq -Rs . <<< "$PROMPT_CONTENT") + echo "prompt_content<> $GITHUB_OUTPUT + echo "$COMPLETE_PROMPT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT - MESSAGE_FOR_PROBE=$(jq -n --arg prompt_escaped "$PROMPT_ESCAPED" \ - '{ "message": "I'\''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST.\\nREQUEST: \($prompt_escaped). Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: [\"file1.py\", \"file2.py\"]" }' | jq -r .message) - - set -o pipefail - PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || { - echo "::error::probe-chat command failed. Output:" - echo "$PROBE_OUTPUT" - exit 1 - } - set +o pipefail - echo "Probe-chat raw output:" - echo "$PROBE_OUTPUT" - - JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q') - echo "Extracted JSON block:" - echo "$JSON_FILES" - - FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | map(@sh) | join(" ")' || echo "") - - if [[ -z "$FILES_LIST" ]]; then - echo "::warning::probe-chat did not identify any relevant files." - exit 1 - fi - - echo "Formatted files list for aider: $FILES_LIST" - echo "FILES_TO_EDIT=$FILES_LIST" >> $GITHUB_ENV - - - name: Run Aider with review prompt - run: | - aider \ - --read .cursor/rules/rust-best-practices.mdc \ - --read .cursor/rules/svelte5-best-practices.mdc \ - --read .cursor/rules/windmill-overview.mdc \ - ${{ env.FILES_TO_EDIT }} \ - --model gemini/gemini-2.5-pro-preview-05-06 \ - --message-file .github/aider/review-prompt.txt \ - --yes \ - --no-check-update \ - --auto-commits \ - --no-analytics \ - --no-gitignore \ - | tee .github/aider/aider-output.txt || true - echo "Aider command completed. Output saved to .github/aider/aider-output.txt" - # Check if there are any changes to commit - if [[ -z "$(git status --porcelain)" ]]; then - echo "No changes detected after running Aider." - echo "HAS_CHANGES=false" >> $GITHUB_OUTPUT - exit 0 - fi - - - name: Clean up prompt file - if: always() - run: rm -f .github/aider/review-prompt.txt - - - name: Commit and Push Changes - id: commit_and_push - if: ${{ success() }} - run: | - CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) - echo "Attempting to push changes to PR branch $CURRENT_BRANCH_NAME for PR #${{ github.event.pull_request.number }}" - - # Pull latest changes to avoid rejection due to non-fast-forward - git config pull.rebase true - git pull origin $CURRENT_BRANCH_NAME - - if git push origin $CURRENT_BRANCH_NAME; then - echo "Push to $CURRENT_BRANCH_NAME successful." - echo "CHANGES_APPLIED=true" >> $GITHUB_OUTPUT - else - echo "::warning::Push to PR branch $CURRENT_BRANCH_NAME failed." - echo "CHANGES_APPLIED=false" >> $GITHUB_OUTPUT - fi - - - name: Comment on PR - if: success() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUM: ${{ github.event.pull_request.number }} - run: | - # Create comment body in a temporary file to avoid command line length limits - if [[ "${{ steps.commit_and_push.outputs.CHANGES_APPLIED }}" == "true" ]]; then - cat > /tmp/pr-comment.md << EOL - 🤖 I've automatically addressed the feedback based on the review. - - ## Aider Output - \`\`\` - $(cat .github/aider/aider-output.txt || echo 'No output available') - \`\`\` - - Please review the changes and let me know if further adjustments are needed. - EOL - else - cat > /tmp/pr-comment.md << EOL - 🤖 I attempted to address the review feedback, but no modifications were made. - - ## Aider Output - \`\`\` - $(cat .github/aider/aider-output.txt || echo 'No output available') - \`\`\` - - Please review the output and provide additional guidance if needed. - EOL - fi - - # Use the file for comment body - gh pr comment $PR_NUM --body-file /tmp/pr-comment.md + run-aider: + needs: [check-membership, check-and-prepare] + if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]') && needs.check-membership.outputs.is_member == 'true' + uses: ./.github/workflows/aider-common.yml + with: + needs_processing: false + base_prompt: ${{ needs.check-and-prepare.outputs.prompt_content }} + rules_files: "CLAUDE.md backend/CLAUDE.md frontend/CLAUDE.md" + secrets: inherit diff --git a/.github/workflows/aider-common.yml b/.github/workflows/aider-common.yml new file mode 100644 index 0000000000..935c4224f2 --- /dev/null +++ b/.github/workflows/aider-common.yml @@ -0,0 +1,522 @@ +name: Aider Common Steps + +on: + workflow_call: + inputs: + issue_title: + description: "Title of the issue or PR" + required: false + type: string + issue_body: + description: "Body of the issue or PR" + required: false + type: string + instruction: + description: "Instruction for Aider" + required: false + type: string + issue_id: + description: "ID of the issue or PR" + required: false + type: string + needs_processing: + description: "Whether the issue needs to be processed by the external API" + required: false + type: boolean + default: true + base_prompt: + description: "Base prompt for Aider" + required: false + type: string + default: "Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line." + probe_prompt: + description: "Prompt for probe-chat" + required: false + type: string + default: 'I''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST. Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: ["file1.py", "file2.py"]' + rules_files: + description: "Rules files for Aider" + required: false + type: string + outputs: + files_to_edit: + description: "Files identified by probe-chat for editing" + value: ${{ jobs.common-steps.outputs.files_to_edit }} + final_prompt: + description: "Final prompt for Aider" + value: ${{ jobs.common-steps.outputs.final_prompt }} + pr_branch_name: + description: "Name of the branch used for PR" + value: ${{ jobs.common-steps.outputs.pr_branch_name }} + changes_applied_message: + description: "Message indicating changes were applied" + value: ${{ jobs.common-steps.outputs.changes_applied_message }} + changes_applied: + description: "Boolean indicating if changes were successfully applied" + value: ${{ jobs.common-steps.outputs.changes_applied }} + +jobs: + common-steps: + runs-on: ubicloud-standard-8 + outputs: + files_to_edit: ${{ steps.probe_files.outputs.files_to_edit }} + final_prompt: ${{ steps.create_prompt.outputs.final_prompt }} + pr_branch_name: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }} + changes_applied_message: ${{ steps.commit_and_push.outputs.CHANGES_APPLIED_MESSAGE }} + changes_applied: ${{ steps.commit_and_push.outputs.CHANGES_APPLIED }} + env: + GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@v2 + with: + egress-policy: audit + + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout PR Branch + id: checkout_pr + if: (github.event_name == 'issue_comment' && github.event.issue.pull_request) || (github.event_name == 'pull_request_review') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Issue comment trigger: Checking out PR branch..." + PR_NUMBER="" + if [ -n "${{ github.event.issue.number }}" ]; then + PR_NUMBER="${{ github.event.issue.number }}" + elif [ -n "${{ github.event.pull_request.number }}" ]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + else + echo "::error::Could not determine PR number." + exit 1 + fi + PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY) + if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then + echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI." + exit 1 + fi + echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER" + git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags + git checkout "$PR_HEAD_REF" + echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)" + echo "PR_BRANCH=$PR_HEAD_REF" >> $GITHUB_OUTPUT + + - name: Configure Git User + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Cache Python dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install Aider and Dependencies + run: | + echo "Installing Aider..." + python -m pip install uv + python -m venv ~/uv-env + source ~/uv-env/bin/activate + uv pip install configargparse==1.7 + uv pip install aider-chat==0.83.1 + uv pip install -U google-generativeai + sudo apt-get update && sudo apt-get install -y jq + echo "$HOME/.local/bin" >> $GITHUB_PATH + echo "VIRTUAL_ENV_PATH=$HOME/uv-env" >> $GITHUB_ENV + + - name: Create Prompt for Aider + id: create_prompt + shell: bash + env: + BASE_PROMPT_ENV: ${{ inputs.base_prompt }} + ISSUE_TITLE_ENV: ${{ inputs.issue_title }} + ISSUE_BODY_ENV: ${{ inputs.issue_body }} + INSTRUCTION_ENV: ${{ inputs.instruction }} + NEEDS_PROCESSING_ENV: ${{ inputs.needs_processing }} + WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} + run: | + set -e + FINAL_PROMPT_CONTENT="" + + if [[ "$ISSUE_TITLE_ENV" != "" && "$ISSUE_BODY_ENV" != "" ]]; then + echo "Processing issue with title: $ISSUE_TITLE_ENV" + if [[ "$NEEDS_PROCESSING_ENV" == "true" ]]; then + echo "Needs processing is true. Calling Windmill API..." + JSON_PAYLOAD=$(jq -n \ + --arg title "$ISSUE_TITLE_ENV" \ + --arg body "$ISSUE_BODY_ENV" \ + '{"body":{"issue_title":$title,"issue_body":$body}}') + + echo "Windmill JSON Payload: $JSON_PAYLOAD" + + API_RESULT_FILE=$(mktemp) + HTTP_CODE=$(curl -s -o "$API_RESULT_FILE" -w "%{http_code}" \ + -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $WINDMILL_TOKEN" \ + --data-binary "$JSON_PAYLOAD" \ + --max-time 90) + + BODY_CONTENT=$(cat "$API_RESULT_FILE") + rm -f "$API_RESULT_FILE" # Clean up temp file + + echo "Windmill API HTTP Code: $HTTP_CODE" + if [[ "$HTTP_CODE" -eq 200 ]]; then + PROCESSED_ISSUE_PROMPT=$(echo "$BODY_CONTENT" | jq -r '.effective_body // empty') + if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then + echo "::warning::Windmill API returned 200 but effective_body was empty or null." + EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT="$ISSUE_BODY_ENV" + else + echo "Successfully processed issue via Windmill API." + EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT="$PROCESSED_ISSUE_PROMPT" + fi + FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ + "$BASE_PROMPT_ENV" "$EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT" "$INSTRUCTION_ENV") + else + echo "::error::Windmill API call failed (HTTP $HTTP_CODE). Using raw issue content for prompt." + FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ + "$BASE_PROMPT_ENV" "$ISSUE_BODY_ENV" "$INSTRUCTION_ENV") + fi + else + echo "Needs processing is false. Using raw issue content for prompt." + FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ + "$BASE_PROMPT_ENV" "$ISSUE_BODY_ENV" "$INSTRUCTION_ENV") + fi + else + echo "No issue title or body given. Using base prompt." + FINAL_PROMPT_CONTENT=$(printf "%s\nINSTRUCTION:\n%s" "$BASE_PROMPT_ENV" "$INSTRUCTION_ENV") + fi + + echo "Final prompt: $FINAL_PROMPT_CONTENT" + echo "final_prompt<> "$GITHUB_OUTPUT" + echo "$FINAL_PROMPT_CONTENT" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_PROMPT" >> "$GITHUB_OUTPUT" + + - name: Probe Chat for Relevant Files + id: probe_files + shell: bash + env: + FINAL_PROMPT: ${{ steps.create_prompt.outputs.final_prompt }} + PROBE_PROMPT: ${{ inputs.probe_prompt }} + run: | + echo "Running probe-chat to find relevant files..." + + MESSAGE_FOR_PROBE=$(printf "%s\nREQUEST:\n%s" "$PROBE_PROMPT" "$FINAL_PROMPT") + + set -o pipefail + PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || { + echo "::error::probe-chat command failed. Output:" + echo "$PROBE_OUTPUT" + exit 1 + } + set +o pipefail + echo "Probe-chat raw output:" + echo "$PROBE_OUTPUT" + + JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q') + echo "Extracted JSON block:" + echo "$JSON_FILES" + + FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | join(" ")' || echo "") + + if [[ -z "$FILES_LIST" ]]; then + echo "::warning::probe-chat did not identify any relevant files." + fi + + echo "Formatted files list for aider: $FILES_LIST" + echo "files_to_edit=$FILES_LIST" >> $GITHUB_OUTPUT + + - name: Cache Aider tags + uses: actions/cache@v3 + with: + path: .aider.tags.cache.v4 + key: ${{ runner.os }}-aider-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-aider- + + - name: Prepare branch for Aider + id: prepare_branch + env: + ISSUE_ID: ${{ inputs.issue_id }} + run: | + if [[ "$ISSUE_ID" != "" ]]; then + BRANCH_NAME="aider-fix-issue-${ISSUE_ID}" + + # Check if branch exists remotely + if git ls-remote --heads origin $BRANCH_NAME | grep -q $BRANCH_NAME; then + echo "Branch $BRANCH_NAME already exists remotely, fetching it" + git fetch origin $BRANCH_NAME + git checkout $BRANCH_NAME + git pull origin $BRANCH_NAME + else + echo "Creating new branch $BRANCH_NAME" + git checkout -b $BRANCH_NAME + fi + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT + else + # We're in a pull_request_review event + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_HEAD_REF="${{ github.event.pull_request.head.ref }}" + + echo "Handling pull_request_review for PR #$PR_NUMBER on branch $PR_HEAD_REF" + + # Ensure we're on the correct branch + git config pull.rebase true + git fetch origin $PR_HEAD_REF + git checkout $PR_HEAD_REF + git pull origin $PR_HEAD_REF + + echo "Using PR branch $PR_HEAD_REF for PR #$PR_NUMBER" + echo "BRANCH_NAME=$PR_HEAD_REF" >> $GITHUB_OUTPUT + fi + + - name: Run Aider + id: run_aider + shell: bash + env: + FILES_TO_EDIT: ${{ steps.probe_files.outputs.files_to_edit }} + FINAL_PROMPT: ${{ steps.create_prompt.outputs.final_prompt }} + RULES_FILES: ${{ inputs.rules_files }} + run: | + source $VIRTUAL_ENV_PATH/bin/activate + echo "$FINAL_PROMPT" > .aider_final_prompt.txt + echo "FILES_TO_EDIT: $FILES_TO_EDIT" + + RULES="" + if [ -n "$RULES_FILES" ]; then + for rule in $RULES_FILES; do + RULES="$RULES --read $rule" + done + fi + + aider \ + $RULES \ + $FILES_TO_EDIT \ + --model gemini/gemini-2.5-pro-preview-05-06 \ + --message-file .aider_final_prompt.txt \ + --yes \ + --no-check-update \ + --auto-commits \ + --no-analytics \ + --no-gitignore \ + | tee .aider_output.txt || true + + echo "Aider command completed. Output saved to .aider_output.txt" + + - name: Cache Node.js dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Commit and Push Changes + id: commit_and_push + env: + ISSUE_ID: ${{ inputs.issue_id }} + BRANCH_NAME: ${{ steps.prepare_branch.outputs.BRANCH_NAME }} + run: | + if [[ "$ISSUE_ID" != "" ]]; then + # Check if there are any uncommitted changes + if [[ -n $(git status --porcelain) ]]; then + echo "Found uncommitted changes, committing them" + git add . + git commit -m "Aider changes" + fi + + # Push changes to the branch + if git push origin $BRANCH_NAME; then + echo "Pushed to branch $BRANCH_NAME" + echo "PR_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "CHANGES_APPLIED_MESSAGE=Aider changes pushed to branch $BRANCH_NAME." >> $GITHUB_OUTPUT + echo "CHANGES_APPLIED=true" >> $GITHUB_OUTPUT + else + echo "::warning::Push to PR branch $BRANCH_NAME failed." + echo "CHANGES_APPLIED_MESSAGE=Aider ran, but failed to push changes to PR branch $BRANCH_NAME." >> $GITHUB_OUTPUT + echo "CHANGES_APPLIED=false" >> $GITHUB_OUTPUT + fi + else + # We're in a pull_request_review event + PR_HEAD_REF="${{ github.event.pull_request.head.ref }}" + echo "Attempting to push changes to PR branch $PR_HEAD_REF" + if git push origin $PR_HEAD_REF; then + echo "Push to $PR_HEAD_REF successful (or no new changes to push)." + echo "CHANGES_APPLIED_MESSAGE=Aider changes (if any) pushed to PR branch $PR_HEAD_REF." >> $GITHUB_OUTPUT + echo "PR_BRANCH_NAME=$PR_HEAD_REF" >> $GITHUB_OUTPUT + echo "CHANGES_APPLIED=true" >> $GITHUB_OUTPUT + else + echo "::warning::Push to PR branch $PR_HEAD_REF failed." + echo "CHANGES_APPLIED_MESSAGE=Aider ran, but failed to push changes to PR branch $PR_HEAD_REF." >> $GITHUB_OUTPUT + echo "CHANGES_APPLIED=false" >> $GITHUB_OUTPUT + fi + fi + + - name: Create Pull Request + if: always() && (github.event_name == 'issue_comment' || github.event_name == 'repository_dispatch') && !github.event.issue.pull_request && steps.commit_and_push.outputs.PR_BRANCH_NAME != '' + id: create_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BRANCH: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }} + ISSUE_NUM: ${{ inputs.issue_id }} + ISSUE_TITLE: ${{ inputs.issue_title }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: | + # Create PR description in a temporary file to avoid command line length limits and ensure it stays under 40k chars + HEADER="This PR was created automatically by Aider to fix issue #${ISSUE_NUM}." + # if event is repository_dispatch, add the issue title to the header + if [ "$GITHUB_EVENT_NAME" == "repository_dispatch" ]; then + if [[ "${{ github.event.client_payload.source }}" == "linear" ]]; then + HEADER="This PR was created automatically by Aider to fix issue #linear:${ISSUE_NUM}." + elif [[ "${{ github.event.client_payload.source }}" == "discord" ]]; then + HEADER="This PR was created automatically by Aider to fix issue #discord:${ISSUE_NUM}." + fi + fi + cat > /tmp/pr-description.md << EOL | head -c 40000 + $HEADER + + ## Aider Output + \`\`\` + $(cat .aider_output.txt || echo "No output available") + \`\`\` + EOL + + # Create PR using the file for the body content, handle errors gracefully + set +e # Don't exit on error + PR_TITLE="[Aider PR] Fix: ${ISSUE_TITLE}" + if [ -z "$ISSUE_TITLE" ]; then + PR_TITLE="[Aider PR] AI changes after request" + fi + gh pr create \ + --title "$PR_TITLE" \ + --body-file /tmp/pr-description.md \ + --head "$PR_BRANCH" \ + --base main \ + --draft + PR_CREATE_EXIT_CODE=$? + set -e # Re-enable exit on error + + if [ $PR_CREATE_EXIT_CODE -eq 0 ]; then + echo "PR created successfully" + PR_URL=$(gh pr view $PR_BRANCH --json url --jq .url) + echo "PR_URL=$PR_URL" >> $GITHUB_OUTPUT + echo "PR_CREATED=true" >> $GITHUB_OUTPUT + else + echo "Warning: Failed to create PR. Exit code: $PR_CREATE_EXIT_CODE" + echo "PR_CREATED=false" >> $GITHUB_OUTPUT + # Continue workflow despite PR creation failure + fi + + - name: Comment on PR with Aider Output + if: always() && github.event_name == 'pull_request_review' && steps.commit_and_push.outputs.CHANGES_APPLIED != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUM: ${{ github.event.pull_request.number }} + JOB_STATUS: ${{ job.status }} + run: | + # Create comment body in a temporary file to avoid command line length limits + if [[ "${{ steps.commit_and_push.outputs.CHANGES_APPLIED }}" == "true" ]]; then + if [[ "$JOB_STATUS" == "success" ]]; then + STATUS_PREFIX="🤖 I've automatically addressed the feedback based on the review." + else + STATUS_PREFIX="⚠️ I attempted to address the feedback, but encountered some issues." + fi + else + if [[ "$JOB_STATUS" == "success" ]]; then + STATUS_PREFIX="🤖 I attempted to address the review feedback, but no modifications were made." + else + STATUS_PREFIX="⚠️ I encountered issues while attempting to address the feedback, and no modifications were made." + fi + fi + + cat > /tmp/pr-comment.md << EOL + ${STATUS_PREFIX} + + ## Aider Output + \`\`\` + $(cat .aider_output.txt || echo 'No output available') + \`\`\` + + Please review the output and provide additional guidance if needed. + EOL + + # Use the file for comment body + gh pr comment $PR_NUM --body-file /tmp/pr-comment.md + + - name: Comment on issue/PR to let the user know Aider has finished working on the request + if: always() && github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + JOB_STATUS: ${{ job.status }} + PR_CREATED: ${{ steps.create_pr.outputs.PR_CREATED }} + PR_URL: ${{ steps.create_pr.outputs.PR_URL }} + run: | + echo "Commenting on issue/PR #${{ github.event.issue.number }} to let the user know Aider has finished working on the request." + + if [[ "$JOB_STATUS" == "success" ]]; then + if [[ "$PR_CREATED" == "true" ]]; then + COMMENT_BODY="🤖 Aider has finished working on your request. A PR has been created. $PR_URL" + else + COMMENT_BODY="🤖 Aider has finished working on your request, but was unable to create a PR." + fi + else + COMMENT_BODY="⚠️ Aider encountered issues while working on your request. Please check the workflow logs for details." + fi + + gh issue comment ${{ github.event.issue.number }} --body "$COMMENT_BODY" --repo $GITHUB_REPOSITORY + + - name: Comment on linear issue to let the user know Aider has finished working on the request + if: always() && github.event_name == 'repository_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + JOB_STATUS: ${{ job.status }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + PR_CREATED: ${{ steps.create_pr.outputs.PR_CREATED }} + PR_URL: ${{ steps.create_pr.outputs.PR_URL }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + SOURCE: ${{ github.event.client_payload.source }} + run: | + echo "Notifying user about Aider completion status for $SOURCE request #${{ github.event.client_payload.issue_id }}" + if [[ "$JOB_STATUS" == "success" ]]; then + if [[ "$PR_CREATED" == "true" ]]; then + COMMENT_BODY="🤖 Aider has finished working on your request. A PR has been created. $PR_URL" + else + COMMENT_BODY="🤖 Aider has finished working on your request, but was unable to create a PR." + fi + else + COMMENT_BODY="⚠️ Aider encountered issues while working on your request. Please check the workflow logs for details." + fi + + if [[ "$SOURCE" == "discord" ]]; then + curl -X POST \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN" \ + -H "Content-Type: application/json" \ + "https://discord.com/api/v10/channels/${{ github.event.client_payload.channel_id }}/messages" \ + -d "{\"content\":\"${COMMENT_BODY}\"}" + else + curl -X POST \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + "https://api.linear.app/graphql" \ + -d "{\"query\":\"mutation { commentCreate(input: { issueId: \\\"${{ github.event.client_payload.issue_id }}\\\", body: \\\"${COMMENT_BODY}\\\" }) { success } }\"}" + fi diff --git a/.github/workflows/aider-external.yaml b/.github/workflows/aider-external.yaml new file mode 100644 index 0000000000..cfdb03d352 --- /dev/null +++ b/.github/workflows/aider-external.yaml @@ -0,0 +1,80 @@ +name: External Aider Issue Fix + +on: + repository_dispatch: + types: [external_issue_fix] + +jobs: + check-and-prepare: + runs-on: ubicloud-standard-2 + permissions: + contents: write + pull-requests: write + outputs: + issue_title: ${{ steps.determine_inputs.outputs.ISSUE_TITLE }} + issue_body: ${{ steps.determine_inputs.outputs.ISSUE_BODY }} + instruction: ${{ steps.determine_inputs.outputs.INSTRUCTION }} + env: + GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + + steps: + - name: Acknowledge Request + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + run: | + if [[ "${{ github.event.client_payload.source }}" == "linear" ]]; then + echo "Commenting on Linear issue #${{ github.event.client_payload.issue_id }} to acknowledge the request." + curl -X POST \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + "https://api.linear.app/graphql" \ + -d "{\"query\":\"mutation { commentCreate(input: { issueId: \\\"${{ github.event.client_payload.issue_id }}\\\", body: \\\"🤖 Aider is starting to work on your request. I'll update you here once I have a PR ready. Please be patient, this might take a few minutes.\\\" }) { success } }\"}" + elif [[ "${{ github.event.client_payload.source }}" == "discord" ]]; then + echo "Commenting on Discord thread #${{ github.event.client_payload.channel_id }} to acknowledge the request." + curl -X POST \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN" \ + -H "Content-Type: application/json" \ + "https://discord.com/api/v10/channels/${{ github.event.client_payload.channel_id }}/messages" \ + -d "{\"content\":\"🤖 Aider is starting to work on your request. I'll update you here once I have a PR ready. Please be patient, this might take a few minutes.\"}" + fi + + - name: Determine inputs for Aider + id: determine_inputs + shell: bash + env: + ISSUE_TITLE: ${{ github.event.client_payload.issue_title }} + ISSUE_BODY: ${{ github.event.client_payload.issue_body }} + INSTRUCTION: ${{ github.event.client_payload.instruction }} + run: | + echo "Determining inputs for Aider..." + + echo "ISSUE_TITLE<> "$GITHUB_OUTPUT" + echo "$ISSUE_TITLE" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT" + + echo "ISSUE_BODY<> "$GITHUB_OUTPUT" + echo "$ISSUE_BODY" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT" + + echo "INSTRUCTION<> "$GITHUB_OUTPUT" + echo "$INSTRUCTION" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_INSTRUCTION" >> "$GITHUB_OUTPUT" + echo "Finished determining inputs." + + run-aider: + needs: check-and-prepare + uses: ./.github/workflows/aider-common.yml + with: + issue_title: ${{ needs.check-and-prepare.outputs.issue_title }} + issue_body: ${{ needs.check-and-prepare.outputs.issue_body }} + instruction: ${{ needs.check-and-prepare.outputs.instruction }} + issue_id: ${{ github.event.client_payload.issue_id }} + rules_files: "CLAUDE.md backend/CLAUDE.md frontend/CLAUDE.md" + secrets: inherit diff --git a/.github/workflows/aider.yaml b/.github/workflows/aider.yaml index 82d5f8b63e..e7609597e3 100644 --- a/.github/workflows/aider.yaml +++ b/.github/workflows/aider.yaml @@ -5,12 +5,40 @@ on: types: [created] jobs: - auto-fix: - runs-on: ubicloud-standard-8 + check-membership: + runs-on: ubicloud-standard-2 if: | github.event_name == 'issue_comment' && contains(github.event.comment.body, '/aider') && !contains(github.event.comment.user.login, '[bot]') + outputs: + is_member: ${{ steps.check-membership.outputs.is_member }} + steps: + - name: Check organization membership + id: check-membership + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + COMMENTER: ${{ github.event.comment.user.login }} + ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }} + run: | + ORG="windmill-labs" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $ORG_ACCESS_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/$ORG/members/$COMMENTER") + + if [ "$STATUS" -eq 204 ]; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "is_member=false" >> $GITHUB_OUTPUT + fi + + check-and-prepare: + needs: check-membership + runs-on: ubicloud-standard-2 + if: needs.check-membership.outputs.is_member == 'true' permissions: contents: write pull-requests: write @@ -21,323 +49,117 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} + outputs: + issue_title: ${{ steps.determine_inputs.outputs.ISSUE_TITLE }} + issue_body: ${{ steps.determine_inputs.outputs.ISSUE_BODY }} + comment_content: ${{ steps.determine_inputs.outputs.COMMENT_CONTENT }} + pr_branch: ${{ steps.checkout_pr.outputs.PR_BRANCH }} steps: - - name: Harden Runner - uses: step-security/harden-runner@v2 - with: - egress-policy: audit - - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Configure Git User - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Checkout PR Branch - if: github.event_name == 'issue_comment' && github.event.issue.pull_request + - name: Acknowledge Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | - echo "Issue comment trigger: Checking out PR branch..." - PR_NUMBER=${{ github.event.issue.number }} - PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY) - if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then - echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI." - exit 1 - fi - echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER" - git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags - git checkout "$PR_HEAD_REF" - echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)" + echo "Commenting on issue/PR #${{ github.event.issue.number }} to acknowledge the /aider command." + gh issue comment ${{ github.event.issue.number }} --body "🤖 Aider is starting to work on your request. I'll update you here once I have a PR ready. Please be patient, this might take a few minutes." --repo $GITHUB_REPOSITORY - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install Aider and Dependencies - run: | - python -m pip install aider-install; aider-install - pip install -U google-generativeai - sudo apt-get update && sudo apt-get install -y jq - - - name: Determine Prompt for Aider - id: determine_prompt + - name: Determine inputs for Aider + id: determine_inputs shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENT_BODY: ${{ github.event.comment.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + GITHUB_REPOSITORY: ${{ github.repository }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} run: | - PROMPT_FILE_PATH=".github/aider/issue-prompt.txt" - mkdir -p .github/aider + echo "Determining inputs for Aider..." + ISSUE_TITLE_VAL="" + ISSUE_BODY_VAL="" - # Determine if this is a PR comment or regular issue comment if [[ ! -z "${{ github.event.issue.pull_request }}" ]]; then echo "This is a comment on a Pull Request" - PR_NUMBER="${{ github.event.issue.number }}" + PR_NUMBER="$ISSUE_NUMBER" - # Get PR description to check for issue references - PR_BODY=$(gh pr view $PR_NUMBER --json body -q .body --repo $GITHUB_REPOSITORY) - - # Extract issue number from PR description (looking for #123 or "fixes #123" patterns) - REFERENCED_ISSUE=$(echo "$PR_BODY" | grep -oE "#[0-9]+" | grep -oE "[0-9]+" | head -1) - - if [[ ! -z "$REFERENCED_ISSUE" ]]; then - echo "Found referenced issue #$REFERENCED_ISSUE in PR description" - - # Fetch the referenced issue details - ISSUE_DETAILS=$(gh issue view $REFERENCED_ISSUE --json title,body --repo $GITHUB_REPOSITORY) - ISSUE_TITLE=$(echo "$ISSUE_DETAILS" | jq -r .title) - ISSUE_BODY=$(echo "$ISSUE_DETAILS" | jq -r .body) - - # Store raw comment body in a file first to avoid shell interpretation issues - echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt - RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt) - # Remove the /aider prefix and trim whitespace - COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - echo "Sending issue content and PR comment to external API…" - - ISSUE_TITLE_Q=$(printf '%q' "$ISSUE_TITLE") - ISSUE_BODY_Q=$(printf '%q' "$ISSUE_BODY") - - JSON_PAYLOAD=$(jq -n \ - --arg title "$ISSUE_TITLE_Q" \ - --arg body "$ISSUE_BODY_Q" \ - '{"body":{"issue_title":$title,"issue_body":$body}}') - - API_RESULT=$(curl -s -w "\n%{http_code}" \ - -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $WINDMILL_TOKEN" \ - --data-binary "$JSON_PAYLOAD" \ - --max-time 90) - - HTTP_CODE=$(echo "$API_RESULT" | tail -n1) - BODY=$(echo "$API_RESULT" | sed '$d') - - echo "$BODY" > /tmp/api_response.txt - - BASE_PROMPT="Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line." - if [[ "$HTTP_CODE" -eq 200 ]]; then - PROCESSED_ISSUE_PROMPT=$(jq -r '.effective_body // empty' /tmp/api_response.txt) - if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then - PROCESSED_ISSUE_PROMPT="" - fi - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$PROCESSED_ISSUE_PROMPT" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH" - else - echo "::warning::API call failed (HTTP $HTTP_CODE). Using PR comment with issue context." - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$ISSUE_BODY_Q" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH" - fi - rm -f /tmp/api_response.txt + PR_BODY_JSON=$(gh pr view "$PR_NUMBER" --json body --repo "$GITHUB_REPOSITORY") + if [[ $? -ne 0 ]]; then + echo "Error fetching PR body for PR #$PR_NUMBER" + PR_BODY_VAL="" else - echo "No referenced issue found in PR description, using comment content only" - # Use comment content directly as with regular issue comments - echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt - RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt) - COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - if [[ -z "$COMMENT_CONTENT" ]]; then - echo "::error::Comment with /aider provided, but no instruction found after it. Cannot proceed." - printf "Error: /aider command found but no instruction followed." > "$PROMPT_FILE_PATH" - exit 1 - else - echo "Using comment content as prompt." - printf '%s' "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH" + PR_BODY_VAL=$(jq -r '.body // ""' <<< "$PR_BODY_JSON") + fi + + if [[ ! -z "$PR_BODY_VAL" ]]; then + REFERENCED_ISSUE="" + if [[ "$PR_BODY_VAL" =~ \#linear:([a-f0-9-]+) ]]; then + REFERENCED_ISSUE="${BASH_REMATCH[1]}" + echo "Found referenced Linear issue #$REFERENCED_ISSUE in PR description" + LINEAR_ISSUE_JSON=$(curl -s -H "Authorization: $LINEAR_API_KEY" \ + "https://api.linear.app/graphql" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "{\"query\":\"query { issue(id: \\\"$REFERENCED_ISSUE\\\") { title description } }\"}") + + if [[ $? -eq 0 && ! "$LINEAR_ISSUE_JSON" =~ "error" ]]; then + ISSUE_TITLE_VAL=$(jq -r '.data.issue.title // ""' <<< "$LINEAR_ISSUE_JSON") + ISSUE_BODY_VAL=$(jq -r '.data.issue.description // ""' <<< "$LINEAR_ISSUE_JSON") + echo "Successfully fetched Linear issue details" + else + echo "Error fetching Linear issue details for #$REFERENCED_ISSUE" + fi + elif [[ "$PR_BODY_VAL" =~ \#([0-9]+) ]]; then + REFERENCED_ISSUE="${BASH_REMATCH[1]}" + echo "Found referenced GitHub issue #$REFERENCED_ISSUE in PR description" + + ISSUE_DETAILS_JSON=$(gh issue view "$REFERENCED_ISSUE" --json title,body --repo "$GITHUB_REPOSITORY") + if [[ $? -ne 0 ]]; then + echo "Error fetching issue details for #$REFERENCED_ISSUE" + else + ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON") + ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON") + fi fi + else + echo "PR body is empty or could not be fetched." fi else echo "This is a comment on a regular issue" - # Fetch the issue details - ISSUE_NUMBER="${{ github.event.issue.number }}" - ISSUE_DETAILS=$(gh issue view $ISSUE_NUMBER --json title,body --repo $GITHUB_REPOSITORY) - ISSUE_TITLE=$(echo "$ISSUE_DETAILS" | jq -r .title) - ISSUE_BODY=$(echo "$ISSUE_DETAILS" | jq -r .body) - - # Store raw comment body in a file first to avoid shell interpretation issues - echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt - # Extract the command part safely - RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt) - # Remove the /aider prefix and trim whitespace - COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - if [[ -z "$COMMENT_CONTENT" ]]; then - echo "::error::Comment with /aider provided, but no instruction found after it. Cannot proceed." - printf "Error: /aider command found but no instruction followed." > "$PROMPT_FILE_PATH" - exit 1 + ISSUE_DETAILS_JSON=$(gh issue view "$ISSUE_NUMBER" --json title,body --repo "$GITHUB_REPOSITORY") + if [[ $? -ne 0 ]]; then + echo "Error fetching issue details for #$ISSUE_NUMBER" else - echo "Sending issue content and issue comment to external API…" - - ISSUE_TITLE_Q=$(printf '%q' "$ISSUE_TITLE") - ISSUE_BODY_Q=$(printf '%q' "$ISSUE_BODY") - COMMENT_CONTENT_Q=$(printf '%q' "$COMMENT_CONTENT") - - JSON_PAYLOAD=$(jq -n \ - --arg title "$ISSUE_TITLE_Q" \ - --arg body "$ISSUE_BODY_Q" \ - --arg comment "$COMMENT_CONTENT_Q" \ - '{"body":{"issue_title":$title,"issue_body":$body,"issue_comment":$comment}}') - - API_RESULT=$(curl -s -w "\n%{http_code}" \ - -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $WINDMILL_TOKEN" \ - --data-binary "$JSON_PAYLOAD" \ - --max-time 90) - - HTTP_CODE=$(echo "$API_RESULT" | tail -n1) - BODY=$(echo "$API_RESULT" | sed '$d') - - echo "$BODY" > /tmp/api_response.txt - - BASE_PROMPT="Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line." - if [[ "$HTTP_CODE" -eq 200 ]]; then - PROCESSED_ISSUE_PROMPT=$(jq -r '.effective_body // empty' /tmp/api_response.txt) - if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then - PROCESSED_ISSUE_PROMPT="" - fi - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$PROCESSED_ISSUE_PROMPT" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH" - else - echo "::warning::API call failed (HTTP $HTTP_CODE). Using PR comment with issue context." - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$ISSUE_BODY_Q" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH" - fi - - rm -f /tmp/api_response.txt - fi - fi - echo "Prompt determined and written to $PROMPT_FILE_PATH" - echo "PROMPT_FILE_PATH=$PROMPT_FILE_PATH" >> $GITHUB_OUTPUT - - - name: Probe Chat for Relevant Files - id: probe_files - env: - PROMPT_CONTENT_FILE: ${{ steps.determine_prompt.outputs.PROMPT_FILE_PATH }} - run: | - echo "Running probe-chat to find relevant files..." - if [[ ! -f "$PROMPT_CONTENT_FILE" ]]; then - echo "::error::Prompt file $PROMPT_CONTENT_FILE not found!" - exit 1 - fi - PROMPT_CONTENT=$(cat "$PROMPT_CONTENT_FILE") - if [ -z "$PROMPT_CONTENT" ]; then - echo "::error::Prompt content is empty!" - exit 1 - fi - - PROMPT_ESCAPED=$(jq -Rs . <<< "$PROMPT_CONTENT") - - MESSAGE_FOR_PROBE=$(jq -n --arg prompt_escaped "$PROMPT_ESCAPED" \ - '{ "message": "I'\''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST.\\nREQUEST: \($prompt_escaped). Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: [\"file1.py\", \"file2.py\"]" }' | jq -r .message) - - set -o pipefail - PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || { - echo "::error::probe-chat command failed. Output:" - echo "$PROBE_OUTPUT" - exit 1 - } - set +o pipefail - echo "Probe-chat raw output:" - echo "$PROBE_OUTPUT" - - JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q') - echo "Extracted JSON block:" - echo "$JSON_FILES" - - FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | map(@sh) | join(" ")' || echo "") - - if [[ -z "$FILES_LIST" ]]; then - echo "::warning::probe-chat did not identify any relevant files." - exit 1 - fi - - echo "Formatted files list for aider: $FILES_LIST" - echo "FILES_TO_EDIT=$FILES_LIST" >> $GITHUB_ENV - - - name: Run Aider with external prompt - run: | - echo "Files identified by probe-chat: ${{ env.FILES_TO_EDIT }}" - aider \ - --read .cursor/rules/rust-best-practices.mdc \ - --read .cursor/rules/svelte5-best-practices.mdc \ - --read .cursor/rules/windmill-overview.mdc \ - ${{ env.FILES_TO_EDIT }} \ - --model gemini/gemini-2.5-pro-preview-05-06 \ - --message-file .github/aider/issue-prompt.txt \ - --yes \ - --no-check-update \ - --auto-commits \ - --no-analytics \ - --no-gitignore \ - | tee .github/aider/aider-output.txt || true - echo "Aider command completed. Output saved to .github/aider/aider-output.txt" - - - name: Clean up prompt file - if: always() - run: rm -f .github/aider/issue-prompt.txt - - - name: Commit and Push Changes - id: commit_and_push - if: ${{ success() }} - run: | - if [[ -z "${{ github.event.issue.pull_request }}" ]]; then - BRANCH_NAME="aider-fix-issue-${{ github.event.issue.number }}" - - # Check if branch exists remotely - if git ls-remote --heads origin $BRANCH_NAME | grep -q $BRANCH_NAME; then - echo "Branch $BRANCH_NAME already exists remotely, fetching it" - git fetch origin $BRANCH_NAME - git checkout $BRANCH_NAME - git pull origin $BRANCH_NAME - else - echo "Creating new branch $BRANCH_NAME" - git checkout -b $BRANCH_NAME - fi - - echo "Created/checked out branch $BRANCH_NAME for issue #${{ github.event.issue.number }}" - git push origin $BRANCH_NAME - echo "Pushed to branch $BRANCH_NAME" - echo "PR_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT - echo "CHANGES_APPLIED_MESSAGE=Aider changes pushed to branch $BRANCH_NAME." >> $GITHUB_OUTPUT - else - CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) - echo "Attempting to push changes to PR branch $CURRENT_BRANCH_NAME for PR #${{ github.event.issue.number }}" - if git push origin $CURRENT_BRANCH_NAME; then - echo "Push to $CURRENT_BRANCH_NAME successful (or no new changes to push)." - echo "CHANGES_APPLIED_MESSAGE=Aider changes (if any) pushed to PR branch $CURRENT_BRANCH_NAME." >> $GITHUB_OUTPUT - echo "PR_BRANCH_NAME=$CURRENT_BRANCH_NAME" >> $GITHUB_OUTPUT - else - echo "::warning::Push to PR branch $CURRENT_BRANCH_NAME failed." - echo "CHANGES_APPLIED_MESSAGE=Aider ran, but failed to push changes to PR branch $CURRENT_BRANCH_NAME." >> $GITHUB_OUTPUT + ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON") + ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON") fi fi - - name: Create Pull Request - if: success() && github.event_name == 'issue_comment' && !github.event.issue.pull_request && steps.commit_and_push.outputs.PR_BRANCH_NAME != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_BRANCH: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }} - ISSUE_NUM: ${{ github.event.issue.number }} - run: | - # Create PR description in a temporary file to avoid command line length limits - cat > /tmp/pr-description.md << EOL - This PR was created automatically by Aider to fix issue #${ISSUE_NUM}. + echo "ISSUE_TITLE<> "$GITHUB_OUTPUT" + echo "$ISSUE_TITLE_VAL" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT" - ## Aider Output - \`\`\` - $(cat .github/aider/aider-output.txt || echo "No output available") - \`\`\` - EOL + echo "ISSUE_BODY<> "$GITHUB_OUTPUT" + echo "$ISSUE_BODY_VAL" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT" - # Create PR using the file for the body content - gh pr create \ - --title "[Aider PR] Add fixes for issue #${ISSUE_NUM}" \ - --body-file /tmp/pr-description.md \ - --head "$PR_BRANCH" \ - --base main + CLEAN_COMMENT="${COMMENT_BODY/\/aider/}" + CLEAN_COMMENT="${CLEAN_COMMENT#"${CLEAN_COMMENT%%[![:space:]]*}"}" + CLEAN_COMMENT="${CLEAN_COMMENT%"${CLEAN_COMMENT##*[![:space:]]}"}" + + echo "COMMENT_CONTENT<> "$GITHUB_OUTPUT" + echo "$CLEAN_COMMENT" >> "$GITHUB_OUTPUT" + echo "EOF_AIDER_COMMENT" >> "$GITHUB_OUTPUT" + echo "Finished determining inputs." + + run-aider: + needs: [check-membership, check-and-prepare] + if: needs.check-membership.outputs.is_member == 'true' + uses: ./.github/workflows/aider-common.yml + with: + issue_title: ${{ needs.check-and-prepare.outputs.issue_title }} + issue_body: ${{ needs.check-and-prepare.outputs.issue_body }} + instruction: ${{ needs.check-and-prepare.outputs.comment_content }} + issue_id: ${{ github.event.issue.number }} + rules_files: "CLAUDE.md backend/CLAUDE.md frontend/CLAUDE.md" + secrets: inherit diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 6d12e4f69c..c7d1c50db1 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -45,9 +45,9 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: 1.1.43 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v6 with: - version: "0.4.18" + version: "0.6.2" - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000000..66eff9dc57 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,85 @@ +name: Claude PR Assistant + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + check-membership: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/aider') && !contains(github.event.comment.user.login, '[bot]')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/aider') && !contains(github.event.comment.user.login, '[bot]')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/aider') && !contains(github.event.review.user.login, '[bot]')) || + (github.event_name == 'issues' && contains(github.event.issue.body, '/aider') && !contains(github.event.issue.user.login, '[bot]')) + runs-on: ubicloud-standard-2 + outputs: + is_member: ${{ steps.check-membership.outputs.is_member }} + steps: + - name: Check organization membership + id: check-membership + env: + ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }} + run: | + ORG="windmill-labs" + + if [[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]]; then + COMMENTER="${{ github.event.comment.user.login }}" + elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then + COMMENTER="${{ github.event.review.user.login }}" + else + COMMENTER="${{ github.event.issue.user.login }}" + fi + + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $ORG_ACCESS_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/$ORG/members/$COMMENTER") + + if [ "$STATUS" -eq 204 ]; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "is_member=false" >> $GITHUB_OUTPUT + fi + + claude-code-action: + needs: check-membership + if: | + needs.check-membership.outputs.is_member == 'true' + runs-on: ubicloud-standard-8 + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude PR Action + uses: anthropics/claude-code-action@beta + 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)" + 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 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. + - DO NOT FORGET TO OPEN A 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): Install Rust. You need this to run cargo check." + trigger_phrase: "/aider" diff --git a/.github/workflows/create-docs.yml b/.github/workflows/create-docs.yml index 6c280ae5f9..209883d24c 100644 --- a/.github/workflows/create-docs.yml +++ b/.github/workflows/create-docs.yml @@ -3,8 +3,34 @@ on: types: [created] jobs: + check-membership: + if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && github.event.comment.user.type != 'Bot' }} + runs-on: ubicloud-standard-2 + outputs: + is_member: ${{ steps.check-membership.outputs.is_member }} + steps: + - name: Check organization membership + id: check-membership + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENTER: ${{ github.event.comment.user.login }} + run: | + ORG="windmill-labs" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/$ORG/members/$COMMENTER") + + if [ "$STATUS" -eq 204 ]; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "is_member=false" >> $GITHUB_OUTPUT + fi + trigger-docs: - if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }} + needs: check-membership + if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }} uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main with: pr_number: ${{ github.event.issue.number }} diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 4d062f0816..3ffec78a60 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -29,4 +29,4 @@ jobs: DISCORD_GUILD_ID: "930051556043276338" PR_NUMBER: ${{ github.event.pull_request.number }} secrets: - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_PR_BOT_TOKEN }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} diff --git a/.github/workflows/linear-claude.yaml b/.github/workflows/linear-claude.yaml new file mode 100644 index 0000000000..c74cfeba1d --- /dev/null +++ b/.github/workflows/linear-claude.yaml @@ -0,0 +1,38 @@ +name: Claude PR Assistant + +on: + repository_dispatch: + types: [external_claude_issue_fix] + +jobs: + claude-code-action: + runs-on: ubicloud-standard-8 + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Process inputs + id: process_inputs + shell: bash + run: | + ISSUE_TITLE="${{ github.event.client_payload.issue_title }}" + INSTRUCTION="${{ github.event.client_payload.instruction }}" + ISSUE_BODY=$(printf '%q' "${{ github.event.client_payload.issue_body }}") + BASE_PROMPT="Try to fix the following issue based on the instruction given. You are provided with the issue title, issue body, and instruction. You are to fix the issue based on the instruction. You are to create a pull request to fix the issue." + CUSTOM_PROMPT=$(printf -v PROMPT "%s\n\nISSUE_TITLE: %s\n\nISSUE_BODY: %s\n\nINSTRUCTION: %s" "$BASE_PROMPT" "$ISSUE_TITLE" "$ISSUE_BODY" "$INSTRUCTION") + echo "CUSTOM_PROMPT=$CUSTOM_PROMPT" >> $GITHUB_OUTPUT + + - name: Run Claude PR Action + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + timeout_minutes: "60" + allowed_tools: "mcp__github__create_pull_request" + direct_prompt: ${{ steps.process_inputs.outputs.CUSTOM_PROMPT }} diff --git a/.github/workflows/linear-issue.yaml b/.github/workflows/linear-issue.yaml deleted file mode 100644 index e3fbeb7f39..0000000000 --- a/.github/workflows/linear-issue.yaml +++ /dev/null @@ -1,223 +0,0 @@ -name: External Aider Issue Fix - -on: - repository_dispatch: - types: [external_issue_fix] - -jobs: - auto-fix: - runs-on: ubicloud-standard-8 - permissions: - contents: write - pull-requests: write - issues: write - env: - GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }} - - steps: - - name: Harden Runner - uses: step-security/harden-runner@v2 - with: - egress-policy: audit - - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Configure Git User - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install Aider and Dependencies - run: | - python -m pip install aider-install; aider-install - pip install -U google-generativeai - sudo apt-get update && sudo apt-get install -y jq - - - name: Create Prompt for Aider - id: create_prompt - shell: bash - run: | - PROMPT_FILE_PATH=".github/aider/issue-prompt.txt" - mkdir -p .github/aider - - ISSUE_TITLE="${{ github.event.client_payload.issue_title }}" - INSTRUCTION="${{ github.event.client_payload.instruction }}" - ISSUE_BODY=$(printf '%q' "${{ github.event.client_payload.issue_body }}") - - echo "Processing issue with title: $ISSUE_TITLE" - - JSON_PAYLOAD=$(jq -n \ - --arg title "$ISSUE_TITLE" \ - --arg body "$ISSUE_BODY" \ - '{"body":{"issue_title":$title,"issue_body":$body}}') - - API_RESULT=$(curl -s -w "\n%{http_code}" \ - -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $WINDMILL_TOKEN" \ - --data-binary "$JSON_PAYLOAD" \ - --max-time 90) - - HTTP_CODE=$(echo "$API_RESULT" | tail -n1) - BODY=$(echo "$API_RESULT" | sed '$d') - - echo "$BODY" > /tmp/api_response.txt - - BASE_PROMPT="Try to fix the following issue based on the instruction given. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line." - if [[ "$HTTP_CODE" -eq 200 ]]; then - PROCESSED_ISSUE_PROMPT=$(jq -r '.effective_body // empty' /tmp/api_response.txt) - if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then - PROCESSED_ISSUE_PROMPT="" - fi - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$PROCESSED_ISSUE_PROMPT" "$INSTRUCTION" > "$PROMPT_FILE_PATH" - else - echo "::warning::API call failed (HTTP $HTTP_CODE). Using raw issue content." - printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \ - "$BASE_PROMPT" "$ISSUE_BODY" "$INSTRUCTION" > "$PROMPT_FILE_PATH" - fi - rm -f /tmp/api_response.txt - - echo "Prompt created and written to $PROMPT_FILE_PATH" - echo "PROMPT_FILE_PATH=$PROMPT_FILE_PATH" >> $GITHUB_OUTPUT - - # Store the issue title for PR creation - ISSUE_TITLE_SAFE=$(echo "$ISSUE_TITLE" | tr -d '\n' | sed 's/"/\\"/g') - echo "ISSUE_TITLE=$ISSUE_TITLE_SAFE" >> $GITHUB_OUTPUT - - # Generate unique branch name using timestamp and issue info - ISSUE_ID="${{ github.event.client_payload.issue_id }}" - BRANCH_NAME="aider-fix-linear-issue-$ISSUE_ID" - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT - - - name: Probe Chat for Relevant Files - id: probe_files - env: - PROMPT_CONTENT_FILE: ${{ steps.create_prompt.outputs.PROMPT_FILE_PATH }} - run: | - echo "Running probe-chat to find relevant files..." - if [[ ! -f "$PROMPT_CONTENT_FILE" ]]; then - echo "::error::Prompt file $PROMPT_CONTENT_FILE not found!" - exit 1 - fi - PROMPT_CONTENT=$(cat "$PROMPT_CONTENT_FILE") - if [ -z "$PROMPT_CONTENT" ]; then - echo "::error::Prompt content is empty!" - exit 1 - fi - - PROMPT_ESCAPED=$(jq -Rs . <<< "$PROMPT_CONTENT") - - MESSAGE_FOR_PROBE=$(jq -n --arg prompt_escaped "$PROMPT_ESCAPED" \ - '{ "message": "I'\''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST.\\nREQUEST: \($prompt_escaped). Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: [\"file1.py\", \"file2.py\"]" }' | jq -r .message) - - set -o pipefail - PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || { - echo "::error::probe-chat command failed. Output:" - echo "$PROBE_OUTPUT" - exit 1 - } - set +o pipefail - echo "Probe-chat raw output:" - echo "$PROBE_OUTPUT" - - JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q') - echo "Extracted JSON block:" - echo "$JSON_FILES" - - FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | map(@sh) | join(" ")' || echo "") - - if [[ -z "$FILES_LIST" ]]; then - echo "::warning::probe-chat did not identify any relevant files." - exit 1 - fi - - echo "Formatted files list for aider: $FILES_LIST" - echo "FILES_TO_EDIT=$FILES_LIST" >> $GITHUB_ENV - - - name: Run Aider with external prompt - run: | - echo "Files identified by probe-chat: ${{ env.FILES_TO_EDIT }}" - aider \ - --read .cursor/rules/rust-best-practices.mdc \ - --read .cursor/rules/svelte5-best-practices.mdc \ - --read .cursor/rules/windmill-overview.mdc \ - ${{ env.FILES_TO_EDIT }} \ - --model gemini/gemini-2.5-pro-preview-05-06 \ - --message-file .github/aider/issue-prompt.txt \ - --yes \ - --no-check-update \ - --auto-commits \ - --no-analytics \ - --no-gitignore \ - | tee .github/aider/aider-output.txt || true - echo "Aider command completed. Output saved to .github/aider/aider-output.txt" - - - name: Clean up prompt file - if: always() - run: rm -f .github/aider/issue-prompt.txt - - - name: Commit and Push Changes - id: commit_and_push - if: ${{ success() }} - run: | - BRANCH_NAME="${{ steps.create_prompt.outputs.BRANCH_NAME }}" - - # Check if branch exists remotely - if git ls-remote --heads origin $BRANCH_NAME | grep -q $BRANCH_NAME; then - echo "Branch $BRANCH_NAME already exists remotely, fetching it" - git fetch origin $BRANCH_NAME - git checkout $BRANCH_NAME - git pull origin $BRANCH_NAME - else - echo "Creating new branch $BRANCH_NAME" - git checkout -b $BRANCH_NAME - fi - - # Check if there are any changes to commit - if git diff --quiet && git diff --staged --quiet; then - echo "No changes to commit" - else - git commit -am "Auto-fix using Aider for external issue [skip ci]" || echo "No changes to commit" - fi - - git push origin $BRANCH_NAME - echo "Pushed to branch $BRANCH_NAME" - echo "PR_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT - - - name: Create Pull Request - if: success() && steps.commit_and_push.outputs.PR_BRANCH_NAME != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_BRANCH: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }} - ISSUE_TITLE: ${{ steps.create_prompt.outputs.ISSUE_TITLE }} - ISSUE_ID: ${{ github.event.client_payload.issue_id }} - run: | - # Create PR description in a temporary file to avoid command line length limits - cat > /tmp/pr-description.md << EOL - This PR was created automatically by Aider to fix an external issue: ${ISSUE_TITLE} - - ## Aider Output - \`\`\` - $(cat .github/aider/aider-output.txt || echo "No output available") - \`\`\` - EOL - - # Create PR using the file for the body content - gh pr create \ - --title "[Aider PR] Fix: ${ISSUE_TITLE}" \ - --body-file /tmp/pr-description.md \ - --head "$PR_BRANCH" \ - --base main || echo "PR already exists or couldn't be created" diff --git a/.github/workflows/shareable-discord-notification.yml b/.github/workflows/shareable-discord-notification.yml index e3cfff6197..cb6dbf055d 100644 --- a/.github/workflows/shareable-discord-notification.yml +++ b/.github/workflows/shareable-discord-notification.yml @@ -84,7 +84,7 @@ jobs: fi # 2) get the first message in that thread messages=$(curl -H "Authorization: Bot $BOT_TOKEN" \ - "https://discord.com/api/v10/channels/$thread_id/messages?limit=1") + "https://discord.com/api/v10/channels/$thread_id/messages") message_id=$(echo "$messages" | jq -r '.[-1].id') if [ -z "$message_id" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index dfae51c5c3..af899a02cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.492.1](https://github.com/windmill-labs/windmill/compare/v1.492.0...v1.492.1) (2025-05-22) + + +### Bug Fixes + +* fix strum compile ([59f6024](https://github.com/windmill-labs/windmill/commit/59f6024cbdaface9c9f0ed61c4a415a13b558515)) + +## [1.492.0](https://github.com/windmill-labs/windmill/compare/v1.491.5...v1.492.0) (2025-05-22) + + +### Features + +* job search pagination + result count ([#5789](https://github.com/windmill-labs/windmill/issues/5789)) ([55ae766](https://github.com/windmill-labs/windmill/commit/55ae76648475ce9ff14b2fa33b2a71b90fbd50a1)) +* **python:** add annotation to skip result post-processing ([#5769](https://github.com/windmill-labs/windmill/issues/5769)) ([07c2ff5](https://github.com/windmill-labs/windmill/commit/07c2ff5668f4725a3b9a8a2655248b0945ac251c)) +* shift/ctrl+click/enter to open ctrl+k menu results in new tab ([#5800](https://github.com/windmill-labs/windmill/issues/5800)) ([66a997a](https://github.com/windmill-labs/windmill/commit/66a997afc399de2d592c469faf9a5b2cd6433aac)) +* triggers git sync ([#5766](https://github.com/windmill-labs/windmill/issues/5766)) ([065a814](https://github.com/windmill-labs/windmill/commit/065a814d35a5749725c2ada1155481abba782684)) + + +### Bug Fixes + +* improve app css consistency ([88482c3](https://github.com/windmill-labs/windmill/commit/88482c3bd76ddad16738354f7531d16fa806ad2f)) +* improve docker mode unexpected exit handling ([7c24fbc](https://github.com/windmill-labs/windmill/commit/7c24fbcef2ecfe5fc034870c4c65dd80513301a4)) +* postgres trigger ssl issue ([#5790](https://github.com/windmill-labs/windmill/issues/5790)) ([b9a776c](https://github.com/windmill-labs/windmill/commit/b9a776c97b3411af18e58cde7a070c4955aaaab4)) +* specify using inline type in system prompt for AI ([#5787](https://github.com/windmill-labs/windmill/issues/5787)) ([791296f](https://github.com/windmill-labs/windmill/commit/791296fa41c5bc45c32944db8bc1b66e1515ea82)) +* workspace preprocessor improvements ([#5784](https://github.com/windmill-labs/windmill/issues/5784)) ([30edcdf](https://github.com/windmill-labs/windmill/commit/30edcdfe0e950b0ab850942bcbc9b4b5ff4fc00c)) + ## [1.491.5](https://github.com/windmill-labs/windmill/compare/v1.491.4...v1.491.5) (2025-05-17) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..3b701f2536 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# Windmill Overview + +Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications. + +## Core Capabilities + +- **Script Development and Execution**: Write and run scripts in Python, TypeScript/JavaScript (Deno/Bun), Go, Bash, SQL, and other languages +- **Workflow Orchestration**: Compose scripts into multi-step flows with conditional logic, loops, and error handling +- **UI Generation**: Automatically generate UIs from scripts or build custom applications with a low-code editor +- **Job Scheduling**: Trigger scripts and flows on schedules, webhooks, or external events +- **Resource Management**: Securely store and use credentials, databases, and other connections + +## Platform Architecture + +The Windmill platform consists of several key components: + +- **Frontend UI**: Web-based interface for script and flow development, app building, and result visualization +- **API Server**: Central API that handles authentication, resource management, and job coordination +- **Workers**: Execute scripts in their respective environments with proper sandboxing +- **Database**: PostgreSQL database for storage of scripts, flows, resources, job results, and more +- **Job Queue**: Queue system for managing job execution, implemented in PostgreSQL +- **Client Libraries**: Libraries for interacting with Windmill from Python, TypeScript, or command line + +# Windmill Backend Architecture + +The Windmill backend is written in Rust and consists of several services working together. These services are designed for horizontal scaling with stateless API servers and workers that can be deployed across multiple machines. + +## Key Components + +- **API Server (`windmill-api`)**: Handles HTTP requests, authentication, and resource management +- **Queue Manager (`windmill-queue`)**: Manages the job queue in PostgreSQL +- **Worker System (`windmill-worker`)**: Executes jobs in sandboxed environments +- **Common Utilities (`windmill-common`)**: Shared code used by multiple services +- **Git Sync (`windmill-git-sync`)**: Synchronizes scripts with Git repositories + +## Job Execution System + +The job execution process follows these steps: + +1. The API server receives a request to run a script or flow and creates a job record in the database +2. The job is added to the queue system in PostgreSQL +3. Workers continuously poll the queue for jobs matching their capabilities +4. When a job is picked up, it's routed to the appropriate language executor +5. The script is executed in a sandboxed environment using NSJAIL for security +6. Results are processed and stored in the database +7. For flows, each step creates a new job that goes through the same process + +Windmill supports worker tags and groups to route jobs to workers with specific capabilities or resource access. + +# Windmill Frontend Architecture + +The Windmill frontend is built with Svelte and provides several key interfaces for interacting with the platform. + +## Key Components + +- **Script Builder**: Code editor with language support, schema inference, and dependency management +- **Flow Builder**: Visual editor for creating multi-step workflows with branching and looping +- **App Editor**: Grid-based editor for building custom UIs that integrate scripts and flows +- **Schema Form System**: Generates form interfaces from script parameters automatically +- **Result Viewer**: Visualizes job results, logs, and execution status + +The frontend uses the Monaco editor (same as VS Code) for code editing, with specialized language support for all supported script languages. + +## UI Framework + +The frontend is built with Svelte, providing a reactive and component-based architecture. Key frontend technologies include: + +- **Svelte/SvelteKit**: Core framework for UI components and routing +- **Monaco Editor**: Code editing experience similar to VS Code +- **Schema Form**: Automatic UI generation from TypeScript/JSON schemas +- **Tailwind CSS**: Utility-first CSS framework for styling diff --git a/backend/.sqlx/query-551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9.json b/backend/.sqlx/query-551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9.json new file mode 100644 index 0000000000..7f1cdf4aa2 --- /dev/null +++ b/backend/.sqlx/query-551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT fv.id\n FROM flow f\n INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]\n WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9" +} diff --git a/backend/.sqlx/query-4fb3881cdbb4b9e93e28f460a9b3715bdc6a52b76c89f3a3913023b13c4e085c.json b/backend/.sqlx/query-9a9e4a8779b0bf8a275d029221dfa1465e5d44cd8a7be5879219ffc8cd7ae6b1.json similarity index 66% rename from backend/.sqlx/query-4fb3881cdbb4b9e93e28f460a9b3715bdc6a52b76c89f3a3913023b13c4e085c.json rename to backend/.sqlx/query-9a9e4a8779b0bf8a275d029221dfa1465e5d44cd8a7be5879219ffc8cd7ae6b1.json index 9709a354cf..4a85852957 100644 --- a/backend/.sqlx/query-4fb3881cdbb4b9e93e28f460a9b3715bdc6a52b76c89f3a3913023b13c4e085c.json +++ b/backend/.sqlx/query-9a9e4a8779b0bf8a275d029221dfa1465e5d44cd8a7be5879219ffc8cd7ae6b1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", + "query": "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('5 mins')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "4fb3881cdbb4b9e93e28f460a9b3715bdc6a52b76c89f3a3913023b13c4e085c" + "hash": "9a9e4a8779b0bf8a275d029221dfa1465e5d44cd8a7be5879219ffc8cd7ae6b1" } diff --git a/backend/.sqlx/query-72916f8e490f8252e0a51b7f562ccc3be832b12102eb86a07d8405a4fa9287d5.json b/backend/.sqlx/query-e7348225a27bbdc9607d7c799e7192cd6ce4088467d91a5cbdc019430320d26d.json similarity index 63% rename from backend/.sqlx/query-72916f8e490f8252e0a51b7f562ccc3be832b12102eb86a07d8405a4fa9287d5.json rename to backend/.sqlx/query-e7348225a27bbdc9607d7c799e7192cd6ce4088467d91a5cbdc019430320d26d.json index 5321de8ccb..000329b19f 100644 --- a/backend/.sqlx/query-72916f8e490f8252e0a51b7f562ccc3be832b12102eb86a07d8405a4fa9287d5.json +++ b/backend/.sqlx/query-e7348225a27bbdc9607d7c799e7192cd6ce4088467d91a5cbdc019430320d26d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n value->'preprocessor_module'->'value' as \"preprocessor_module: _\",\n schema as \"schema: _\"\n FROM flow \n WHERE workspace_id = $1\n AND path = $2", + "query": "SELECT\n value->'preprocessor_module'->'value' as \"preprocessor_module: _\",\n schema as \"schema: _\"\n FROM flow_version\n WHERE \n path = $1\n AND workspace_id = $2\n ORDER BY created_at DESC\n LIMIT 1", "describe": { "columns": [ { @@ -25,5 +25,5 @@ true ] }, - "hash": "72916f8e490f8252e0a51b7f562ccc3be832b12102eb86a07d8405a4fa9287d5" + "hash": "e7348225a27bbdc9607d7c799e7192cd6ce4088467d91a5cbdc019430320d26d" } diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000000..382c7a04a9 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,104 @@ +# Windmill Backend - Rust Best Practices + +## Project Structure + +Windmill uses a workspace-based architecture with multiple crates: + +- **windmill-api**: API server functionality +- **windmill-worker**: Job execution +- **windmill-common**: Shared code used by all crates +- **windmill-queue**: Job & flow queuing +- **windmill-audit**: Audit logging +- Other specialized crates (git-sync, autoscaling, etc.) + +## Adding New Code + +### Module Organization + +- Place new code in the appropriate crate based on functionality +- For API endpoints, create or modify files in `windmill-api/src/` organized by domain +- For shared functionality, use `windmill-common/src/` +- Use the `_ee.rs` suffix for enterprise-only modules +- Follow existing patterns for file structure and organization + +### Error Handling + +- Use the custom `Error` enum from `windmill-common::error` +- Return `Result` or `JsonResult` for functions that can fail +- Use the `?` operator for error propagation +- Add location tracking to errors using `#[track_caller]` + +### Database Operations + +- Use `sqlx` for database operations with prepared statements +- Leverage existing database helper functions in `db.rs` modules +- Use transactions for multi-step operations +- Handle database errors properly + +### API Endpoints + +- Follow existing patterns in the `windmill-api` crate +- Use axum's routing system and extractors +- Group related routes together +- Use consistent response formats (JSON) +- Follow proper authentication and authorization patterns + +## Performance Optimizations + +When generating code, especially involving `serde`, `sqlx`, and `tokio`, prioritize performance by applying the following principles: + +### Serde Optimizations (Serialization & Deserialization) + +- **Specify Structure Explicitly:** When defining structs for Serde (`#[derive(Serialize, Deserialize)]`), use `#[serde(...` attributes extensively. This includes: + - `#[serde(rename = "...")]` or `#[serde(alias = "...")]` to map external names precisely, avoiding dynamic lookups. + - `#[serde(default)]` for optional fields with default values, reducing parsing complexity. + - `#[serde(skip_serializing_if = "...")]` to avoid writing fields that meet a certain condition (e.g., `Option::is_none()`, `Vec::is_empty()`, or a custom function), reducing output size and serialization work. + - `#[serde(skip_serializing)]` or `#[serde(skip_deserializing)]` for fields that should _not_ be included. +- **Prefer Borrowing:** Where possible and safe (data lifetime allows), use `Cow<'a, str>` or `&'a str` (with `#[serde(borrow)]`) instead of `String` for string fields during deserialization. This avoids allocating new strings, enabling zero-copy reading from the input buffer. Apply this principle to byte slices (`&'a [u8]` / `Cow<'a, [u8]>`) and potentially borrowed vectors as well. +- **Avoid Intermediate `Value`:** Unless the data structure is truly dynamic or unknown at compile time, deserialize directly into a well-defined struct or enum rather than into `serde_json::Value` (or equivalent for other formats). This avoids unnecessary heap allocations and type switching. + +### SQLx Optimizations (Database Interaction) + +- **Select Only Necessary Columns:** In `SELECT` queries, list specific column names rather than using `SELECT *`. This reduces data transferred from the database and the work needed for hydration/deserialization. +- **Batch Operations:** For multiple `INSERT`, `UPDATE`, or `DELETE` statements, prefer executing them in a single query if the database and driver support it efficiently (e.g., `INSERT INTO ... VALUES (...), (...), ...`). This minimizes round trips to the database. +- **Avoid N+1 Queries:** Do not loop through results of one query and execute a separate query for each item (e.g., fetching users, then querying for each user's profile in a loop). Instead, use JOINs or a single query with an `IN` clause to fetch related data efficiently. +- **Deserialize Directly:** Use `#[derive(FromRow)]` on structs and ensure the struct fields match the selected columns in the query. This allows SQLx to hydrate objects directly, avoiding intermediate data structures. +- **Parameterize Queries:** Always use SQLx's query methods (`.bind(...)`) to pass values as parameters rather than string formatting. This prevents SQL injection and allows the database to cache query plans, improving performance on repeated executions. + +### Tokio Optimizations (Asynchronous Runtime) + +- **Avoid Blocking Operations:** **Crucially**, never perform blocking operations (synchronous file I/O, `std::thread::sleep`, CPU-bound loops, `std::sync::Mutex::lock`, blocking network calls without `tokio::net`) directly within an `async fn` or a standard `tokio::spawn` task. Blocking pauses the entire worker thread, potentially starving other tasks. Use `tokio::task::spawn_blocking` for CPU-intensive work or blocking I/O. +- **Use Tokio's Async Primitives:** Prefer `tokio::sync` (channels, mutexes, semaphores), `tokio::io`, `tokio::net`, and `tokio::time` over their `std` counterparts in asynchronous contexts. These are designed to yield control back to the scheduler. +- **Manage Concurrency:** Be mindful of how many tasks are spawned. Creating a new task for every tiny piece of work can introduce overhead. Group related asynchronous operations where appropriate. +- **Handle Shared State Efficiently:** Use `Arc` for shared ownership in concurrent tasks. When shared state needs mutation, prefer `tokio::sync::Mutex` over `std::sync::Mutex` in `async` code. Consider `tokio::sync::RwLock` if reads significantly outnumber writes. Minimize the duration for which locks are held. +- **Understand `.await`:** Place `.await` strategically to allow the runtime to switch to other ready tasks. Ensure that `.await` points to genuinely asynchronous operations. +- **Backpressure:** If dealing with data streams or queues between tasks, implement backpressure mechanisms (e.g., bounded channels like `tokio::sync::mpsc::channel`) to prevent one component from overwhelming another or critical resources like the database. + +## Enterprise Features + +- Use feature flags for enterprise functionality +- Conditionally compile with `#[cfg(feature = "enterprise")]` +- Isolate enterprise code in separate modules + +## Code Style + +- Group imports by external and internal crates +- Place struct/enum definitions before implementations +- Group similar functionality together +- Use descriptive naming consistent with the codebase +- Follow existing patterns for async code using tokio + +## Testing + +- Write unit tests for core functionality +- Use the `#[cfg(test)]` module for test code +- For database tests, use the existing test utilities + +## Common Crates Used + +- **tokio**: For async runtime +- **axum**: For web server and routing +- **sqlx**: For database operations +- **serde**: For serialization/deserialization +- **tracing**: For logging and diagnostics +- **reqwest**: For HTTP client functionality diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 164c48393f..eaf6ef8e37 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -214,12 +214,12 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" dependencies = [ "anstyle", - "once_cell", + "once_cell_polyfill", "windows-sys 0.59.0", ] @@ -833,9 +833,9 @@ dependencies = [ [[package]] name = "aws-sdk-sqs" -version = "1.68.0" +version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b484821a335b02b109c17623b8347e692583c2229f8db2f029edd0fdbbd3bea" +checksum = "b742e0981caafc34a57b36d6e492786e2a11638766f49e1c92dec1b55f33d16b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -855,9 +855,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.68.0" +version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5f01ea61fed99b5fe4877abff6c56943342a56ff145e9e0c7e2494419008be" +checksum = "83447efb7179d8e2ad2afb15ceb9c113debbc2ecdf109150e338e2e28b86190b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -877,9 +877,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.69.0" +version = "1.71.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27454e4c55aaa4ef65647e3a1cf095cb834ca6d54e959e2909f1fef96ad87860" +checksum = "c5f9bfbbda5e2b9fe330de098f14558ee8b38346408efe9f2e9cee82dc1636a4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -899,9 +899,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.69.0" +version = "1.71.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd6ef5d00c94215960fabcdf2d9fe7c090eed8be482d66d47b92d4aba1dd4aa" +checksum = "e17b984a66491ec08b4f4097af8911251db79296b3e4a763060b45805746264f" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1096,6 +1096,16 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "aws-smithy-types-convert" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df786cc1aea35d24b609f7a32d05570916edfe7b3e09e81f2faf365f9062f647" +dependencies = [ + "aws-smithy-types", + "chrono", +] + [[package]] name = "aws-smithy-xml" version = "0.60.9" @@ -3860,8 +3870,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum", - "strum_macros", + "strum 0.25.0", + "strum_macros 0.25.3", "syn 2.0.101", "thiserror 2.0.12", ] @@ -5880,7 +5890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" dependencies = [ "anyhow", - "strum", + "strum 0.25.0", "thiserror 1.0.69", "unic-ucd-category", ] @@ -6445,9 +6455,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" dependencies = [ "bytes", "futures-channel", @@ -6490,7 +6500,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.1", + "windows-core 0.61.2", ] [[package]] @@ -6551,9 +6561,9 @@ checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", @@ -6567,9 +6577,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" @@ -8508,6 +8518,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "oneshot" version = "0.1.11" @@ -9039,6 +9055,18 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width 0.2.0", + "unscanny", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -10758,9 +10786,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "rustyline" @@ -11595,9 +11623,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -11608,9 +11636,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -11647,9 +11675,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -11660,9 +11688,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -11679,16 +11707,15 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.101", - "tempfile", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", @@ -11731,9 +11758,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", @@ -11772,9 +11799,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -11877,7 +11904,16 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" dependencies = [ - "strum_macros", + "strum_macros 0.25.3", +] + +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros 0.27.1", ] [[package]] @@ -11893,6 +11929,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", +] + [[package]] name = "subtle" version = "2.6.1" @@ -13831,6 +13880,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + [[package]] name = "untrusted" version = "0.7.1" @@ -14397,7 +14452,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "axum", @@ -14413,6 +14468,7 @@ dependencies = [ "memchr", "object_store", "once_cell", + "pep440_rs", "prometheus", "quote", "rand 0.9.0", @@ -14424,6 +14480,7 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", + "strum 0.27.1", "systemstat", "tikv-jemalloc-ctl", "tikv-jemalloc-sys", @@ -14446,7 +14503,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "argon2", @@ -14555,7 +14612,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.491.5" +version = "1.492.1" dependencies = [ "base64 0.22.1", "chrono", @@ -14570,7 +14627,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.491.5" +version = "1.492.1" dependencies = [ "chrono", "serde", @@ -14583,7 +14640,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "serde", @@ -14597,12 +14654,13 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "async-stream", "aws-config", "aws-sdk-sts", + "aws-smithy-types-convert", "axum", "backon", "bytes", @@ -14627,6 +14685,7 @@ dependencies = [ "magic-crypt", "mail-send", "object_store", + "openidconnect", "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-otlp", @@ -14646,6 +14705,8 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", + "strum 0.27.1", + "strum_macros 0.27.1", "systemstat", "tar", "tempfile", @@ -14662,12 +14723,14 @@ dependencies = [ "tracing-subscriber", "uuid", "windmill-macros", + "windmill-parser-py", "windmill-parser-sql", + "windmill-parser-ts", ] [[package]] name = "windmill-git-sync" -version = "1.491.5" +version = "1.492.1" dependencies = [ "regex", "serde", @@ -14681,7 +14744,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "bytes", @@ -14704,7 +14767,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.491.5" +version = "1.492.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14779,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.491.5" +version = "1.492.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14725,7 +14788,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "lazy_static", @@ -14737,7 +14800,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "serde_json", @@ -14749,7 +14812,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "gosyn", @@ -14761,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "lazy_static", @@ -14773,7 +14836,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "serde_json", @@ -14785,7 +14848,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "nu-parser", @@ -14796,7 +14859,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14807,7 +14870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14818,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "async-recursion", @@ -14826,19 +14889,22 @@ dependencies = [ "lazy_static", "malachite", "malachite-bigint", + "pep440_rs", "phf", "regex", "regex-lite", "rustpython-parser", + "serde", "serde_json", "sqlx", + "toml", "windmill-common", "windmill-parser", ] [[package]] name = "windmill-parser-rust" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -14855,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "lazy_static", @@ -14867,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "lazy_static", @@ -14885,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "getrandom 0.2.16", @@ -14909,7 +14975,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "serde_json", @@ -14919,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "async-recursion", @@ -14952,7 +15018,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.491.5" +version = "1.492.1" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -14962,7 +15028,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.491.5" +version = "1.492.1" dependencies = [ "anyhow", "async-recursion", @@ -15008,6 +15074,7 @@ dependencies = [ "opentelemetry", "oracle", "pem 3.0.5", + "pep440_rs", "postgres-native-tls 0.5.1", "prometheus", "rand 0.9.0", @@ -15079,7 +15146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ "windows-collections", - "windows-core 0.61.1", + "windows-core 0.61.2", "windows-future", "windows-link", "windows-numerics", @@ -15091,7 +15158,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.1", + "windows-core 0.61.2", ] [[package]] @@ -15120,15 +15187,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", "windows-link", - "windows-result 0.3.3", - "windows-strings 0.4.1", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -15137,7 +15204,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.1", + "windows-core 0.61.2", "windows-link", "windows-threading", ] @@ -15220,7 +15287,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.1", + "windows-core 0.61.2", "windows-link", ] @@ -15230,7 +15297,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result 0.3.3", + "windows-result 0.3.4", "windows-strings 0.3.1", "windows-targets 0.53.0", ] @@ -15246,9 +15313,9 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ "windows-link", ] @@ -15264,9 +15331,9 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ "windows-link", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0ed1c2b0e5..069aca4367 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.491.5" +version = "1.492.1" authors.workspace = true edition.workspace = true @@ -32,7 +32,7 @@ members = [ ] [workspace.package] -version = "1.491.5" +version = "1.492.1" authors = ["Ruben Fiszel "] edition = "2021" @@ -59,7 +59,7 @@ embedding = ["windmill-api/embedding"] parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-worker/parquet", "dep:object_store"] prometheus = ["windmill-common/prometheus", "windmill-api/prometheus", "windmill-worker/prometheus", "windmill-queue/prometheus", "dep:prometheus"] flow_testing = ["windmill-worker/flow_testing"] -openidconnect = ["windmill-api/openidconnect"] +openidconnect = ["windmill-api/openidconnect", "windmill-common/openidconnect"] cloud = ["windmill-queue/cloud", "windmill-worker/cloud", "windmill-common/cloud", "windmill-api/cloud"] jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"] tantivy = ["dep:windmill-indexer", "windmill-api/tantivy", "windmill-indexer/enterprise", "windmill-indexer/parquet", "windmill-common/tantivy", "enterprise", "parquet"] @@ -83,7 +83,7 @@ zip = ["windmill-api/zip"] static_frontend = ["windmill-api/static_frontend"] scoped_cache = ["windmill-common/scoped_cache"] # Languages -python = ["windmill-worker/python"] +python = ["windmill-worker/python", "windmill-api/python"] rust = ["windmill-worker/rust"] mysql = ["windmill-worker/mysql"] oracledb = ["windmill-worker/oracledb"] @@ -135,8 +135,11 @@ quote.workspace = true memchr.workspace = true v8 = { workspace = true, optional = true } rustls.workspace = true +pep440_rs.workspace = true systemstat.workspace = true size.workspace = true +strum.workspace = true + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { optional = true, workspace = true } @@ -218,6 +221,7 @@ git-version = "^0" malachite = "=0.4.18" malachite-bigint = "=0.2.0" rustpython-parser = "^0" +pep440_rs = "0.7.3" php-parser-rs = { git = "https://github.com/php-rust-tools/parser", rev = "ec4cb411dec09450946ef57920b7ffced7f6495d" } cron = "^0" mail-send = { version = "0.4.0", features = ["builder"], default-features=false } @@ -342,7 +346,7 @@ openidconnect = { version = "4.0.0-rc.1" } aws-config = "^1" aws-sdk-sqs = "1.57.0" aws-sdk-sts = "^1" - +aws-smithy-types-convert = { version = "^0", features = ["convert-chrono"] } crc = "^3" tar = "^0" http = "^1" @@ -389,3 +393,5 @@ tree-sitter-c-sharp = "0.23.0" tree-sitter-java = "0.23.0" oracle = { version = "0.6.3", features = ["chrono"] } rumqttc = { version = "0.24.0", features = ["use-native-tls"]} +strum = { version = "0.27", features = ["derive"] } +strum_macros = "^0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 81d012d243..04b305d5a2 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -11917062c8a5ea230f27fe750cbde1dbdc0512f9 \ No newline at end of file +6899b8151329218a1df59964dac57e0e004ae25a \ No newline at end of file diff --git a/backend/migrations/20250520104210_workspace_preprocessor_notify.down.sql b/backend/migrations/20250520104210_workspace_preprocessor_notify.down.sql new file mode 100644 index 0000000000..2cdc8d698b --- /dev/null +++ b/backend/migrations/20250520104210_workspace_preprocessor_notify.down.sql @@ -0,0 +1,12 @@ +-- Add down migration script here +CREATE OR REPLACE FUNCTION notify_runnable_version_change() +RETURNS TRIGGER AS $$ +DECLARE + source_type TEXT; +BEGIN + source_type := TG_ARGV[0]; + + PERFORM pg_notify('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/backend/migrations/20250520104210_workspace_preprocessor_notify.up.sql b/backend/migrations/20250520104210_workspace_preprocessor_notify.up.sql new file mode 100644 index 0000000000..bb1ebe0a40 --- /dev/null +++ b/backend/migrations/20250520104210_workspace_preprocessor_notify.up.sql @@ -0,0 +1,19 @@ +-- Add up migration script here +CREATE OR REPLACE FUNCTION notify_runnable_version_change() +RETURNS TRIGGER AS $$ +DECLARE + source_type TEXT; + kind TEXT; +BEGIN + source_type := TG_ARGV[0]; + + IF source_type = 'script' THEN + kind := NEW.kind; + ELSE + kind := 'flow'; + END IF; + + PERFORM pg_notify('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path || ':' || kind); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/backend/parsers/windmill-parser-py-imports/Cargo.toml b/backend/parsers/windmill-parser-py-imports/Cargo.toml index 7bc558f9c0..abd363b42b 100644 --- a/backend/parsers/windmill-parser-py-imports/Cargo.toml +++ b/backend/parsers/windmill-parser-py-imports/Cargo.toml @@ -27,3 +27,6 @@ anyhow.workspace = true lazy_static.workspace = true sqlx.workspace = true async-recursion.workspace = true +toml.workspace = true +serde.workspace = true +pep440_rs.workspace = true diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index 3b06a491d5..92f4346115 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -11,7 +11,7 @@ mod mapping; use async_recursion::async_recursion; use itertools::Itertools; use lazy_static::lazy_static; -use std::collections::HashMap; +use std::{collections::HashMap, str::FromStr}; use mapping::{FULL_IMPORTS_MAP, SHORT_IMPORTS_MAP}; #[cfg(not(target_arch = "wasm32"))] @@ -25,7 +25,10 @@ use rustpython_parser::{ Parse, }; use sqlx::{Pool, Postgres}; -use windmill_common::{error, worker::PythonAnnotations}; +use windmill_common::{ + error::{self, to_anyhow}, + worker::PythonAnnotations, +}; const DEF_MAIN: &str = "def main("; @@ -242,8 +245,7 @@ pub async fn parse_python_imports( w_id: &str, path: &str, db: &Pool, - already_visited: &mut Vec, - annotated_pyv_numeric: &mut Option, + version_specifiers: &mut Vec, ) -> error::Result<(Vec, Option)> { let mut compile_error_hint: Option = None; let mut imports = parse_python_imports_inner( @@ -251,9 +253,10 @@ pub async fn parse_python_imports( w_id, path, db, - already_visited, - annotated_pyv_numeric, - &mut annotated_pyv_numeric.and_then(|_| Some(path.to_owned())), + &mut vec![], + version_specifiers, + // &mut version_specifier.and_then(|_| Some(path.to_owned())), + &mut None ) .await? .into_values() @@ -279,6 +282,7 @@ pub async fn parse_python_imports( .flatten() .collect::>>()? .into_iter() + .filter(|x| !x.trim_start().starts_with("--") && !x.trim().is_empty()) .unique() .collect_vec(); @@ -304,11 +308,34 @@ async fn parse_python_imports_inner( path: &str, db: &Pool, already_visited: &mut Vec, - annotated_pyv_numeric: &mut Option, + version_specifiers: &mut Vec, path_where_annotated_pyv: &mut Option, ) -> error::Result> { let PythonAnnotations { py310, py311, py312, py313, .. } = PythonAnnotations::parse(&code); + let mut push_version_specifiers = |perform, unparsed: String| -> error::Result<()> { + if perform { + pep440_rs::VersionSpecifiers::from_str(unparsed.as_str()) + .ok() + .map(|vs| version_specifiers.extend(vs.to_vec())); + } + Ok(()) + }; + push_version_specifiers(py310, "==3.10.*".to_owned())?; + push_version_specifiers(py311, "==3.11.*".to_owned())?; + push_version_specifiers(py312, "==3.12.*".to_owned())?; + push_version_specifiers(py313, "==3.13.*".to_owned())?; + + for x in code.lines() { + if x.starts_with("# py:") || x.starts_with("#py:") { + push_version_specifiers( + true, + x.replace('#', "").replace("py:", "").trim().to_owned(), + )?; + } else if !x.starts_with('#') { + break; + } + } // we pass only if there is none or only one annotation // Naive: @@ -323,39 +350,48 @@ async fn parse_python_imports_inner( // This way we make sure there is no multiple annotations for same script // and we get detailed span on conflicting versions - let mut check = |is_py_xyz, numeric| -> error::Result<()> { - if is_py_xyz { - if let Some(v) = annotated_pyv_numeric { - if *v != numeric { - return Err(error::Error::from(anyhow::anyhow!( - "Annotated 2 or more different python versions: \n - py{v} at {}\n - py{numeric} at {path}\nIt is possible to use only one.", - path_where_annotated_pyv.clone().unwrap_or("Unknown".to_owned()) - ))); - } - } else { - *annotated_pyv_numeric = Some(numeric); - } - *path_where_annotated_pyv = Some(path.to_owned()); - } - Ok(()) - }; + #[derive(serde::Serialize, serde::Deserialize)] + struct InlineMetadata { + requires_python: String, + dependencies: Vec, + } - check(py310, 310)?; - check(py311, 311)?; - check(py312, 312)?; - check(py313, 313)?; - - let find_requirements = code - .lines() - .find_position(|x| x.starts_with("#requirements:") || x.starts_with("# requirements:")); - if let Some((pos, _)) = find_requirements { + let find_requirements = code.lines().find_position(|x| { + x.starts_with("#requirements:") + || x.starts_with("# requirements:") + || x.starts_with("# /// script") + }); + if let Some((pos, item)) = find_requirements { let mut requirements = HashMap::new(); - code.lines() - .skip(pos + 1) - .map_while(|x| { - RE.captures(x).and_then(|x| { - x.get(1).map(|m| { - let requirement = m.as_str().to_string(); + if item.starts_with("# /// script") { + let mut incorrect = false; + let metadata = code + .lines() + .skip(pos + 1) + .map_while(|x| { + incorrect = !x.starts_with('#'); + if incorrect || x.starts_with("# ///") { + None + } else { + x.get(1..) + } + }) + .join("\n") + .parse::() + .map_err(to_anyhow)?; + + { + if let Some(v) = metadata.get("requires-python").and_then(|v| v.as_str()) { + push_version_specifiers(true, v.to_owned())?; + } + }; + + metadata + .get("dependencies") + .and_then(|dependencies| dependencies.as_array()) + .inspect(|list| { + for dependency_v in list.into_iter() { + let requirement = dependency_v.as_str().unwrap_or("ERROR").to_owned(); let key = extract_pkg_name(&requirement); requirements.insert( key.clone(), @@ -367,11 +403,31 @@ async fn parse_python_imports_inner( key, }, ); + } + }); + } else { + code.lines() + .skip(pos + 1) + .map_while(|x| { + RE.captures(x).and_then(|x| { + x.get(1).map(|m| { + let requirement = m.as_str().to_string(); + let key = extract_pkg_name(&requirement); + requirements.insert( + key.clone(), + NImportResolved::Pin { + pins: vec![ImportPin { + pkg: requirement.clone(), + path: Default::default(), + }], + key, + }, + ); + }) }) }) - }) - .collect_vec(); - + .collect_vec(); + } Ok(requirements) } else { let find_extra_requirements = code.lines().find_position(|x| { @@ -442,7 +498,7 @@ async fn parse_python_imports_inner( &rpath, db, already_visited, - annotated_pyv_numeric, + version_specifiers, path_where_annotated_pyv, ) .await? diff --git a/backend/parsers/windmill-parser-py-imports/tests/tests.rs b/backend/parsers/windmill-parser-py-imports/tests/tests.rs index 9fee9b21c9..b7fd2c0737 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/tests.rs +++ b/backend/parsers/windmill-parser-py-imports/tests/tests.rs @@ -18,16 +18,8 @@ def main(): pass "; - let mut already_visited = vec![]; - let (r, ..) = parse_python_imports( - code, - "test-workspace", - "f/foo/bar", - &db, - &mut already_visited, - &mut None, - ) - .await?; + let (r, ..) = + parse_python_imports(code, "test-workspace", "f/foo/bar", &db, &mut vec![]).await?; // println!("{}", serde_json::to_string(&r)?); assert_eq!( r, @@ -59,16 +51,8 @@ def main(): pass "; - let mut already_visited = vec![]; - let (r, ..) = parse_python_imports( - code, - "test-workspace", - "f/foo/bar", - &db, - &mut already_visited, - &mut None, - ) - .await?; + let (r, ..) = + parse_python_imports(code, "test-workspace", "f/foo/bar", &db, &mut vec![]).await?; println!("{}", serde_json::to_string(&r)?); assert_eq!(r, vec!["burkina=0.4", "nigeria"]); @@ -89,17 +73,9 @@ def main(): pass "; - let mut already_visited = vec![]; - let (r, ..) = parse_python_imports( - code, - "test-workspace", - "f/foo/bar", - &db, - &mut already_visited, - &mut None, - ) - .await?; + let (r, ..) = + parse_python_imports(code, "test-workspace", "f/foo/bar", &db, &mut vec![]).await?; println!("{}", serde_json::to_string(&r)?); assert_eq!( r, diff --git a/backend/src/main.rs b/backend/src/main.rs index 821b209048..a4148d93c6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -5,7 +5,6 @@ * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ - use anyhow::Context; use monitor::{ load_base_url, load_otel, reload_critical_alerts_on_db_oversize, @@ -23,6 +22,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, time::{Duration, Instant}, }; +use strum::IntoEnumIterator; use tokio::{fs::File, io::AsyncReadExt, task::JoinHandle}; use uuid::Uuid; use windmill_api::HTTP_CLIENT; @@ -50,6 +50,7 @@ use windmill_common::{ }, scripts::ScriptLang, stats_ee::schedule_stats, + triggers::TriggerKind, utils::{hostname, rd_string, Mode, GIT_VERSION, MODE_AND_ADDONS}, worker::{ reload_custom_tags_setting, Connection, HUB_CACHE_DIR, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP, @@ -68,7 +69,7 @@ use tikv_jemallocator::Jemalloc; static GLOBAL: Jemalloc = Jemalloc; #[cfg(feature = "parquet")] -use windmill_common::global_settings::OBJECT_STORE_CACHE_CONFIG_SETTING; +use windmill_common::global_settings::OBJECT_STORE_CONFIG_SETTING; use windmill_worker::{ get_hub_script_content_and_requirements, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, CSHARP_CACHE_DIR, @@ -91,7 +92,7 @@ use crate::monitor::{ }; #[cfg(feature = "parquet")] -use crate::monitor::reload_s3_cache_setting; +use windmill_common::s3_helpers::reload_object_store_setting; const DEFAULT_NUM_WORKERS: usize = 1; const DEFAULT_PORT: u16 = 8000; @@ -791,11 +792,37 @@ Windmill Community Edition {GIT_VERSION} let payload = n.payload(); tracing::info!("Runnable version change detected: {}", payload); match payload.split(':').collect::>().as_slice() { - [workspace_id, source_type, path] => { + [workspace_id, source_type, path, kind] => { let key = (workspace_id.to_string(), path.to_string()); match source_type { &"script" => { windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key); + match kind { + &"preprocessor" => { + match sqlx::query_scalar!( + "SELECT fv.id + FROM flow f + INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)] + WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2", + path, + workspace_id + ).fetch_all(&db).await { + Ok(flow_versions) => { + tracing::debug!("Workspace preprocessor {} changed, removing runnable format version cache for flow versions {:?}", path, flow_versions); + for version in flow_versions { + for trigger_kind in TriggerKind::iter() { + let key = (windmill_common::triggers::HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), version, trigger_kind); + windmill_common::triggers::RUNNABLE_FORMAT_VERSION_CACHE.remove(&key); + } + } + } + Err(e) => { + tracing::error!("Error fetching flow paths: {e:#}"); + } + } + }, + _ => {} + } } &"flow" => { windmill_common::FLOW_VERSION_CACHE.remove(&key); @@ -880,9 +907,9 @@ Windmill Community Edition {GIT_VERSION} reload_job_default_timeout_setting(&conn).await }, #[cfg(feature = "parquet")] - OBJECT_STORE_CACHE_CONFIG_SETTING => { + OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { - reload_s3_cache_setting(&db).await + reload_object_store_setting(&db).await; } }, SCIM_TOKEN_SETTING => { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 24efd181c0..d2a5657c6c 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -33,8 +33,11 @@ use windmill_common::ee::low_disk_alerts; #[cfg(feature = "enterprise")] use windmill_common::ee::{jobs_waiting_alerts, worker_groups_alerts}; +use windmill_common::client::AuthedClient; #[cfg(feature = "oauth2")] use windmill_common::global_settings::OAUTH_SETTING; +#[cfg(feature = "parquet")] +use windmill_common::s3_helpers::reload_object_store_setting; use windmill_common::{ agent_workers::DECODED_AGENT_TOKEN, auth::create_token_for_owner, @@ -75,19 +78,13 @@ use windmill_common::{ }; use windmill_queue::{cancel_job, MiniPulledJob, SameWorkerPayload}; use windmill_worker::{ - handle_job_error, AuthedClient, JobCompletedSender, SameWorkerSender, BUNFIG_INSTALL_SCOPES, + handle_job_error, JobCompletedSender, SameWorkerSender, BUNFIG_INSTALL_SCOPES, INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, MAVEN_REPOS, NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, }; #[cfg(feature = "parquet")] -use windmill_common::s3_helpers::{ - build_object_store_from_settings, build_s3_client_from_settings, S3Settings, - OBJECT_STORE_CACHE_SETTINGS, -}; - -#[cfg(feature = "parquet")] -use windmill_common::global_settings::OBJECT_STORE_CACHE_CONFIG_SETTING; +use windmill_common::s3_helpers::ObjectStoreReload; #[cfg(feature = "enterprise")] use crate::ee::verify_license_key; @@ -241,7 +238,23 @@ pub async fn initial_load( #[cfg(feature = "parquet")] if !disable_s3_store { if let Some(db) = conn.as_sql() { - reload_s3_cache_setting(db).await; + let db2 = db.clone(); + match reload_object_store_setting(db).await { + ObjectStoreReload::Later => { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(10)).await; + match reload_object_store_setting(&db2).await { + ObjectStoreReload::Later => { + tracing::error!("Giving up on loading object store setting"); + } + ObjectStoreReload::Never => { + tracing::info!("Object store setting successfully loaded"); + } + } + }); + } + ObjectStoreReload::Never => (), + } } } @@ -631,7 +644,7 @@ async fn send_log_file_to_object_store( } #[cfg(feature = "parquet")] - let s3_client = OBJECT_STORE_CACHE_SETTINGS.read().await.clone(); + let s3_client = windmill_common::s3_helpers::get_object_store().await; #[cfg(feature = "parquet")] if let Some(s3_client) = s3_client { let path = std::path::Path::new(TMP_WINDMILL_LOGS_SERVICE) @@ -917,10 +930,7 @@ async fn delete_log_files_from_disk_and_store( _s3_prefix: &str, ) { #[cfg(feature = "parquet")] - let os = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone(); + let os = windmill_common::s3_helpers::get_object_store().await; #[cfg(not(feature = "parquet"))] let os: Option<()> = None; @@ -1101,62 +1111,6 @@ pub async fn reload_delete_logs_periodically_setting(conn: &Connection) { } } -#[cfg(feature = "parquet")] -pub async fn reload_s3_cache_setting(db: &DB) { - use windmill_common::{ - ee::{get_license_plan, LicensePlan}, - s3_helpers::ObjectSettings, - }; - - let s3_config = load_value_from_global_settings(db, OBJECT_STORE_CACHE_CONFIG_SETTING).await; - if let Err(e) = s3_config { - tracing::error!("Error reloading s3 cache config: {:?}", e) - } else { - if let Some(v) = s3_config.unwrap() { - if matches!(get_license_plan().await, LicensePlan::Pro) { - tracing::error!("S3 cache is not available for pro plan"); - return; - } - let mut s3_cache_settings = OBJECT_STORE_CACHE_SETTINGS.write().await; - let setting = serde_json::from_value::(v); - if let Err(e) = setting { - tracing::error!("Error parsing s3 cache config: {:?}", e) - } else { - let s3_client = build_object_store_from_settings(setting.unwrap()).await; - if let Err(e) = s3_client { - tracing::error!("Error building s3 client from settings: {:?}", e) - } else { - tracing::info!("Loaded object store {:?}", setting.unwrap().get_bucket()); - *s3_cache_settings = Some(s3_client.unwrap()); - } - } - } else { - let mut s3_cache_settings = OBJECT_STORE_CACHE_SETTINGS.write().await; - if std::env::var("S3_CACHE_BUCKET").is_ok() { - if matches!(get_license_plan().await, LicensePlan::Pro) { - tracing::error!("S3 cache is not available for pro plan"); - return; - } - *s3_cache_settings = build_s3_client_from_settings(S3Settings { - bucket: None, - region: None, - access_key: None, - secret_key: None, - endpoint: None, - store_logs: None, - path_style: None, - allow_http: None, - port: None, - }) - .await - .ok(); - } else { - *s3_cache_settings = None; - } - } - } -} - pub async fn reload_job_default_timeout_setting(conn: &Connection) { reload_option_setting_with_tracing( conn, diff --git a/backend/tests/fixtures/multipython.sql b/backend/tests/fixtures/multipython.sql new file mode 100644 index 0000000000..fa7d9c8c8d --- /dev/null +++ b/backend/tests/fixtures/multipython.sql @@ -0,0 +1,20 @@ +INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( +'test-workspace', +'test-user', +'# py312 +', +'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', +'', +'', +'f/multipython/aliases', 2468135790, 'python3', ''); + +INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( +'test-workspace', +'test-user', +'# py: >=3.9,!=3.12.2 +', +'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', +'', +'', +'f/multipython/script1', 2345678901, 'python3', ''); + diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 8b7509bb84..c8299796f8 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -3970,7 +3970,7 @@ async fn assert_lockfile( #[sqlx::test(fixtures("base", "lockfile_python"))] async fn test_requirements_python(db: Pool) { let content = r#" -# py311 +# py: 3.11.11 # requirements: # tiny==0.1.3 @@ -3988,7 +3988,7 @@ def main(): &db, content, ScriptLang::Python3, - vec!["# py311", "tiny==0.1.3"], + vec!["# py: 3.11.11", "tiny==0.1.3"], ) .await; } @@ -3998,7 +3998,7 @@ def main(): async fn test_extra_requirements_python(db: Pool) { { let content = r#" -# py311 +# py: ==3.11.11 # extra_requirements: # tiny @@ -4016,7 +4016,7 @@ def main(): &db, content, ScriptLang::Python3, - vec!["# py311", "bottle==0.13.2", "tiny==0.1.2"], + vec!["# py: 3.11.11", "bottle==0.13.2", "tiny==0.1.2"], ) .await; } @@ -4026,7 +4026,7 @@ def main(): #[sqlx::test(fixtures("base", "lockfile_python"))] async fn test_extra_requirements_python2(db: Pool) { let content = r#" -# py311 +# py: ==3.11.11 # extra_requirements: # tiny==0.1.3 @@ -4040,7 +4040,7 @@ def main(): &db, content, ScriptLang::Python3, - vec!["# py311", "simplejson==3.20.1", "tiny==0.1.3"], + vec!["# py: 3.11.11", "simplejson==3.20.1", "tiny==0.1.3"], ) .await; } @@ -4049,7 +4049,7 @@ def main(): #[sqlx::test(fixtures("base", "lockfile_python"))] async fn test_pins_python(db: Pool) { let content = r#" -# py311 +# py: ==3.11.11 # extra_requirements: # tiny==0.1.3 # bottle==0.13.2 @@ -4069,7 +4069,7 @@ def main(): content, ScriptLang::Python3, vec![ - "# py311", + "# py: 3.11.11", "bottle==0.13.2", "microdot==2.2.0", "simplejson==3.19.3", @@ -4078,6 +4078,39 @@ def main(): ) .await; } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base", "multipython"))] +async fn test_multipython_python(db: Pool) { + let content = r#"# py: <=3.12.2, >=3.12.0 +import f.multipython.script1 +import f.multipython.aliases +"# + .to_string(); + + assert_lockfile(&db, content, ScriptLang::Python3, vec!["# py: 3.12.1\n"]).await; +} + +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base", "multipython"))] +async fn test_inline_script_metadata_python(db: Pool) { + let content = r#"# py_select_latest +# /// script +# requires-python = ">3.11,<3.12.3,!=3.12.2" +# dependencies = [ +# "tiny==0.1.3", +# ] +# /// +"# + .to_string(); + + assert_lockfile( + &db, + content, + ScriptLang::Python3, + vec!["# py: 3.12.1", "tiny==0.1.3"], + ) + .await; +} #[sqlx::test(fixtures("base", "result_format"))] async fn test_result_format(db: Pool) { let ordered_result_job_id = "1eecb96a-c8b0-4a3d-b1b6-087878c55e41"; diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 233fbeec54..9f6de0c5ca 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -18,7 +18,7 @@ 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"] prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"] -openidconnect = ["dep:openidconnect"] +openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"] tantivy = ["dep:windmill-indexer"] kafka = ["dep:rdkafka"] nats = ["dep:async-nats", "dep:nkeys"] @@ -36,6 +36,7 @@ deno_core = ["dep:deno_core", "dep:deno_error"] gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"] cloud = ["windmill-common/cloud"] mcp = ["dep:rmcp"] +python = [] [dependencies] rmcp = { git = "https://github.com/windmill-labs/rust-sdk", features = ["transport-sse-server"], optional = true } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5020067440..fd3e81f209 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.491.5 + version: 1.492.1 title: Windmill API contact: @@ -11105,6 +11105,23 @@ paths: items: $ref: "#/components/schemas/AutoscalingEvent" + /configs/list_available_python_versions: + get: + summary: Get currently available python versions provided by UV. + operationId: listAvailablePythonVersions + tags: + - config + # parameters: + responses: + "200": + description: List of python versions + content: + application/json: + schema: + type: array + items: + type: string + /agent_workers/create_agent_token: post: summary: create agent token @@ -12590,6 +12607,11 @@ paths: required: true schema: type: string + - name: pagination_offset + in: query + required: false + schema: + type: integer responses: "200": description: search results @@ -12602,15 +12624,26 @@ paths: description: a list of the terms that couldn't be parsed (and thus ignored) type: array items: - type: object - properties: - dancer: - type: string + type: string hits: description: the jobs that matched the query type: array items: $ref: "#/components/schemas/JobSearchHit" + hit_count: + description: how many jobs matched in total + type: number + index_metadata: + description: Metadata about the index current state + type: object + properties: + indexed_until: + description: Datetime of the most recently indexed job + type: string + format: date-time + lost_lock_ownership: + description: Is the current indexer service being replaced + type: boolean /srch/index/search/service_logs: get: @@ -16786,7 +16819,6 @@ components: type: string required: - s3 - TeamsChannel: type: object required: @@ -16810,4 +16842,4 @@ components: channel_name: type: string description: Microsoft Teams channel name - minLength: 1 \ No newline at end of file + minLength: 1 diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index f75deb91f8..e3a2adb38f 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -10,12 +10,17 @@ use http::{header::CONTENT_TYPE, request::Parts, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use sqlx::types::JsonRawValue; -use windmill_common::{error::Error, worker::to_raw_value, DB}; -use windmill_queue::{PushArgsOwned, TriggerKind}; +use windmill_common::{ + error::Error, + triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind}, + worker::to_raw_value, + DB, +}; +use windmill_queue::PushArgsOwned; use crate::{ db::ApiAuthed, - trigger_helpers::{get_runnable_format, RunnableFormat, RunnableFormatVersion, RunnableId}, + trigger_helpers::{get_runnable_format, RunnableId}, }; #[derive(Debug)] diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 12a36e30a7..07127e53a1 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -57,7 +57,6 @@ use { use crate::{ args::RawWebhookArgs, db::{ApiAuthed, DB}, - trigger_helpers::{RunnableFormat, RunnableFormatVersion}, users::fetch_api_authed, utils::RunnableKind, }; @@ -76,11 +75,12 @@ use sqlx::types::Json as SqlxJson; use windmill_common::{ db::UserDB, error::{JsonResult, Result}, + triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind}, utils::{not_found_if_none, paginate, Pagination, StripPath}, worker::{to_raw_value, CLOUD_HOSTED}, }; -use windmill_queue::{PushArgs, PushArgsOwned, TriggerKind}; +use windmill_queue::{PushArgs, PushArgsOwned}; const KEEP_LAST: i64 = 20; diff --git a/backend/windmill-api/src/configs.rs b/backend/windmill-api/src/configs.rs index 81b65216e0..e8770b2b5e 100644 --- a/backend/windmill-api/src/configs.rs +++ b/backend/windmill-api/src/configs.rs @@ -33,6 +33,10 @@ pub fn global_service() -> Router { "/list_autoscaling_events/:worker_group", get(list_autoscaling_events), ) + .route( + "/list_available_python_versions", + get(list_available_python_versions), + ) } #[derive(Serialize, Deserialize, FromRow)] @@ -205,6 +209,24 @@ async fn list_autoscaling_events( Ok(Json(events)) } +async fn list_available_python_versions() -> error::JsonResult> { + #[cfg(not(feature = "python"))] + return Err(error::Error::BadRequest( + "Python listing available only with 'python' feature enabled".to_string(), + )); + + #[cfg(feature = "python")] + use itertools::Itertools; + #[cfg(feature = "python")] + return Ok(Json( + windmill_worker::PyV::list_available_python_versions() + .await + .iter() + .map(|v| v.to_string()) + .collect_vec(), + )); +} + #[cfg(feature = "enterprise")] async fn list_configs( authed: ApiAuthed, diff --git a/backend/windmill-api/src/gcp_triggers_ee.rs b/backend/windmill-api/src/gcp_triggers_ee.rs index 6e39cfe07c..0dc672580c 100644 --- a/backend/windmill-api/src/gcp_triggers_ee.rs +++ b/backend/windmill-api/src/gcp_triggers_ee.rs @@ -11,9 +11,9 @@ use windmill_common::db::UserDB; use windmill_common::worker::to_raw_value; use windmill_common::{ error::{Error as WindmillError, Result as WindmillResult}, + triggers::TriggerKind, utils::empty_as_none, }; -use windmill_queue::TriggerKind; #[derive(sqlx::Type, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] @@ -86,7 +86,7 @@ pub async fn manage_google_subscription( _subscription_mode: SubscriptionMode, _create_update_config: Option, _trigger_mode: bool, - _is_flow: bool + _is_flow: bool, ) -> WindmillResult { Ok(CreateUpdateConfig::default()) } diff --git a/backend/windmill-api/src/http_trigger_args.rs b/backend/windmill-api/src/http_trigger_args.rs index 828948b114..6aab75e46e 100644 --- a/backend/windmill-api/src/http_trigger_args.rs +++ b/backend/windmill-api/src/http_trigger_args.rs @@ -6,13 +6,17 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; -use windmill_common::{error::Error, worker::to_raw_value, DB}; +use windmill_common::{ + error::Error, + triggers::{RunnableFormat, RunnableFormatVersion}, + worker::to_raw_value, + DB, +}; use windmill_queue::PushArgsOwned; use crate::{ args::{try_from_request_body, Body, RawWebhookArgs, WebhookArgs, WebhookArgsMetadata}, db::ApiAuthed, - trigger_helpers::{RunnableFormat, RunnableFormatVersion}, }; pub struct RawHttpTriggerArgs(pub RawWebhookArgs); diff --git a/backend/windmill-api/src/http_triggers.rs b/backend/windmill-api/src/http_triggers.rs index 82d1fd8b0c..b17c8205ad 100644 --- a/backend/windmill-api/src/http_triggers.rs +++ b/backend/windmill-api/src/http_triggers.rs @@ -41,11 +41,11 @@ use windmill_common::{ db::UserDB, error::{self, JsonResult}, s3_helpers::S3Object, + triggers::TriggerKind, utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath}, worker::CLOUD_HOSTED, }; use windmill_git_sync::handle_deployment_metadata; -use windmill_queue::TriggerKind; lazy_static::lazy_static! { static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"/?:[-\w]+").unwrap(); diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1a3e161d5d..cf9e7d06de 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -83,8 +83,6 @@ use windmill_common::{ }, }; -#[cfg(all(feature = "enterprise", feature = "parquet"))] -use windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS; #[cfg(feature = "prometheus")] use windmill_common::{METRICS_DEBUG_ENABLED, METRICS_ENABLED}; @@ -1058,7 +1056,7 @@ async fn get_logs_from_store( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { tracing::debug!("Getting logs from store: {file_index:?}"); - if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { tracing::debug!("object store client present, streaming from there"); let logs = logs.to_string(); @@ -3473,7 +3471,7 @@ pub async fn run_flow_by_path( &authed, &db, &w_id, - RunnableId::from_flow_path(&flow_path.0), + RunnableId::from_flow_path(flow_path.to_path()), run_query.skip_preprocessor, ) .await?; @@ -3672,7 +3670,7 @@ pub async fn run_script_by_path( &authed, &db, &w_id, - RunnableId::from_script_path(&script_path.0), + RunnableId::from_script_path(script_path.to_path()), run_query.skip_preprocessor, ) .await?; @@ -4351,17 +4349,18 @@ pub async fn run_wait_result_job_by_path_get( let mut args = args.process_args(&authed, &db, &w_id, None).await?; args.body = args::Body::HashMap(payload_args); + let script_path = script_path.to_path(); + let args = args .to_args_from_runnable( &db, &w_id, - RunnableId::from_script_path(&script_path.0), + RunnableId::from_script_path(script_path), run_query.skip_preprocessor, ) .await?; check_queue_too_long(&db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?; - let script_path = script_path.to_path(); check_scopes(&authed, || format!("run:script/{script_path}"))?; let mut tx = user_db.clone().begin(&authed).await?; @@ -4457,7 +4456,7 @@ pub async fn run_wait_result_flow_by_path_get( .to_args_from_runnable( &db, &w_id, - RunnableId::from_flow_path(&flow_path.0), + RunnableId::from_flow_path(flow_path.to_path()), run_query.skip_preprocessor, ) .await?; @@ -4482,7 +4481,7 @@ pub async fn run_wait_result_script_by_path( &authed, &db, &w_id, - RunnableId::from_script_path(&script_path.0), + RunnableId::from_script_path(script_path.to_path()), run_query.skip_preprocessor, ) .await?; @@ -4692,7 +4691,7 @@ pub async fn run_wait_result_flow_by_path( &authed, &db, &w_id, - RunnableId::from_flow_path(&flow_path.0), + RunnableId::from_flow_path(flow_path.to_path()), run_query.skip_preprocessor, ) .await?; @@ -4961,10 +4960,7 @@ async fn run_bundle_preview_script( uploaded = true; #[cfg(all(feature = "enterprise", feature = "parquet"))] - let object_store = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone(); + let object_store = windmill_common::s3_helpers::get_object_store().await; #[cfg(not(all(feature = "enterprise", feature = "parquet")))] let object_store: Option<()> = None; @@ -5662,7 +5658,7 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R } #[cfg(all(feature = "enterprise", feature = "parquet"))] - if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { let file = os .get(&object_store::path::Path::from(format!("logs/{file_p}"))) .await; diff --git a/backend/windmill-api/src/mqtt_triggers.rs b/backend/windmill-api/src/mqtt_triggers.rs index 16508def86..9f3fd19827 100644 --- a/backend/windmill-api/src/mqtt_triggers.rs +++ b/backend/windmill-api/src/mqtt_triggers.rs @@ -7,7 +7,6 @@ use crate::{ users::fetch_api_authed, }; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; -use windmill_queue::TriggerKind; use axum::{ async_trait, @@ -44,6 +43,7 @@ use windmill_audit::{audit_ee::audit_log, ActionKind}; use windmill_common::{ db::UserDB, error::{self, JsonResult}, + triggers::TriggerKind, utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath}, worker::{to_raw_value, CLOUD_HOSTED}, INSTANCE_NAME, diff --git a/backend/windmill-api/src/postgres_triggers/handler.rs b/backend/windmill-api/src/postgres_triggers/handler.rs index cf13a967d1..933582670c 100644 --- a/backend/windmill-api/src/postgres_triggers/handler.rs +++ b/backend/windmill-api/src/postgres_triggers/handler.rs @@ -25,7 +25,7 @@ use windmill_common::error::Error; use windmill_common::{ db::UserDB, error::{self, JsonResult, Result}, - utils::{not_found_if_none, paginate, Pagination, StripPath}, + utils::{not_found_if_none, paginate, Pagination, StripPath, empty_as_none}, worker::CLOUD_HOSTED, }; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -46,7 +46,8 @@ pub struct Postgres { pub dbname: String, #[serde(default)] pub sslmode: String, - pub root_certificate_pem: String, + #[serde(default, deserialize_with = "empty_as_none")] + pub root_certificate_pem: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] diff --git a/backend/windmill-api/src/postgres_triggers/mod.rs b/backend/windmill-api/src/postgres_triggers/mod.rs index 0be19d6c4c..d557ca67cc 100644 --- a/backend/windmill-api/src/postgres_triggers/mod.rs +++ b/backend/windmill-api/src/postgres_triggers/mod.rs @@ -84,8 +84,8 @@ pub async fn get_raw_postgres_connection( } }; - let options = if !db.root_certificate_pem.is_empty() { - options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec()) + let options = if let Some(root_certificate_pem) = &db.root_certificate_pem { + options.ssl_root_cert_from_pem(root_certificate_pem.as_bytes().to_vec()) } else { options }; @@ -96,7 +96,6 @@ pub async fn get_raw_postgres_connection( options } }; - Ok(PgConnection::connect_with(&options).await?) } diff --git a/backend/windmill-api/src/postgres_triggers/trigger.rs b/backend/windmill-api/src/postgres_triggers/trigger.rs index f340666977..71daffb4a9 100644 --- a/backend/windmill-api/src/postgres_triggers/trigger.rs +++ b/backend/windmill-api/src/postgres_triggers/trigger.rs @@ -15,22 +15,22 @@ use crate::{ trigger_helpers::TriggerJobArgs, users::fetch_api_authed, }; -use windmill_queue::TriggerKind; use bytes::{BufMut, Bytes, BytesMut}; use chrono::TimeZone; use futures::{pin_mut, SinkExt, StreamExt}; -use native_tls::TlsConnector; +use native_tls::{Certificate, TlsConnector}; use pg_escape::{quote_identifier, quote_literal}; use rand::seq::SliceRandom; -use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, SimpleQueryMessage}; +use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, NoTls, SimpleQueryMessage}; use rust_postgres_native_tls::MakeTlsConnector; use serde::Deserialize; use serde_json::value::RawValue; use sqlx::types::Json as SqlxJson; use windmill_common::{ - db::UserDB, error, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME, + db::UserDB, error, triggers::TriggerKind, utils::report_critical_error, worker::to_raw_value, + INSTANCE_NAME, }; use super::{ @@ -79,6 +79,45 @@ enum Error { Tls(#[from] native_tls::Error), } +fn build_tls_connector( + ssl_mode: SslMode, + root_certificate_pem: Option<&String>, +) -> Result, Error> { + let get_tls_builder_for_verify = |root_certificate: Option<&String>| { + let mut builder = TlsConnector::builder(); + if let Some(root_certificate) = root_certificate { + let root_certificate_pem = Certificate::from_pem(root_certificate.as_bytes()).map_err(|e| { + Error::Common(error::Error::BadConfig(format!("Invalid Certs: {e:#}"))) + })?; + builder.add_root_certificate(root_certificate_pem); + } + Ok::<_, Error>(builder) + }; + let connector = match ssl_mode { + SslMode::Disable => return Ok(None), + SslMode::Require | SslMode::Prefer => { + let mut builder = TlsConnector::builder(); + builder.danger_accept_invalid_certs(true); + builder.danger_accept_invalid_hostnames(true); + builder + } + + SslMode::VerifyCa => { + let mut builder = get_tls_builder_for_verify(root_certificate_pem)?; + builder.danger_accept_invalid_hostnames(true); + builder + } + + SslMode::VerifyFull => { + let builder = get_tls_builder_for_verify(root_certificate_pem)?; + builder + } + _ => unreachable!(), + }; + + Ok(Some(MakeTlsConnector::new(connector.build()?))) +} + pub struct PostgresSimpleClient(Client); impl PostgresSimpleClient { @@ -112,20 +151,27 @@ impl PostgresSimpleClient { config.password(&database.password); } - if !database.root_certificate_pem.is_empty() { - config.ssl_root_cert(database.root_certificate_pem.as_bytes()); - } + let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?; - let connector = MakeTlsConnector::new(TlsConnector::new()?); - - let (client, connection) = config.connect(connector).await?; - - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::debug!("{:#?}", e); - }; - tracing::info!("Successfully Connected into database"); - }); + let client = if let Some(connector) = connector { + let (client, connection) = config.connect(connector).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::debug!("{:#?}", e); + }; + tracing::info!("Successfully Connected into database"); + }); + client + } else { + let (client, connection) = config.connect(NoTls).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::debug!("{:#?}", e); + }; + tracing::info!("Successfully Connected into database"); + }); + client + }; Ok(PostgresSimpleClient(client)) } diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 080c07cc91..a827fa5079 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -223,7 +223,8 @@ async fn list_scripts( "draft_only", "ws_error_handler_muted", "no_main_func", - "codebase IS NOT NULL as use_codebase" + "codebase IS NOT NULL as use_codebase", + "kind" ]) .left() .join("favorite") @@ -298,7 +299,9 @@ async fn list_scripts( if let Some(it) = &lq.is_template { sqlb.and_where_eq("is_template", it); } - if let Some(lowercased_kinds) = lowercased_kinds { + if authed.is_operator { + sqlb.and_where_eq("kind", quote("script")); + } else if let Some(lowercased_kinds) = lowercased_kinds { let safe_kinds = lowercased_kinds .into_iter() .map(sql_builder::quote) @@ -407,10 +410,7 @@ async fn create_snapshot_script( uploaded = true; #[cfg(all(feature = "enterprise", feature = "parquet"))] - let object_store = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone(); + let object_store = windmill_common::s3_helpers::get_object_store().await; #[cfg(not(all(feature = "enterprise", feature = "parquet")))] let object_store: Option<()> = None; @@ -683,36 +683,40 @@ async fn create_script_internal<'c>( let validate_schema = should_validate_schema(&ns.content, &ns.language); - let (no_main_func, has_preprocessor) = match lang { - ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { - let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None); - match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), - Err(e) => { - tracing::warn!( - "Error parsing deno signature when deploying script {}: {:?}", - ns.path, - e - ); - (None, None) + let (no_main_func, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) { + (ns.no_main_func, ns.has_preprocessor) + } else { + match lang { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None); + match args { + Ok(args) => (args.no_main_func, args.has_preprocessor), + Err(e) => { + tracing::warn!( + "Error parsing deno signature when deploying script {}: {:?}", + ns.path, + e + ); + (None, None) + } } } - } - ScriptLang::Python3 => { - let args = windmill_parser_py::parse_python_signature(&ns.content, None, true); - match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), - Err(e) => { - tracing::warn!( - "Error parsing python signature when deploying script {}: {:?}", - ns.path, - e - ); - (None, None) + ScriptLang::Python3 => { + let args = windmill_parser_py::parse_python_signature(&ns.content, None, true); + match args { + Ok(args) => (args.no_main_func, args.has_preprocessor), + Err(e) => { + tracing::warn!( + "Error parsing python signature when deploying script {}: {:?}", + ns.path, + e + ); + (None, None) + } } } + _ => (ns.no_main_func, ns.has_preprocessor), } - _ => (ns.no_main_func, ns.has_preprocessor), }; sqlx::query!( @@ -1320,10 +1324,12 @@ async fn raw_script_by_path_internal( w_id ) .fetch_one(&db) - .await?; - if exists.unwrap_or(false) { + .await? + .unwrap_or(false); + + if exists { return Err(Error::NotFound(format!( - "Script {path} not visible to {} but exists", + "Script {path} exists but {} does not have permissions to access it", authed.username ))); } diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index b11646fbe0..0b3ca56af7 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -98,10 +98,7 @@ async fn get_log_file( require_devops_role(&db, &email).await?; let path = path.to_path(); #[cfg(feature = "parquet")] - let s3_client = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone(); + let s3_client = windmill_common::s3_helpers::get_object_store().await; #[cfg(feature = "parquet")] if let Some(s3_client) = s3_client { let path = format!("{}{}", windmill_common::tracing_init::LOGS_SERVICE, path); diff --git a/backend/windmill-api/src/settings.rs b/backend/windmill-api/src/settings.rs index a270074cc0..b589713b91 100644 --- a/backend/windmill-api/src/settings.rs +++ b/backend/windmill-api/src/settings.rs @@ -120,12 +120,15 @@ use windmill_common::s3_helpers::build_object_store_from_settings; #[cfg(feature = "parquet")] pub async fn test_s3_bucket( _authed: ApiAuthed, + Extension(db): Extension, Json(test_s3_bucket): Json, ) -> error::Result { use bytes::Bytes; use futures::StreamExt; - let client = build_object_store_from_settings(test_s3_bucket).await?; + let client = build_object_store_from_settings(test_s3_bucket, Some(&db)) + .await? + .store; let mut list = client.list(Some(&object_store::path::Path::from("".to_string()))); let first_file = list.next().await; diff --git a/backend/windmill-api/src/trigger_helpers.rs b/backend/windmill-api/src/trigger_helpers.rs index 5d7112e0af..917fe48bfa 100644 --- a/backend/windmill-api/src/trigger_helpers.rs +++ b/backend/windmill-api/src/trigger_helpers.rs @@ -1,4 +1,3 @@ -use quick_cache::sync::Cache; use serde::Deserialize; use serde_json::value::RawValue; use std::collections::HashMap; @@ -6,31 +5,19 @@ use windmill_common::{ error::Result, flows::FlowModuleValue, get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, - scripts::{ScriptHash, ScriptLang}, + jobs::get_has_preprocessor_from_content_and_lang, + scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, + triggers::{ + HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind, + RUNNABLE_FORMAT_VERSION_CACHE, + }, + utils::StripPath, worker::to_raw_value, FlowVersionInfo, }; -use windmill_queue::{PushArgsOwned, TriggerKind}; +use windmill_queue::PushArgsOwned; -use crate::db::DB; - -type RunnableFormatCacheKey = (String, i64, TriggerKind); - -lazy_static::lazy_static! { - pub static ref RUNNABLE_FORMAT_VERSION_CACHE: Cache = Cache::new(1000); -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)] -pub struct RunnableFormat { - pub version: RunnableFormatVersion, - pub has_preprocessor: bool, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)] -pub enum RunnableFormatVersion { - V1, - V2, -} +use crate::{db::DB, HTTP_CLIENT}; struct ScriptInfo { has_preprocessor: Option, @@ -53,6 +40,7 @@ struct PartialSchema { pub enum RunnableId { FlowPath(String), ScriptId(ScriptId), + HubScript(String), } impl RunnableId { @@ -61,7 +49,11 @@ impl RunnableId { } pub fn from_script_path(path: &str) -> Self { - Self::ScriptId(ScriptId::ScriptPath(path.to_string())) + if path.starts_with("hub/") { + Self::HubScript(path.to_string()) + } else { + Self::ScriptId(ScriptId::ScriptPath(path.to_string())) + } } pub fn from_flow_path(path: &str) -> Self { @@ -156,6 +148,33 @@ struct FlowInfo { schema: Option>, } +fn get_preprocessor_args_from_content_and_language( + content: &str, + language: &ScriptLang, +) -> Result>> { + let args = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + let args = windmill_parser_ts::parse_deno_signature( + &content, + true, + false, + Some("preprocessor".to_string()), + )?; + Some(args.args) + } + ScriptLang::Python3 => { + let args = windmill_parser_py::parse_python_signature( + &content, + Some("preprocessor".to_string()), + false, + )?; + Some(args.args) + } + _ => None, + }; + Ok(args) +} + pub async fn get_runnable_format( runnable_id: RunnableId, workspace_id: &str, @@ -163,11 +182,65 @@ pub async fn get_runnable_format( trigger_kind: &TriggerKind, ) -> Result { let (key, preprocessor_info) = match runnable_id { + RunnableId::HubScript(path) => { + let Some(version) = path.split("/").nth(1) else { + return Err(windmill_common::error::Error::internal_err( + "Invalid hub script path".to_string(), + )); + }; + + let version = match version.parse::() { + Ok(version) => version, + Err(_) => { + return Err(windmill_common::error::Error::internal_err( + "Invalid hub script version".to_string(), + )); + } + }; + + let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone()); + + let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); + + if let Some(runnable_format) = runnable_format { + tracing::debug!("Using cached runnable format for hub script {path}"); + return Ok(runnable_format); + } + + let hub_script = + get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db)) + .await?; + + let has_preprocessor = get_has_preprocessor_from_content_and_lang( + &hub_script.content, + &hub_script.language, + )?; + + let partial_schema = serde_json::from_str(hub_script.schema.get())?; + + ( + key, + if has_preprocessor { + PreprocessorInfo::Preprocessor { + content: hub_script.content, + language: hub_script.language, + } + } else { + PreprocessorInfo::NoPreprocessor { + schema: Some(sqlx::types::Json(partial_schema)), + } + }, + ) + } RunnableId::FlowPath(path) => { let FlowVersionInfo { version, .. } = get_latest_flow_version_info_for_path(db, workspace_id, &path, true).await?; - let key = (workspace_id.to_string(), version, trigger_kind.clone()); + let key = ( + HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), + version, + trigger_kind.clone(), + ); let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); @@ -181,11 +254,14 @@ pub async fn get_runnable_format( "SELECT value->'preprocessor_module'->'value' as \"preprocessor_module: _\", schema as \"schema: _\" - FROM flow - WHERE workspace_id = $1 - AND path = $2", + FROM flow_version + WHERE + path = $1 + AND workspace_id = $2 + ORDER BY created_at DESC + LIMIT 1", + path, workspace_id, - path ) .fetch_one(db) .await?; @@ -227,7 +303,11 @@ pub async fn get_runnable_format( } RunnableId::ScriptId(script_id) => { let hash = script_id.get_script_hash(workspace_id, db).await?; - let key = (workspace_id.to_string(), hash, trigger_kind.clone()); + let key = ( + HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), + hash, + trigger_kind.clone(), + ); let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); if let Some(runnable_format) = runnable_format { @@ -256,30 +336,7 @@ pub async fn get_runnable_format( let runnable_format = match preprocessor_info { PreprocessorInfo::Preprocessor { content, language } => { - let args = match language { - ScriptLang::Bun - | ScriptLang::Bunnative - | ScriptLang::Deno - | ScriptLang::Nativets => { - let args = windmill_parser_ts::parse_deno_signature( - &content, - true, - false, - Some("preprocessor".to_string()), - )?; - Some(args.args) - } - ScriptLang::Python3 => { - let args = windmill_parser_py::parse_python_signature( - &content, - Some("preprocessor".to_string()), - false, - )?; - Some(args.args) - } - _ => None, - }; - + let args = get_preprocessor_args_from_content_and_language(&content, &language)?; runnable_format_from_preprocessor_args(args) } PreprocessorInfo::NoPreprocessor { schema } => { diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index b59d5d4d7f..5eef389138 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -5,6 +5,9 @@ * Please see the included NOTICE for copyright information and * 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}; @@ -35,6 +38,16 @@ pub enum RunnableKind { 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?; diff --git a/backend/windmill-api/src/websocket_triggers.rs b/backend/windmill-api/src/websocket_triggers.rs index f8eda49f83..8bd165844c 100644 --- a/backend/windmill-api/src/websocket_triggers.rs +++ b/backend/windmill-api/src/websocket_triggers.rs @@ -24,6 +24,7 @@ use windmill_audit::{audit_ee::audit_log, ActionKind}; use windmill_common::{ db::UserDB, error::{self, to_anyhow, JsonResult}, + triggers::TriggerKind, utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath}, worker::{to_raw_value, CLOUD_HOSTED}, INSTANCE_NAME, @@ -31,8 +32,6 @@ use windmill_common::{ use windmill_git_sync::handle_deployment_metadata; use windmill_queue::PushArgsOwned; -use windmill_queue::TriggerKind; - use crate::{ capture::{insert_capture_payload, WebsocketTriggerConfig}, db::{ApiAuthed, DB}, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index d3f7ed4c00..17764b1253 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -12,14 +12,14 @@ tantivy = [] prometheus = ["dep:prometheus"] loki = ["dep:tracing-loki"] benchmark = [] -parquet = ["dep:object_store", "dep:aws-config", "dep:aws-sdk-sts", "dep:datafusion"] +parquet = ["dep:object_store", "dep:aws-config", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"] aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"] otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:opentelemetry", "dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic"] smtp = ["dep:mail-send"] scoped_cache = [] cloud = [] - +openidconnect = ["dep:openidconnect"] [lib] name = "windmill_common" path = "src/lib.rs" @@ -62,6 +62,7 @@ object_store = { workspace = true, optional = true } prometheus = { workspace = true, optional = true } aws-config = { workspace = true, optional = true } aws-sdk-sts = { workspace = true, optional = true } +aws-smithy-types-convert = { workspace = true, optional = true } indexmap.workspace = true bytes.workspace = true mail-send = { workspace = true, optional = true } @@ -71,8 +72,13 @@ const_format.workspace = true crc.workspace = true windmill-macros.workspace = true windmill-parser-sql.workspace = true +windmill-parser-ts.workspace = true +windmill-parser-py.workspace = true jsonwebtoken.workspace = true backon.workspace = true +openidconnect = { workspace = true, optional = true } +strum.workspace = true +strum_macros.workspace = true semver.workspace = true croner = "2.0.6" diff --git a/backend/windmill-common/src/client.rs b/backend/windmill-common/src/client.rs new file mode 100644 index 0000000000..95d9f64bd2 --- /dev/null +++ b/backend/windmill-common/src/client.rs @@ -0,0 +1,200 @@ +use anyhow::Context; +use reqwest::{Body, Response}; +use serde::de::DeserializeOwned; + +use crate::utils::HTTP_CLIENT; + +#[derive(Clone)] +pub struct AuthedClient { + pub base_internal_url: String, + pub workspace: String, + pub token: String, + pub force_client: Option, +} + +impl AuthedClient { + pub async fn get(&self, url: &str, query: Vec<(&str, String)>) -> anyhow::Result { + self.force_client + .as_ref() + .unwrap_or(&HTTP_CLIENT) + .get(url) + .query(&query) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .header( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token))?, + ) + .send() + .await + .map_err(|e| { + tracing::error!("Error executing get request from authed http client to {url} with query {query:?}: {e}"); + anyhow::anyhow!("Error executing get request from authed http client to {url} with query {query:?}: {e}") + }) + } + + pub async fn get_id_token(&self, audience: &str) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/oidc/token/{}", + self.base_internal_url, self.workspace, audience + ); + let response = self.get(&url, vec![]).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding oidc token as json string")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn get_resource_value(&self, path: &str) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/resources/get_value/{}", + self.base_internal_url, self.workspace, path + ); + let response = self.get(&url, vec![]).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding resource value as json")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn get_variable_value(&self, path: &str) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/variables/get_value/{}", + self.base_internal_url, self.workspace, path + ); + let response = self.get(&url, vec![]).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding variable value as json")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn get_resource_value_interpolated( + &self, + path: &str, + job_id: Option, + ) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/resources/get_value_interpolated/{}", + self.base_internal_url, self.workspace, path + ); + let mut query = Vec::with_capacity(1usize); + if let Some(v) = &job_id { + query.push(("job_id", v.to_string())); + } + let response = self.get(&url, query).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding interpolated resource value as json")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn get_completed_job_result( + &self, + path: &str, + json_path: Option, + ) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/jobs_u/completed/get_result/{}", + self.base_internal_url, self.workspace, path + ); + let query = if let Some(json_path) = json_path { + vec![("json_path", json_path)] + } else { + vec![] + }; + let response = self.get(&url, query).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding completed job result as json")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn get_result_by_id( + &self, + flow_job_id: &str, + node_id: &str, + json_path: Option, + ) -> anyhow::Result { + let url = format!( + "{}/api/w/{}/jobs/result_by_id/{}/{}", + self.base_internal_url, self.workspace, flow_job_id, node_id + ); + let query = if let Some(json_path) = json_path { + vec![("json_path", json_path)] + } else { + vec![] + }; + let response = self.get(&url, query).await?; + match response.status().as_u16() { + 200u16 => Ok(response + .json::() + .await + .context("decoding result by id as json")?), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), + } + } + + pub async fn upload_s3_file( + &self, + workspace_id: &str, + object_key: String, + storage: Option, + body: S, + ) -> anyhow::Result<()> + where + S: futures::stream::TryStream + Send + 'static, + S::Error: Into>, + bytes::Bytes: From, + { + let mut query = vec![("file_key", object_key)]; + if let Some(storage) = storage { + query.push(("storage", storage)); + } + let response = self + .force_client + .as_ref() + .unwrap_or(&HTTP_CLIENT) + .post(format!( + "{}/api/w/{}/job_helpers/upload_s3_file", + self.base_internal_url, workspace_id + )) + .query(&query) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .header( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token)) + .map_err(|e| anyhow::anyhow!(e.to_string()))?, + ) + .body(Body::wrap_stream(body)) + .send() + .await + .context(format!("Sent upload_s3_file request",)) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + match response.status().as_u16() { + 200u16 => Ok(()), + _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default()))?, + } + } +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 61184b627c..895384e855 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -30,7 +30,7 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics"; pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics"; pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; -pub const OBJECT_STORE_CACHE_CONFIG_SETTING: &str = "object_store_cache_config"; +pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; pub const HUB_BASE_URL_SETTING: &str = "hub_base_url"; diff --git a/backend/windmill-common/src/job_s3_helpers_ee.rs b/backend/windmill-common/src/job_s3_helpers_ee.rs index b00c00a583..d5d6ac4052 100644 --- a/backend/windmill-common/src/job_s3_helpers_ee.rs +++ b/backend/windmill-common/src/job_s3_helpers_ee.rs @@ -1,18 +1,34 @@ -use std::future::Future; +use crate::s3_helpers::{ObjectStoreResource, StorageResourceType}; -use crate::{ - error::Error, - s3_helpers::{ObjectStoreResource, StorageResourceType}, -}; - -pub async fn get_s3_resource_internal<'c, F, Fut>( +pub async fn get_s3_resource_internal<'c>( _resource_type: StorageResourceType, _s3_resource_value_raw: serde_json::Value, - _gen_token: F, -) -> crate::error::Result -where - F: FnOnce(String) -> Fut, - Fut: Future> + Send + 'static, -{ + _gen_token: TokenGenerator<'c>, + _db: &crate::DB, +) -> crate::error::Result { + todo!() +} + +pub enum TokenGenerator<'c> { + AsClient(&'c crate::client::AuthedClient), + AsServerInstance(), +} + +impl<'c> TokenGenerator<'c> { + pub async fn gen_token( + &self, + _audience: &str, + _db: Option<&crate::DB>, + ) -> anyhow::Result { + todo!() + } +} + +#[cfg(feature = "parquet")] +pub(crate) async fn generate_s3_aws_oidc_resource<'c>( + _clone: crate::s3_helpers::S3AwsOidcResource, + _token_generator: TokenGenerator<'c>, + _init_private_key: Option<&sqlx::Pool>, +) -> crate::error::Result { todo!() } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 83752b125c..1e28d373be 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -18,8 +18,9 @@ use crate::{ flow_status::{FlowStatus, RestartedFrom}, flows::{FlowNodeId, FlowValue, Retry}, get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, - scripts::{ScriptHash, ScriptLang}, + scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, users::username_to_permissioned_as, + utils::{StripPath, HTTP_CLIENT}, worker::{to_raw_value, TMP_DIR}, FlowVersionInfo, ScriptHashInfo, }; @@ -270,6 +271,7 @@ impl CompletedJob { pub enum JobPayload { ScriptHub { path: String, + apply_preprocessor: bool, }, ScriptHash { hash: ScriptHash, @@ -387,6 +389,25 @@ pub struct OnBehalfOf { pub permissioned_as: String, } +pub fn get_has_preprocessor_from_content_and_lang( + content: &str, + language: &ScriptLang, +) -> error::Result { + let has_preprocessor = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + let args = windmill_parser_ts::parse_deno_signature(&content, true, true, None)?; + args.has_preprocessor.unwrap_or(false) + } + ScriptLang::Python3 => { + let args = windmill_parser_py::parse_python_signature(&content, None, true)?; + args.has_preprocessor.unwrap_or(false) + } + _ => false, + }; + + Ok(has_preprocessor) +} + pub async fn script_path_to_payload<'e, A: sqlx::Acquire<'e, Database = Postgres> + Send>( script_path: &str, db: A, @@ -399,63 +420,74 @@ pub async fn script_path_to_payload<'e, A: sqlx::Acquire<'e, Database = Postgres Option, Option, )> { - let (job_payload, tag, delete_after_use, script_timeout, on_behalf_of) = - if script_path.starts_with("hub/") { - ( - JobPayload::ScriptHub { path: script_path.to_owned() }, - None, - None, - None, - None, - ) + let (job_payload, tag, delete_after_use, script_timeout, on_behalf_of) = if script_path + .starts_with("hub/") + { + let hub_script = + get_full_hub_script_by_path(StripPath(script_path.to_string()), &HTTP_CLIENT, None) + .await?; + + let has_preprocessor = + get_has_preprocessor_from_content_and_lang(&hub_script.content, &hub_script.language)?; + + ( + JobPayload::ScriptHub { + path: script_path.to_owned(), + apply_preprocessor: has_preprocessor && !skip_preprocessor.unwrap_or(false), + }, + None, + None, + None, + None, + ) + } else { + let ScriptHashInfo { + hash, + tag, + concurrency_key, + concurrent_limit, + concurrency_time_window_s, + cache_ttl, + language, + dedicated_worker, + priority, + delete_after_use, + timeout, + has_preprocessor, + on_behalf_of_email, + created_by, + .. + } = get_latest_deployed_hash_for_path(db, w_id, script_path).await?; + + let on_behalf_of = if let Some(email) = on_behalf_of_email { + Some(OnBehalfOf { + email, + permissioned_as: username_to_permissioned_as(created_by.as_str()), + }) } else { - let ScriptHashInfo { - hash, - tag, - concurrency_key, + None + }; + + ( + JobPayload::ScriptHash { + hash: ScriptHash(hash), + path: script_path.to_owned(), + custom_concurrency_key: concurrency_key, concurrent_limit, concurrency_time_window_s, - cache_ttl, + cache_ttl: cache_ttl, language, dedicated_worker, priority, - delete_after_use, - timeout, - has_preprocessor, - on_behalf_of_email, - created_by, - .. - } = get_latest_deployed_hash_for_path(db, w_id, script_path).await?; - - let on_behalf_of = if let Some(email) = on_behalf_of_email { - Some(OnBehalfOf { - email, - permissioned_as: username_to_permissioned_as(created_by.as_str()), - }) - } else { - None - }; - - ( - JobPayload::ScriptHash { - hash: ScriptHash(hash), - path: script_path.to_owned(), - custom_concurrency_key: concurrency_key, - concurrent_limit, - concurrency_time_window_s, - cache_ttl: cache_ttl, - language, - dedicated_worker, - priority, - apply_preprocessor: !skip_preprocessor.unwrap_or(false) - && has_preprocessor.unwrap_or(false), - }, - tag, - delete_after_use, - timeout, - on_behalf_of, - ) - }; + apply_preprocessor: !skip_preprocessor.unwrap_or(false) + && has_preprocessor.unwrap_or(false), + }, + tag, + delete_after_use, + timeout, + on_behalf_of, + ) + }; Ok(( job_payload, tag, @@ -576,11 +608,11 @@ pub async fn get_logs_from_store( logs: &str, log_file_index: &Option>, ) -> Option>> { - use crate::s3_helpers::OBJECT_STORE_CACHE_SETTINGS; + use crate::s3_helpers::get_object_store; if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { - if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() { + if let Some(os) = get_object_store().await { let logs = logs.to_string(); let stream = async_stream::stream! { for file_p in file_index.clone() { diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index e1c2f4aa15..6d92cd8f55 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -30,6 +30,7 @@ pub mod auth; #[cfg(feature = "benchmark")] pub mod bench; pub mod cache; +pub mod client; pub mod db; pub mod ee; pub mod email_ee; @@ -43,6 +44,9 @@ pub mod job_metrics; #[cfg(feature = "parquet")] pub mod job_s3_helpers_ee; +#[cfg(all(feature = "enterprise", feature = "openidconnect"))] +pub mod oidc_ee; + pub mod jobs; pub mod jwt; pub mod more_serde; @@ -62,6 +66,7 @@ pub mod utils; pub mod variables; pub mod worker; pub mod workspaces; +pub mod triggers; pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5; diff --git a/backend/windmill-common/src/oidc_ee.rs b/backend/windmill-common/src/oidc_ee.rs new file mode 100644 index 0000000000..e7a157b04d --- /dev/null +++ b/backend/windmill-common/src/oidc_ee.rs @@ -0,0 +1,198 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2023 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +#[cfg(all(feature = "enterprise", feature = "openidconnect"))] +use { + crate::db::DB, + crate::{auth::IdToken as WindmillIdToken, error::Result}, + anyhow, + openidconnect::{ + core::{CoreJwsSigningAlgorithm, CoreRsaPrivateSigningKey}, + IssuerUrl, JsonWebKeyId, + }, + std::process::Command, +}; + +#[cfg(feature = "openidconnect")] +use openidconnect::AdditionalClaims; + +#[cfg(feature = "openidconnect")] +impl AdditionalClaims for JobClaim {} + +#[cfg(feature = "openidconnect")] +impl AdditionalClaims for WorkspaceClaim {} + +#[cfg(feature = "openidconnect")] +impl AdditionalClaims for InstanceClaim {} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct WorkspaceClaim { + pub workspace: String, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct InstanceClaim {} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct JobClaim { + pub job_id: String, + pub path: Option, + pub flow_path: Option, + pub groups: Vec, + pub username: String, + pub email: String, + pub workspace: String, +} + +lazy_static::lazy_static! { + static ref PRIVATE_KEY: RwLock> = RwLock::new(None); +} + +pub async fn generate_id_token( + db: Option<&DB>, + claim: T, + audience: &str, + identifier: String, + email: Option, +) -> Result { + use chrono::{Duration, Utc}; + use openidconnect::{ + core::{CoreGenderClaim, CoreJweContentEncryptionAlgorithm}, + Audience, EndUserEmail, IdToken, IdTokenClaims, StandardClaims, SubjectIdentifier, + }; + + let private_key = get_private_key(db).await?; + + let issue_url = format!("{}/api/oidc/", crate::BASE_URL.read().await.clone()); + let issue_time = Utc::now(); + let expiration = issue_time + Duration::try_hours(48).unwrap(); + let id_token = IdToken::< + T, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >::new( + IdTokenClaims::::new( + // Specify the issuer URL for the OpenID Connect Provider. + IssuerUrl::new(issue_url) + .map_err(|e| anyhow::anyhow!("Failed to generate IssueUrl: {}", e))?, + // The audience is usually a single entry with the client ID of the client for whom + // the ID token is intended. This is a required claim. + vec![Audience::new(audience.to_string())], + // The ID token expiration is usually much shorter than that of the access or refresh + // tokens issued to clients. + expiration, + // The issue time is usually the current time. + issue_time, + // Set the standard claims defined by the OpenID Connect Core spec. + StandardClaims::new( + // Stable subject identifiers are recommended in place of e-mail addresses or other + // potentially unstable identifiers. This is the only required claim. + SubjectIdentifier::new(identifier), + ) + // Optional: specify the user's e-mail address. This should only be provided if the + // client has been granted the 'profile' or 'email' scopes. + .set_email(email.map(|x| EndUserEmail::new(x))) + // Optional: specify whether the provider has verified the user's e-mail address. + .set_email_verified(Some(true)), + // OpenID Connect Providers may supply custom claims by providing a struct that + // implements the AdditionalClaims trait. This requires manually using the + // generic IdTokenClaims struct rather than the CoreIdTokenClaims type alias, + // however. + claim, + ), + // The private key used for signing the ID token. For confidential clients (those able + // to maintain a client secret), a CoreHmacKey can also be used, in conjunction + // with one of the CoreJwsSigningAlgorithm::HmacSha* signing algorithms. When using an + // HMAC-based signing algorithm, the UTF-8 representation of the client secret should + // be used as the HMAC key. + &CoreRsaPrivateSigningKey::from_pem( + &private_key, + Some(JsonWebKeyId::new("windmill".to_string())), + ) + .map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?, + // Uses the RS256 signature algorithm. This crate supports any RS*, PS*, or HS* + // signature algorithm. + CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256, + // When returning the ID token alongside an access token (e.g., in the Authorization Code + // flow), it is recommended to pass the access token here to set the `at_hash` claim + // automatically. + None, + // When returning the ID token alongside an authorization code (e.g., in the implicit + // flow), it is recommended to pass the authorization code here to set the `c_hash` claim + // automatically. + None, + ) + .map_err(|e| anyhow::anyhow!("Failed to generate token: {}", e))?; + + Ok(WindmillIdToken::new(id_token.to_string(), expiration)) +} + +#[cfg(all(feature = "enterprise", feature = "openidconnect"))] +pub async fn get_private_key(db: Option<&DB>) -> anyhow::Result { + if let Some(key) = PRIVATE_KEY.read().await.clone() { + return Ok(key); + } else if let Some(db) = db { + let key = sqlx::query_scalar!( + "SELECT value->>'private_key' FROM global_settings WHERE name = 'rsa_keys'", + ) + .fetch_optional(db) + .await? + .flatten(); + + let key = key.filter(|s| !s.is_empty()); + + if let Some(key) = key { + return Ok(key); + } else { + let keys = gen_pems(db).await?; + return Ok(keys.private_key); + } + } else { + return Err(anyhow::anyhow!("Private key not found and no db provided")); + } +} + +#[cfg(all(feature = "enterprise", feature = "openidconnect"))] +#[derive(Debug, Clone, serde::Serialize)] +struct Keys { + private_key: String, +} + +#[cfg(all(feature = "enterprise", feature = "openidconnect"))] +async fn gen_pems(db: &DB) -> anyhow::Result { + use anyhow::anyhow; + + let private_key_cmd = Command::new("openssl") + .arg("genrsa") + .arg("--traditional") + .arg("2048") + .output() + .expect("failed to execute process"); + + let private_key = String::from_utf8(private_key_cmd.stdout)?; + + tracing::debug!("Generated private key: {}", private_key); + + if private_key.is_empty() { + return Err(anyhow!("Failed to generate RSA key: key is empty")); + } + + let keys = Keys { private_key }; + + sqlx::query!( + r#"INSERT INTO global_settings (name, value) VALUES ('rsa_keys', $1)"#, + serde_json::to_value(&keys).unwrap() + ) + .execute(db) + .await?; + + Ok(keys) +} diff --git a/backend/windmill-common/src/s3_helpers.rs b/backend/windmill-common/src/s3_helpers.rs index 041ca1a5f8..29f59cf07c 100644 --- a/backend/windmill-common/src/s3_helpers.rs +++ b/backend/windmill-common/src/s3_helpers.rs @@ -4,6 +4,7 @@ use crate::error; use aws_sdk_sts::config::ProvideCredentials; #[cfg(feature = "parquet")] use axum::async_trait; +use chrono::{DateTime, Utc}; #[cfg(feature = "parquet")] use object_store::aws::AwsCredential; #[cfg(feature = "parquet")] @@ -17,6 +18,7 @@ use reqwest::header::HeaderMap; use serde::{Deserialize, Serialize}; #[cfg(feature = "parquet")] use std::sync::{Arc, Mutex}; + #[cfg(feature = "parquet")] use tokio::sync::RwLock; @@ -46,9 +48,170 @@ use tokio::task; use windmill_parser_sql::S3ModeFormat; #[cfg(feature = "parquet")] -lazy_static::lazy_static! { +#[derive(Clone)] +pub struct ExpirableObjectStore { + pub store: Arc, + pub refresh: Option, +} - pub static ref OBJECT_STORE_CACHE_SETTINGS: Arc>>> = Arc::new(RwLock::new(None)); +#[cfg(feature = "parquet")] +#[derive(Clone)] +pub struct ObjectStoreRefresh { + refresh: Option>, + settings: ObjectSettings, +} + +#[cfg(feature = "parquet")] +impl ObjectStoreRefresh { + pub fn new(settings: ObjectSettings, refresh: Option>) -> Self { + Self { settings, refresh } + } + fn refresh_needed(&self) -> bool { + if let Some(refresh) = self.refresh { + if refresh < Utc::now() - chrono::Duration::minutes(1) { + return true; + } + } + return false; + } + + async fn refresh(&self) -> Option { + return build_object_store_from_settings(self.settings.clone(), None) + .await + .map_err(|e| { + tracing::error!("Error building s3 client from settings: {:?}", e); + e + }) + .ok(); + } +} + +#[cfg(feature = "parquet")] +impl From> for ExpirableObjectStore { + fn from(store: Arc) -> Self { + Self { store, refresh: None } + } +} + +// #[cfg(feature = "parquet")] + +// impl ExpirableObjectStore { +// pub fn new(store: Arc, expiration: Option>) -> Self { +// Self { store, expiration } +// } +// } + +#[cfg(feature = "parquet")] +lazy_static::lazy_static! { + pub static ref OBJECT_STORE_SETTINGS: Arc>> = Arc::new(RwLock::new(None)); +} + +#[cfg(feature = "parquet")] +pub async fn get_object_store() -> Option> { + let settings = OBJECT_STORE_SETTINGS.read().await; + if let Some(s) = settings.as_ref() { + match &s.refresh { + Some(refresh) => { + if refresh.refresh_needed() { + let refresh = refresh.clone(); + drop(settings); + let new_store = refresh.refresh().await; + if let Some(new_store) = new_store { + let mut s3_cache_settings = OBJECT_STORE_SETTINGS.write().await; + let arc = new_store.store.clone(); + *s3_cache_settings = Some(new_store); + return Some(arc); + } else { + return None; + } + } else { + return Some(s.store.clone()); + } + } + None => { + return Some(s.store.clone()); + } + } + } else { + return None; + } +} + +#[cfg(feature = "parquet")] +pub enum ObjectStoreReload { + //if the jwks endpoints are not up yet, we should retry later soon + Later, + Never, +} + +#[cfg(feature = "parquet")] +pub async fn reload_object_store_setting(db: &crate::DB) -> ObjectStoreReload { + use crate::{ + ee::{get_license_plan, LicensePlan}, + global_settings::{load_value_from_global_settings, OBJECT_STORE_CONFIG_SETTING}, + s3_helpers::ObjectSettings, + }; + + let s3_config = load_value_from_global_settings(db, OBJECT_STORE_CONFIG_SETTING).await; + if let Err(e) = s3_config { + tracing::error!("Error reloading s3 cache config: {:?}", e) + } else { + if let Some(v) = s3_config.unwrap() { + if matches!(get_license_plan().await, LicensePlan::Pro) { + tracing::error!("S3 cache is not available for pro plan"); + return ObjectStoreReload::Never; + } + let setting = serde_json::from_value::(v); + match setting { + Ok(setting) => { + let is_oidc = matches!(setting, ObjectSettings::AwsOidc(_)); + let s3_client = build_object_store_from_settings(setting, Some(db)).await; + match s3_client { + Ok(s3_client) => { + let mut s3_cache_settings = OBJECT_STORE_SETTINGS.write().await; + *s3_cache_settings = Some(s3_client); + } + Err(e) => { + if is_oidc { + tracing::error!("Error building s3 client from oidc settings. It may be due to the jwks endpoints not being up yet, it will be attempted again in 10s to leave time for the server to be ready: {:?}", e); + return ObjectStoreReload::Later; + } else { + tracing::error!("Error building s3 client from settings: {:?}", e); + } + } + } + } + Err(e) => { + tracing::error!("Error parsing s3 cache config: {:?}", e) + } + } + } else { + let mut s3_cache_settings = OBJECT_STORE_SETTINGS.write().await; + if std::env::var("S3_CACHE_BUCKET").is_ok() { + if matches!(get_license_plan().await, LicensePlan::Pro) { + tracing::error!("S3 cache is not available for pro plan"); + return ObjectStoreReload::Never; + } + *s3_cache_settings = build_s3_client_from_settings(S3Settings { + bucket: None, + region: None, + access_key: None, + secret_key: None, + endpoint: None, + store_logs: None, + path_style: None, + allow_http: None, + port: None, + }) + .await + .ok() + .map(|x| ExpirableObjectStore::from(x)) + } else { + *s3_cache_settings = None; + } + } + } + return ObjectStoreReload::Never; } #[derive(Serialize, Deserialize, Debug)] @@ -81,6 +244,15 @@ pub enum ObjectStoreResource { Azure(AzureBlobResource), } +impl ObjectStoreResource { + pub fn expiration(&self) -> Option> { + match self { + ObjectStoreResource::S3(s3_resource) => s3_resource.expiration, + _ => None, + } + } +} + #[derive(Deserialize, Debug)] pub enum StorageResourceType { S3, @@ -104,6 +276,8 @@ pub struct S3Resource { #[serde(rename = "pathStyle")] pub path_style: Option, pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration: Option>, pub port: Option, } @@ -126,7 +300,7 @@ pub struct AzureBlobResource { pub federated_token_file: Option, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Hash)] pub struct S3AwsOidcResource { #[serde(rename = "bucket")] pub bucket: String, @@ -412,16 +586,15 @@ pub enum ObjectStoreSettings { pub enum ObjectSettings { S3(S3Settings), Azure(AzureBlobResource), + AwsOidc(S3AwsOidcResource), } impl ObjectSettings { - pub fn get_bucket(&self) -> &str { + pub fn get_bucket(&self) -> Option<&String> { match self { - ObjectSettings::S3(s3_settings) => s3_settings - .bucket - .as_ref() - .unwrap_or_else(|| "missingbucket".to_string()), - ObjectSettings::Azure(azure_settings) => &azure_settings.container_name, + ObjectSettings::S3(s3_settings) => s3_settings.bucket.as_ref(), + ObjectSettings::Azure(azure_settings) => Some(&azure_settings.container_name), + ObjectSettings::AwsOidc(s3_aws_oidc_settings) => Some(&s3_aws_oidc_settings.bucket), } } } @@ -429,12 +602,31 @@ impl ObjectSettings { #[cfg(feature = "parquet")] pub async fn build_object_store_from_settings( settings: ObjectSettings, -) -> error::Result> { + init_private_key: Option<&crate::DB>, +) -> error::Result { match settings { - ObjectSettings::S3(s3_settings) => build_s3_client_from_settings(s3_settings).await, + ObjectSettings::S3(s3_settings) => build_s3_client_from_settings(s3_settings) + .await + .map(|x| ExpirableObjectStore::from(x)), ObjectSettings::Azure(azure_settings) => { let azure_blob_resource = azure_settings; - build_azure_blob_client(&azure_blob_resource) + build_azure_blob_client(&azure_blob_resource).map(|x| ExpirableObjectStore::from(x)) + } + ObjectSettings::AwsOidc(ref s3_aws_oidc_settings) => { + let token_generator = crate::job_s3_helpers_ee::TokenGenerator::AsServerInstance(); + let res = crate::job_s3_helpers_ee::generate_s3_aws_oidc_resource( + s3_aws_oidc_settings.clone(), + token_generator, + init_private_key, + ) + .await?; + + build_object_store_client(&res) + .await + .map(|x| ExpirableObjectStore { + store: x, + refresh: Some(ObjectStoreRefresh::new(settings.clone(), res.expiration())), + }) } } } @@ -482,6 +674,7 @@ pub async fn build_s3_client_from_settings( path_style: settings.path_style, port: settings.port, token: None, + expiration: None, }; build_s3_client(&s3_resource).await diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 8b67284c4e..e0391767f8 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -247,6 +247,7 @@ pub struct ListableScript { #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, + pub kind: ScriptKind, } fn is_false(x: &bool) -> bool { diff --git a/backend/windmill-common/src/triggers.rs b/backend/windmill-common/src/triggers.rs new file mode 100644 index 0000000000..df68d0543c --- /dev/null +++ b/backend/windmill-common/src/triggers.rs @@ -0,0 +1,79 @@ +use quick_cache::sync::Cache; +use serde::{Deserialize, Serialize}; +use std::fmt; +use strum::EnumIter; + +#[derive(Eq, PartialEq, Hash)] +pub enum HubOrWorkspaceId { + Hub, + WorkspaceId(String), +} + +type RunnableFormatCacheKey = (HubOrWorkspaceId, i64, TriggerKind); + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)] +pub struct RunnableFormat { + pub version: RunnableFormatVersion, + pub has_preprocessor: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)] +pub enum RunnableFormatVersion { + V1, + V2, +} + +lazy_static::lazy_static! { + pub static ref RUNNABLE_FORMAT_VERSION_CACHE: Cache = Cache::new(1000); +} + +#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, EnumIter)] +#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum TriggerKind { + Webhook, + Http, + Websocket, + Kafka, + Email, + Nats, + Mqtt, + Sqs, + Postgres, + Gcp, +} + +impl TriggerKind { + pub fn to_key(&self) -> String { + match self { + TriggerKind::Webhook => "webhook".to_string(), + TriggerKind::Http => "http".to_string(), + TriggerKind::Websocket => "websocket".to_string(), + TriggerKind::Kafka => "kafka".to_string(), + TriggerKind::Email => "email".to_string(), + TriggerKind::Nats => "nats".to_string(), + TriggerKind::Mqtt => "mqtt".to_string(), + TriggerKind::Sqs => "sqs".to_string(), + TriggerKind::Postgres => "postgres".to_string(), + TriggerKind::Gcp => "gcp".to_string(), + } + } +} + +impl fmt::Display for TriggerKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + TriggerKind::Webhook => "webhook", + TriggerKind::Http => "http", + TriggerKind::Websocket => "websocket", + TriggerKind::Kafka => "kafka", + TriggerKind::Email => "email", + TriggerKind::Nats => "nats", + TriggerKind::Mqtt => "mqtt", + TriggerKind::Sqs => "sqs", + TriggerKind::Postgres => "postgres", + TriggerKind::Gcp => "gcp", + }; + write!(f, "{}", s) + } +} diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ea50393018..aa1a715758 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -516,6 +516,7 @@ fn parse_file(path: &str) -> Option { pub struct PythonAnnotations { pub no_cache: bool, pub no_postinstall: bool, + pub py_select_latest: bool, pub skip_result_postprocessing: bool, pub py310: bool, pub py311: bool, @@ -581,11 +582,7 @@ pub async fn load_cache(bin_path: &str, _remote_path: &str, is_dir: bool) -> (bo (true, format!("loaded from local cache: {}\n", bin_path)) } else { #[cfg(all(feature = "enterprise", feature = "parquet"))] - if let Some(os) = crate::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = crate::s3_helpers::get_object_store().await { let started = std::time::Instant::now(); use crate::s3_helpers::attempt_fetch_bytes; @@ -628,11 +625,7 @@ pub async fn exists_in_cache(bin_path: &str, _remote_path: &str) -> bool { return true; } else { #[cfg(all(feature = "enterprise", feature = "parquet"))] - if let Some(os) = crate::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = crate::s3_helpers::get_object_store().await { return os .get(&object_store::path::Path::from(_remote_path)) .await @@ -650,11 +643,7 @@ pub async fn save_cache( ) -> crate::error::Result { let mut _cached_to_s3 = false; #[cfg(all(feature = "enterprise", feature = "parquet"))] - if let Some(os) = crate::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = crate::s3_helpers::get_object_store().await { use object_store::path::Path; let file_to_cache = if is_dir { let tar_path = format!( diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 7a3541d345..de64071c42 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -6,7 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -use std::fmt; use std::{collections::HashMap, sync::Arc, vec}; use anyhow::Context; @@ -981,6 +980,7 @@ pub async fn add_completed_job( is_flow_step = queued_job.is_flow_step(), language = ?queued_job.script_lang, scheduled_for = ?queued_job.scheduled_for, + workspace_id = ?queued_job.workspace_id, success, "inserted completed job: {} (success: {success})", queued_job.id @@ -1944,40 +1944,6 @@ async fn handle_successful_schedule<'a, 'c, T: Serialize + Send + Sync>( Ok(()) } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash)] -#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")] -#[serde(rename_all = "lowercase")] -pub enum TriggerKind { - Webhook, - Http, - Websocket, - Kafka, - Email, - Nats, - Mqtt, - Sqs, - Postgres, - Gcp -} - - -impl TriggerKind { - pub fn to_key(&self) -> String { - match self { - TriggerKind::Webhook => "webhook".to_string(), - TriggerKind::Http => "http".to_string(), - TriggerKind::Websocket => "websocket".to_string(), - TriggerKind::Kafka => "kafka".to_string(), - TriggerKind::Email => "email".to_string(), - TriggerKind::Nats => "nats".to_string(), - TriggerKind::Mqtt => "mqtt".to_string(), - TriggerKind::Sqs => "sqs".to_string(), - TriggerKind::Postgres => "postgres".to_string(), - TriggerKind::Gcp => "gcp".to_string(), - } - } -} - #[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone)] #[sqlx(type_name = "JOB_TRIGGER_KIND", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] @@ -1995,23 +1961,6 @@ pub enum JobTriggerKind { Gcp } -impl fmt::Display for TriggerKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - TriggerKind::Webhook => "webhook", - TriggerKind::Http => "http", - TriggerKind::Websocket => "websocket", - TriggerKind::Kafka => "kafka", - TriggerKind::Email => "email", - TriggerKind::Nats => "nats", - TriggerKind::Mqtt => "mqtt", - TriggerKind::Sqs => "sqs", - TriggerKind::Postgres => "postgres", - TriggerKind::Gcp => "gcp", - }; - write!(f, "{}", s) - } -} #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] pub struct MiniPulledJob { @@ -2174,10 +2123,19 @@ pub struct PulledJob { pub permissioned_as_folders: Option>, } + +// NOTE: +// Precomputed by the server +// Used to offload work from agent workers to server #[derive(Serialize, Deserialize)] pub enum PrecomputedAgentInfo { Bun { local: String, remote: String }, - Python { py_version: Option, requirements: Option }, + Python { + // V1, not used anymore. Exists for compat. + // TODO: Needs to be removed eventually + py_version: Option, + py_version_v2: Option, + requirements: Option }, } #[derive(Serialize, Deserialize)] @@ -3587,7 +3545,7 @@ pub async fn push<'c, 'd>( None, None, ), - JobPayload::ScriptHub { path } => { + JobPayload::ScriptHub { path, apply_preprocessor } => { if path == "hub/7771/slack" || path == "hub/7836/slack" || path == "hub/9084/slack" { // these scripts send app reports to slack // they use the slack bot token and should therefore be run with permissions to access it @@ -3595,6 +3553,10 @@ pub async fn push<'c, 'd>( email = SUPERADMIN_NOTIFICATION_EMAIL; } + if apply_preprocessor { + preprocessed = Some(false); + } + let hub_script = get_full_hub_script_by_path(StripPath(path.clone()), &HTTP_CLIENT, Some(_db)) .await?; diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index c37e78d7cf..4dccc3213c 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -116,6 +116,7 @@ convert_case.workspace = true yaml-rust.workspace = true backon.workspace = true winapi = { workspace = true, optional = true } +pep440_rs.workspace = true opentelemetry = { workspace = true, optional = true } bollard = { workspace = true, optional = true } diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index 7d46fc6040..1783641da3 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -30,10 +30,11 @@ use crate::{ start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, - python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile, PyVersion}, - AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, - PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, + python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile}, + PyVAlias, DISABLE_NSJAIL, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + PY_INSTALL_DIR, TZ_ENV, }; +use windmill_common::client::AuthedClient; lazy_static::lazy_static! { static ref ANSIBLE_PLAYBOOK_PATH: String = @@ -373,7 +374,7 @@ async fn handle_ansible_python_deps( worker_name, w_id, &mut Some(occupancy_metrics), - PyVersion::Py311, + PyVAlias::Py311.into(), false, ) .await @@ -387,10 +388,7 @@ async fn handle_ansible_python_deps( if requirements.len() > 0 { let mut venv_path = handle_python_reqs( - requirements - .split("\n") - .filter(|x| !x.starts_with("--")) - .collect(), + crate::python_executor::split_requirements(requirements), job_id, w_id, mem_peak, @@ -400,7 +398,7 @@ async fn handle_ansible_python_deps( job_dir, worker_dir, &mut Some(occupancy_metrics), - crate::python_executor::PyVersion::Py311, + PyVAlias::default().into(), ) .await?; additional_python_paths.append(&mut venv_path); @@ -1193,7 +1191,7 @@ async fn create_file_resources( job_dir: &str, args: Option<&HashMap>>, r: &AnsibleRequirements, - client: &crate::AuthedClient, + client: &AuthedClient, conn: &Connection, ) -> error::Result> { let mut logs = String::new(); @@ -1270,7 +1268,7 @@ async fn create_file_resources( } async fn get_resource_or_variable_content( - client: &crate::AuthedClient, + client: &AuthedClient, path: &ResourceOrVariablePath, job_id: String, ) -> anyhow::Result { diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 9683c0d8d5..ec08003dd3 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -43,9 +43,11 @@ use crate::{ OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_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; @@ -299,16 +301,28 @@ async fn handle_docker_job( } let wait_f = async { - let wait = client + let waited = client .wait_container::(&container_id, None) .try_collect::>() - .await - .map_err(|e| { + .await; + match waited { + Ok(wait) => Ok(wait.first().map(|x| x.status_code)), + Err(bollard::errors::Error::DockerResponseServerError { status_code, message }) => { + append_logs(&job_id, &workspace_id, &format!(": {message}"), conn).await; + Ok(Some(status_code as i64)) + } + Err(bollard::errors::Error::DockerContainerWaitError { error, code }) => { + append_logs(&job_id, &workspace_id, &format!("{error}"), conn).await; + Ok(Some(code as i64)) + } + Err(e) => { tracing::error!("Error waiting for container: {:?}", e); - anyhow::anyhow!("Error waiting for container") - })?; - let waited = wait.first().map(|x| x.status_code); - Ok(waited) + Err(Error::ExecutionErr(format!( + "Error waiting for container: {:?}", + e + ))) + } + } }; let ncontainer_id = container_id.to_string(); @@ -317,7 +331,7 @@ async fn handle_docker_job( let conn2 = conn.clone(); let worker_name2 = worker_name.to_string(); let (tx, mut rx) = tokio::sync::broadcast::channel::<()>(1); - + let workspace_id2 = workspace_id.to_string(); let mut killpill_rx = killpill_rx.resubscribe(); let logs = tokio::spawn(async move { let client = bollard::Docker::connect_with_unix_defaults().map_err(to_anyhow); @@ -332,6 +346,13 @@ async fn handle_docker_job( ..Default::default() }), ); + append_logs( + &job_id, + &workspace_id2, + "\ndocker logs stream started\n", + &conn2, + ) + .await; loop { tokio::select! { log = log_stream.next() => { @@ -441,11 +462,14 @@ async fn handle_docker_job( let result = result.unwrap(); + if result.is_some_and(|x| x > 0) { + return Err(Error::ExecutionErr(format!( + "Docker job completed with unsuccessful exit status: {}", + result.unwrap() + ))); + } return Ok(to_raw_value(&json!(format!( - "Docker exit status: {}", - result - .map(|x| x.to_string()) - .unwrap_or_else(|| "none".to_string()) + "Docker job completed with success exit status" )))); } diff --git a/backend/windmill-worker/src/bigquery_executor.rs b/backend/windmill-worker/src/bigquery_executor.rs index 3f2f63df5a..a93a067b02 100644 --- a/backend/windmill-worker/src/bigquery_executor.rs +++ b/backend/windmill-worker/src/bigquery_executor.rs @@ -4,6 +4,7 @@ use futures::future::BoxFuture; use futures::{FutureExt, StreamExt}; use reqwest::Client; use serde_json::{json, value::RawValue, Value}; +use windmill_common::client::AuthedClient; use windmill_common::error::to_anyhow; use windmill_common::s3_helpers::convert_json_line_stream; use windmill_common::worker::Connection; @@ -16,15 +17,12 @@ use windmill_queue::CanceledBy; use serde::Deserialize; +use crate::common::{build_args_values, resolve_job_timeout}; use crate::common::{ build_http_client, s3_mode_args_to_worker_data, OccupancyMetrics, S3ModeWorkerData, }; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args; -use crate::{ - common::{build_args_values, resolve_job_timeout}, - AuthedClient, -}; use gcp_auth::{AuthenticationManager, CustomServiceAccount}; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 6a6ad120e7..fa64832ee4 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -20,10 +20,11 @@ use crate::{ read_file_content, read_result, start_child_process, write_file_binary, OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_PATH, - DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPM_CONFIG_REGISTRY, - NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TZ_ENV, + BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_PATH, DISABLE_NSJAIL, + DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, + PATH_ENV, PROXY_ENVS, TZ_ENV, }; +use windmill_common::client::AuthedClient; #[cfg(windows)] use crate::SYSTEM_ROOT; @@ -612,10 +613,7 @@ pub async fn pull_codebase(w_id: &str, id: &str, job_dir: &str) -> Result<()> { extract_saved_codebase(job_dir, &bun_cache_path, is_tar, &dst, false)?; } else { #[cfg(all(feature = "enterprise", feature = "parquet"))] - let object_store = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone(); + let object_store = windmill_common::s3_helpers::get_object_store().await; #[cfg(not(all(feature = "enterprise", feature = "parquet")))] let object_store: Option<()> = None; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index bec5f4c7ef..9955256d13 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -44,10 +44,8 @@ use windmill_common::{variables, DB}; use tokio::{io::AsyncWriteExt, process::Child, time::Instant}; use crate::agent_workers::UPDATE_PING_URL; -use crate::{ - AuthedClient, DISABLE_NSJAIL, JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, - PATH_ENV, -}; +use crate::{DISABLE_NSJAIL, JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, PATH_ENV}; +use windmill_common::client::AuthedClient; pub async fn build_args_map<'a>( job: &'a MiniPulledJob, @@ -782,19 +780,17 @@ async fn get_workspace_s3_resource_path( } }; - let client2 = client.clone(); - let token_fn = |audience: String| async move { - client2 - .get_id_token(&audience) - .await - .map_err(|e| windmill_common::error::Error::from(e)) - }; let s3_resource_value_raw = client .get_resource_value::(path.as_str()) .await?; - get_s3_resource_internal(rt, s3_resource_value_raw, token_fn) - .await - .map(Some) + get_s3_resource_internal( + rt, + s3_resource_value_raw, + windmill_common::job_s3_helpers_ee::TokenGenerator::AsClient(client), + db, + ) + .await + .map(Some) } #[cfg(feature = "parquet")] @@ -1109,7 +1105,7 @@ pub async fn par_install_language_dependencies<'a>( } #[cfg(all(feature = "enterprise", feature = "parquet"))] - if windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS + if windmill_common::s3_helpers::OBJECT_STORE_SETTINGS .read() .await .is_none() @@ -1264,11 +1260,7 @@ pub async fn par_install_language_dependencies<'a>( #[cfg(all(feature = "enterprise", feature = "parquet"))] let s3_pull_future = if is_not_pro { - if let Some(os) = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { Some(crate::global_cache::pull_from_tar( os, path.clone(), @@ -1449,11 +1441,7 @@ pub async fn par_install_language_dependencies<'a>( }; #[cfg(all(feature = "enterprise", feature = "parquet"))] { - if let Some(os) = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { tokio::spawn(async move { if let Err(e) = crate::global_cache::build_tar_and_push( os, @@ -1541,11 +1529,7 @@ pub async fn par_install_language_dependencies<'a>( }; #[cfg(all(feature = "enterprise", feature = "parquet"))] { - if let Some(os) = windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS - .read() - .await - .clone() - { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { let language_name = language_name.to_owned(); tokio::spawn(async move { if let Err(e) = crate::global_cache::build_tar_and_push( @@ -1591,7 +1575,7 @@ pub struct S3ModeWorkerData { } impl S3ModeWorkerData { - pub async fn upload(&self, stream: S) -> error::Result<()> + pub async fn upload(&self, stream: S) -> anyhow::Result<()> where S: futures::stream::TryStream + Send + 'static, S::Error: Into>, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 2054e279db..a8ed06a2e2 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -36,7 +36,7 @@ use crate::{ }; use crate::common::OccupancyMetrics; -use crate::AuthedClient; +use windmill_common::client::AuthedClient; #[cfg(windows)] use crate::SYSTEM_ROOT; diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 696957f463..2215c94d8d 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -11,9 +11,11 @@ use crate::{ start_child_process, OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, + DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV, }; +use windmill_common::client::AuthedClient; + use tokio::{fs::File, io::AsyncReadExt, process::Command}; use windmill_common::{error::Result, worker::write_file, BASE_URL}; use windmill_common::{ diff --git a/backend/windmill-worker/src/global_cache.rs b/backend/windmill-worker/src/global_cache.rs index 155e6db8c1..6ab2acb441 100644 --- a/backend/windmill-worker/src/global_cache.rs +++ b/backend/windmill-worker/src/global_cache.rs @@ -22,6 +22,7 @@ pub async fn build_tar_and_push( platform_agnostic: bool, ) -> error::Result<()> { use object_store::path::Path; + use tokio::fs::create_dir_all; use crate::TAR_PYBASE_CACHE_DIR; @@ -36,7 +37,9 @@ pub async fn build_tar_and_push( }; let prefix = &format!("{TAR_PYBASE_CACHE_DIR}/{}", lang); - let tar_path = format!("{prefix}/{folder_name}_tar.tar",); + let tar_path = format!("{prefix}/{folder_name}_tar.tar"); + + create_dir_all(prefix).await?; let tar_file = std::fs::File::create(&tar_path)?; let mut tar = tar::Builder::new(tar_file); diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index c0fc8a8085..2217c775ba 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -19,9 +19,10 @@ use crate::{ start_child_process, OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, GOPRIVATE, GOPROXY, GO_BIN_CACHE_DIR, - GO_CACHE_DIR, HOME_ENV, NSJAIL_PATH, PATH_ENV, TZ_ENV, + DISABLE_NSJAIL, DISABLE_NUSER, GOPRIVATE, GOPROXY, GO_BIN_CACHE_DIR, GO_CACHE_DIR, HOME_ENV, + NSJAIL_PATH, PATH_ENV, TZ_ENV, }; +use windmill_common::client::AuthedClient; const GO_REQ_SPLITTER: &str = "//go.sum\n"; const NSJAIL_CONFIG_RUN_GO_CONTENT: &str = include_str!("../nsjail/run.go.config.proto"); @@ -473,7 +474,7 @@ pub async fn install_go_dependencies( if non_dep_job { if let Some(db) = conn.as_sql() { sqlx::query!( - "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", + "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('5 mins')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", hash, req_content ) diff --git a/backend/windmill-worker/src/graphql_executor.rs b/backend/windmill-worker/src/graphql_executor.rs index 1fbc547c30..d9117adb53 100644 --- a/backend/windmill-worker/src/graphql_executor.rs +++ b/backend/windmill-worker/src/graphql_executor.rs @@ -12,7 +12,8 @@ use serde::Deserialize; use crate::common::{build_http_client, resolve_job_timeout, OccupancyMetrics}; use crate::handle_child::run_future_with_polling_update_job_poller; -use crate::{common::build_args_map, AuthedClient}; +use crate::common::build_args_map; +use windmill_common::client::AuthedClient; #[derive(Deserialize)] struct GraphqlApi { diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index d82a72df98..3eb9e95dc9 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -134,7 +134,7 @@ pub async fn handle_child( let (tx, rx) = broadcast::channel::<()>(3); let mut rx2: broadcast::Receiver<()> = tx.subscribe(); - let output = child_joined_output_stream(&mut child, job_id.clone()); + let output = child_joined_output_stream(&mut child, job_id.clone(), w_id.to_string()); let job_id: Uuid = job_id.clone(); @@ -729,6 +729,7 @@ where fn child_joined_output_stream( child: &mut Child, job_id: Uuid, + w_id: String, ) -> impl stream::FusedStream> { let stderr = child .stderr @@ -743,8 +744,8 @@ fn child_joined_output_stream( let stdout = BufReader::new(stdout).lines(); let stderr = BufReader::new(stderr).lines(); stream::select( - lines_to_stream(stderr, true, job_id.clone()), - lines_to_stream(stdout, false, job_id), + lines_to_stream(stderr, true, job_id.clone(), w_id.clone()), + lines_to_stream(stdout, false, job_id, w_id), ) } @@ -752,11 +753,12 @@ pub fn lines_to_stream( mut lines: tokio::io::Lines, stderr: bool, job_id: Uuid, + w_id: String, ) -> impl futures::Stream> { stream::poll_fn(move |cx| { std::pin::Pin::new(&mut lines) .poll_next_line(cx) - .map(|result| process_streaming_log_lines(result, stderr, &job_id)) + .map(|result| process_streaming_log_lines(result, stderr, &job_id, &w_id)) }) } diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index c589252005..f62de150a9 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -24,9 +24,11 @@ use crate::{ create_args_and_out_file, get_reserved_variables, par_install_language_dependencies, read_result, start_child_process, OccupancyMetrics, RequiredDependency, }, - handle_child, AuthedClient, COURSIER_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, JAVA_CACHE_DIR, + handle_child, COURSIER_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, JAVA_CACHE_DIR, JAVA_REPOSITORY_DIR, MAVEN_REPOS, NO_DEFAULT_MAVEN, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, }; +use windmill_common::client::AuthedClient; + lazy_static::lazy_static! { static ref JAVA_CONCURRENT_DOWNLOADS: usize = std::env::var("JAVA_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20); static ref JAVA_PATH: String = std::env::var("JAVA_PATH").unwrap_or_else(|_| "/usr/bin/java".to_string()); @@ -243,7 +245,7 @@ pub async fn resolve<'a>( if let Connection::Sql(db) = conn { sqlx::query!( - "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", + "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('5 mins')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", req_hash, lock.clone(), ) diff --git a/backend/windmill-worker/src/job_logger_ee.rs b/backend/windmill-worker/src/job_logger_ee.rs index 4b1d34392c..22772878ee 100644 --- a/backend/windmill-worker/src/job_logger_ee.rs +++ b/backend/windmill-worker/src/job_logger_ee.rs @@ -36,6 +36,7 @@ pub(crate) fn process_streaming_log_lines( r: Result, io::Error>, _stderr: bool, _job_id: &Uuid, + _w_id: &str, ) -> Option> { r.transpose() } diff --git a/backend/windmill-worker/src/js_eval.rs b/backend/windmill-worker/src/js_eval.rs index 5c9af6760b..77ccd9b15f 100644 --- a/backend/windmill-worker/src/js_eval.rs +++ b/backend/windmill-worker/src/js_eval.rs @@ -48,7 +48,8 @@ use windmill_common::worker::{write_file, TMP_DIR}; use windmill_common::flow_status::JobResult; use windmill_queue::CanceledBy; -use crate::{common::OccupancyMetrics, AuthedClient}; +use crate::common::OccupancyMetrics; +use windmill_common::client::AuthedClient; #[cfg(feature = "deno_core")] use crate::{common::unsafe_raw, handle_child::run_future_with_polling_update_job_poller}; diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 64a9cf2c88..4ea56c090e 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -39,6 +39,8 @@ mod pg_executor; mod php_executor; #[cfg(feature = "python")] mod python_executor; +#[cfg(feature = "python")] +mod python_versions; pub mod result_processor; #[cfg(feature = "rust")] mod rust_executor; @@ -60,3 +62,6 @@ pub use bun_executor::{ prebundle_bun_script, prepare_job_dir, }; pub use deno_executor::generate_deno_lock; + +#[cfg(feature = "python")] +pub use python_versions::{PyV, PyVAlias}; diff --git a/backend/windmill-worker/src/mssql_executor.rs b/backend/windmill-worker/src/mssql_executor.rs index ca6b4785c7..7f5ec7c116 100644 --- a/backend/windmill-worker/src/mssql_executor.rs +++ b/backend/windmill-worker/src/mssql_executor.rs @@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::common::{build_args_values, s3_mode_args_to_worker_data, OccupancyMetrics}; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args; -use crate::AuthedClient; +use windmill_common::client::AuthedClient; use serde::Deserializer; diff --git a/backend/windmill-worker/src/mysql_executor.rs b/backend/windmill-worker/src/mysql_executor.rs index dae89112fe..ffd84db175 100644 --- a/backend/windmill-worker/src/mysql_executor.rs +++ b/backend/windmill-worker/src/mysql_executor.rs @@ -13,6 +13,7 @@ use serde_json::{json, value::RawValue, Value}; use std::str::FromStr; use tokio::sync::Mutex; use windmill_common::{ + client::AuthedClient, error::{to_anyhow, Error}, s3_helpers::convert_json_line_stream, worker::{to_raw_value, Connection}, @@ -28,7 +29,6 @@ use crate::{ common::{build_args_values, s3_mode_args_to_worker_data, OccupancyMetrics, S3ModeWorkerData}, handle_child::run_future_with_polling_update_job_poller, sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args, - AuthedClient, }; #[derive(Deserialize)] diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index b2f6590c41..c31d979c27 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -16,8 +16,10 @@ use crate::{ create_args_and_out_file, get_reserved_variables, read_result, start_child_process, OccupancyMetrics, }, - handle_child, AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + handle_child, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, }; +use windmill_common::client::AuthedClient; + const NSJAIL_CONFIG_RUN_NU_CONTENT: &str = include_str!("../nsjail/run.nu.config.proto"); lazy_static::lazy_static! { diff --git a/backend/windmill-worker/src/oracledb_executor.rs b/backend/windmill-worker/src/oracledb_executor.rs index 2147a53e45..244cbb1958 100644 --- a/backend/windmill-worker/src/oracledb_executor.rs +++ b/backend/windmill-worker/src/oracledb_executor.rs @@ -27,9 +27,9 @@ use crate::{ OccupancyMetrics, S3ModeWorkerData, }, handle_child::run_future_with_polling_update_job_poller, - sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args, - AuthedClient, + sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args }; +use windmill_common::client::AuthedClient; #[derive(Deserialize)] struct OracleDatabase { diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index fdd9a3d7a4..67c2d945aa 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -41,11 +41,11 @@ use crate::common::{ }; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args; -use crate::{AuthedClient, MAX_RESULT_SIZE}; +use crate::MAX_RESULT_SIZE; use bytes::Buf; use lazy_static::lazy_static; use urlencoding::encode; - +use windmill_common::client::AuthedClient; #[derive(Deserialize)] struct PgDatabase { host: String, diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index ac50beb99f..ec8478a9a5 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -20,9 +20,10 @@ use crate::{ read_result, start_child_process, OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, + COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH, }; +use windmill_common::client::AuthedClient; const NSJAIL_CONFIG_RUN_PHP_CONTENT: &str = include_str!("../nsjail/run.php.config.proto"); diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 7721a2fc2b..76ee967656 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -3,6 +3,7 @@ use std::{ fs, path::Path, process::Stdio, + str::FromStr, sync::Arc, }; @@ -38,12 +39,12 @@ use std::env::var; use windmill_queue::{append_logs, CanceledBy, PrecomputedAgentInfo}; lazy_static::lazy_static! { - static ref PYTHON_PATH: Option = var("PYTHON_PATH").ok().map(|v| { + pub(crate) static ref PYTHON_PATH: Option = var("PYTHON_PATH").ok().map(|v| { tracing::warn!("PYTHON_PATH is set to {} and thus python will not be managed by uv and stay static regardless of annotation and instance settings. NOT RECOMMENDED", v); v }); - static ref UV_PATH: String = + pub(crate) static ref UV_PATH: String = var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string()); static ref PY_CONCURRENT_DOWNLOADS: usize = @@ -69,7 +70,7 @@ const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py"); use crate::global_cache::{build_tar_and_push, pull_from_tar}; #[cfg(all(feature = "enterprise", feature = "parquet", unix))] -use windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS; +use windmill_common::s3_helpers::OBJECT_STORE_SETTINGS; use crate::{ common::{ @@ -77,347 +78,11 @@ use crate::{ start_child_process, OccupancyMetrics, }, handle_child::handle_child, - worker_lockfiles::LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT, worker_utils::ping_job_status, - AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, INSTANCE_PYTHON_VERSION, NSJAIL_PATH, - PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, UV_CACHE_DIR, + PyV, PyVAlias, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, + PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, UV_CACHE_DIR, }; - -// To change latest stable version: -// 1. Change placeholder in instanceSettings.ts -// 2. Change LATEST_STABLE_PY in dockerfile -// 3. Change #[default] annotation for PyVersion in backend -#[derive(Eq, PartialEq, Clone, Copy, Default, Debug)] -pub enum PyVersion { - Py310, - #[default] - Py311, - Py312, - Py313, -} - -impl PyVersion { - pub async fn from_instance_version(job_id: &Uuid, w_id: &str, conn: &Connection) -> Self { - let mut err = None; - let pyv = match INSTANCE_PYTHON_VERSION.read().await.clone() { - Some(v) => PyVersion::from_string_with_dots(&v).unwrap_or_else(|| { - let v = PyVersion::default(); - err = Some(format!("\nCannot parse INSTANCE_PYTHON_VERSION ({:?}), fallback to latest_stable ({v:?})", *INSTANCE_PYTHON_VERSION)); - v - }), - // Use latest stable - None => PyVersion::default(), - }; - - if let Some(msg) = err { - append_logs(job_id, w_id, &msg, conn).await; - tracing::error!(msg); - } - pyv - } - /// e.g.: `/tmp/windmill/cache/python_3xy` - pub fn to_cache_dir(&self) -> String { - use windmill_common::worker::ROOT_CACHE_DIR; - format!("{ROOT_CACHE_DIR}{}", &self.to_cache_dir_top_level()) - } - /// e.g.: `python_3xy` - pub fn to_cache_dir_top_level(&self) -> String { - format!("python_{}", self.to_string_no_dot()) - } - /// e.g.: `3xy` - pub fn to_string_no_dot(&self) -> String { - self.to_string_with_dot().replace('.', "") - } - /// e.g.: `3.xy` - pub fn to_string_with_dot(&self) -> &str { - use PyVersion::*; - match self { - Py310 => "3.10", - Py311 => "3.11", - Py312 => "3.12", - Py313 => "3.13", - } - } - pub fn from_string_with_dots(value: &str) -> Option { - use PyVersion::*; - match value { - "3.10" => Some(Py310), - "3.11" => Some(Py311), - "3.12" => Some(Py312), - "3.13" => Some(Py313), - "default" => Some(PyVersion::default()), - _ => { - tracing::warn!( - "Cannot convert string (\"{value}\") to PyVersion\nExpected format x.yz" - ); - None - } - } - } - pub fn from_string_no_dots(value: &str) -> Option { - use PyVersion::*; - match value { - "310" => Some(Py310), - "311" => Some(Py311), - "312" => Some(Py312), - "313" => Some(Py313), - "default" => Some(PyVersion::default()), - _ => { - tracing::warn!( - "Cannot convert string (\"{value}\") to PyVersion\nExpected format xyz" - ); - None - } - } - } - /// e.g.: `# py3xy` -> `PyVersion::Py3XY` - pub fn parse_version(line: &str) -> Option { - Self::from_string_no_dots(line.replace(" ", "").replace("#py", "").as_str()) - } - pub fn from_py_annotations(a: PythonAnnotations) -> Option { - let PythonAnnotations { py310, py311, py312, py313, .. } = a; - use PyVersion::*; - if py313 { - Some(Py313) - } else if py312 { - Some(Py312) - } else if py311 { - Some(Py311) - } else if py310 { - Some(Py310) - } else { - None - } - } - pub fn from_numeric(n: u32) -> Option { - use PyVersion::*; - match n { - 310 => Some(Py310), - 311 => Some(Py311), - 312 => Some(Py312), - 313 => Some(Py313), - _ => None, - } - } - pub fn to_numeric(&self) -> u32 { - use PyVersion::*; - match self { - Py310 => 310, - Py311 => 311, - Py312 => 312, - Py313 => 313, - } - } - pub async fn get_python( - &self, - job_id: &Uuid, - mem_peak: &mut i32, - // canceled_by: &mut Option, - conn: &Connection, - worker_name: &str, - w_id: &str, - occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - ) -> error::Result> { - // lazy_static::lazy_static! { - // static ref PYTHON_PATHS: Arc>> = Arc::new(RwLock::new(HashMap::new())); - // } - - let res = self - .get_python_inner(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) - .await; - - if let Err(ref e) = res { - tracing::error!( - "worker_name: {worker_name}, w_id: {w_id}, job_id: {job_id}\n - Error while getting python from uv, falling back to system python: {e:?}" - ); - append_logs( - job_id, - w_id, - format!( - "\nError while getting python from uv, falling back to system python: {e:?}" - ), - conn, - ) - .await; - } - res - } - async fn get_python_inner( - self, - job_id: &Uuid, - mem_peak: &mut i32, - // canceled_by: &mut Option, - conn: &Connection, - worker_name: &str, - w_id: &str, - occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - ) -> error::Result> { - let py_path = self.find_python().await; - - // Runtime is not installed - if py_path.is_err() { - // Install it - if let Err(err) = self - .install_python(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) - .await - { - tracing::error!("Cannot install python: {err}"); - return Err(err); - } else { - // Try to find one more time - let py_path = self.find_python().await; - - if let Err(err) = py_path { - tracing::error!("Cannot find python version {err}"); - return Err(err); - } - - // TODO: Cache the result - py_path - } - } else { - py_path - } - } - async fn install_python( - self, - job_id: &Uuid, - mem_peak: &mut i32, - // canceled_by: &mut Option, - conn: &Connection, - worker_name: &str, - w_id: &str, - occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - ) -> error::Result<()> { - let v = self.to_string_with_dot(); - append_logs(job_id, w_id, format!("\nINSTALLING PYTHON ({})", v), conn).await; - // Create dirs for newly installed python - // If we dont do this, NSJAIL will not be able to mount cache - // For the default version directory created during startup (main.rs) - DirBuilder::new() - .recursive(true) - .create(self.to_cache_dir()) - .await - .expect("could not create initial worker dir"); - - let logs = String::new(); - - #[cfg(windows)] - let uv_cmd = "uv"; - - #[cfg(unix)] - let uv_cmd = UV_PATH.as_str(); - - let mut child_cmd = Command::new(uv_cmd); - child_cmd - .env_clear() - .env("HOME", HOME_ENV.to_string()) - .env("PATH", PATH_ENV.to_string()) - .envs(PROXY_ENVS.clone()) - .args(["python", "install", v, "--python-preference=only-managed"]) - // TODO: Do we need these? - .envs([("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR)]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - #[cfg(windows)] - { - child_cmd - .env("SystemRoot", SYSTEM_ROOT.as_str()) - .env("USERPROFILE", crate::USERPROFILE_ENV.as_str()) - .env( - "TMP", - std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")), - ) - .env( - "LOCALAPPDATA", - std::env::var("LOCALAPPDATA") - .unwrap_or_else(|_| format!("{}\\AppData\\Local", HOME_ENV.as_str())), - ); - } - - let child_process = start_child_process(child_cmd, "uv").await?; - - append_logs(&job_id, &w_id, logs, conn).await; - handle_child( - job_id, - conn, - mem_peak, - &mut None, - child_process, - false, - worker_name, - &w_id, - "uv", - None, - false, - occupancy_metrics, - None, - ) - .await - } - async fn find_python(self) -> error::Result> { - #[cfg(windows)] - let uv_cmd = "uv"; - - #[cfg(unix)] - let uv_cmd = UV_PATH.as_str(); - - let mut child_cmd = Command::new(uv_cmd); - - child_cmd.env_clear(); - - #[cfg(windows)] - { - child_cmd - .env("SystemRoot", SYSTEM_ROOT.as_str()) - .env("USERPROFILE", crate::USERPROFILE_ENV.as_str()) - .env( - "TMP", - std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")), - ) - .env( - "LOCALAPPDATA", - std::env::var("LOCALAPPDATA") - .unwrap_or_else(|_| format!("{}\\AppData\\Local", HOME_ENV.as_str())), - ); - } - - let output = child_cmd - // .current_dir(job_dir) - .env("HOME", HOME_ENV.to_string()) - .env("PATH", PATH_ENV.to_string()) - .args([ - "python", - "find", - self.to_string_with_dot(), - "--system", - "--python-preference=only-managed", - ]) - .envs([ - ("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR), - ("UV_PYTHON_PREFERENCE", "only-managed"), - ]) - // .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - // Check if the command was successful - if output.status.success() { - // Convert the output to a String - let stdout = - String::from_utf8(output.stdout).expect("Failed to convert output to String"); - return Ok(Some(stdout.replace('\n', ""))); - } else { - // If the command failed, print the error - let stderr = - String::from_utf8(output.stderr).expect("Failed to convert error output to String"); - return Err(error::Error::FindPythonError(stderr)); - } - } -} +use windmill_common::client::AuthedClient; #[cfg(windows)] use crate::SYSTEM_ROOT; @@ -465,7 +130,7 @@ pub async fn uv_pip_compile( worker_name: &str, w_id: &str, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - py_version: PyVersion, + py_version: PyV, // Debug-only flag no_cache: bool, ) -> error::Result { @@ -502,10 +167,11 @@ pub async fn uv_pip_compile( requirements.to_string() }; + let py_version_str = py_version.clone().to_string(); // Include python version to requirements.in // We need it because same hash based on requirements.in can get calculated even for different python versions // To prevent from overwriting same requirements.in but with different python versions, we include version to hash - let requirements = format!("# py{}\n{}", py_version.to_string_no_dot(), requirements); + let requirements = format!("# py: {}\n{}", py_version.to_string(), requirements); #[cfg(feature = "enterprise")] let requirements = replace_pip_secret(conn, w_id, &requirements, worker_name, job_id).await?; @@ -525,7 +191,7 @@ pub async fn uv_pip_compile( { logs.push_str(&format!( "\nFound cached resolution: {req_hash}, on python version: {}", - py_version.to_string_with_dot() + &py_version_str )); return Ok(cached); } @@ -539,7 +205,7 @@ pub async fn uv_pip_compile( { // Make sure we have python runtime installed py_version - .get_python(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) + .try_get_python(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) .await?; let mut args = vec![ @@ -561,12 +227,7 @@ pub async fn uv_pip_compile( UV_CACHE_DIR, ]; - args.extend([ - "-p", - &py_version.to_string_with_dot(), - "--python-preference", - "only-managed", - ]); + args.extend(["-p", &py_version_str, "--python-preference", "only-managed"]); if no_cache { args.extend(["--no-cache"]); @@ -666,8 +327,8 @@ pub async fn uv_pip_compile( let mut req_content = "".to_string(); file.read_to_string(&mut req_content).await?; let lockfile = format!( - "# py{}\n{}", - py_version.to_string_no_dot(), + "# py: {}\n{}", + py_version.to_string(), req_content .lines() .filter(|x| !x.trim_start().starts_with('#')) @@ -677,7 +338,7 @@ pub async fn uv_pip_compile( ); if let Some(db) = conn.as_sql() { sqlx::query!( - "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", + "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('5 mins')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2", req_hash, lockfile ).fetch_optional(db).await?; @@ -789,37 +450,6 @@ async fn postinstall( Ok(()) } -async fn get_python_path( - py_version: PyVersion, - worker_name: &str, - job_id: &Uuid, - w_id: &str, - mem_peak: &mut i32, - conn: &Connection, - occupancy_metrics: &mut Option<&mut OccupancyMetrics>, -) -> windmill_common::error::Result { - let python_path = if let Some(python_path) = PYTHON_PATH.clone() { - python_path - } else if let Some(python_path) = py_version - .get_python( - &job_id, - mem_peak, - conn, - worker_name, - w_id, - occupancy_metrics, - ) - .await? - { - python_path - } else { - return Err(Error::ExecutionErr(format!( - "uv could not manage python path. Please manage it manually by setting PYTHON_PATH environment variable to your python binary path" - ))); - }; - Ok(python_path) -} - #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_python_job( requirements_o: Option<&String>, @@ -863,16 +493,16 @@ pub async fn handle_python_job( .await?; tracing::debug!("Finished handling python dependencies"); - let python_path = get_python_path( - py_version, - worker_name, - &job.id, - &job.workspace_id, - mem_peak, - conn, - &mut Some(occupancy_metrics), - ) - .await?; + let python_path = py_version + .get_python( + worker_name, + &job.id, + &job.workspace_id, + mem_peak, + conn, + &mut Some(occupancy_metrics), + ) + .await?; if !annotations.no_postinstall { if let Err(e) = postinstall(&mut additional_python_paths, job_dir, job, conn).await { @@ -887,7 +517,7 @@ pub async fn handle_python_job( &job.workspace_id, format!( "\n\n--- PYTHON ({}) CODE EXECUTION ---\n", - py_version.to_string_with_dot() + py_version.clone().to_string() ), conn, ) @@ -1026,7 +656,7 @@ except BaseException as e: let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; - // Add /tmp/windmill/cache/python_xyz/global-site-packages to PYTHONPATH. + // Add /tmp/windmill/cache/python_x_y_z/global-site-packages to PYTHONPATH. // Usefull if certain wheels needs to be preinstalled before execution. let global_site_packages_path = py_version.to_cache_dir() + "/global-site-packages"; let additional_python_paths_folders = { @@ -1039,9 +669,9 @@ except BaseException as e: // Since we handle mount of global_site_packages on our own, we don't want it to be mounted automatically. // We do this because existence of every wheel in cache is mandatory and if it is not there and nsjail expects it, it is a bug. // On the other side global_site_packages is purely optional. - // NOTE: This behaviour can be changed in future, so verification of wheels can be offloaded from nsjail to windmill + // NOTE: This behaviour can be changed in future, so verification of wheels can be delegated from nsjail to windmill paths.insert(0, global_site_packages_path.clone()); - // ^^^^^^^^ + // ^^^^^^ ^ // We also want this be priorotized, that's why we insert it to the beginning } paths.iter().join(":") @@ -1434,7 +1064,7 @@ async fn handle_python_deps( occupancy_metrics: &mut Option<&mut OccupancyMetrics>, precomputed_agent_info: Option, annotations: PythonAnnotations, -) -> error::Result<(PyVersion, Vec)> { +) -> error::Result<(PyV, Vec)> { create_dependencies_dir(job_dir).await; let mut additional_python_paths: Vec = WORKER_CONFIG @@ -1445,90 +1075,116 @@ async fn handle_python_deps( .unwrap_or_else(|| vec![]) .clone(); - let mut requirements; - let compilation_error_hint; - let mut annotated_pyv = None; - let mut annotated_pyv_numeric = None; - let is_deployed = requirements_o.is_some(); - let instance_pyv = PyVersion::from_instance_version(job_id, w_id, conn).await; - let requirements = match requirements_o { - Some(r) => r, + let (pyv, resolved_lines) = match requirements_o { + // Deployed + Some(r) => { + let rl = split_requirements(r); + (PyV::parse_from_requirements(&rl), rl) + } + // Preview None => { - let mut already_visited = vec![]; - - (requirements, compilation_error_hint) = match conn { + let (v, requirements_lines, error_hint) = match conn { Connection::Sql(db) => { + let mut version_specifiers = vec![]; let (r, h) = windmill_parser_py_imports::parse_python_imports( inner_content, w_id, script_path, db, - &mut already_visited, - &mut annotated_pyv_numeric, + &mut version_specifiers, ) .await?; - (r.join("\n"), h) + let v = PyV::resolve( + version_specifiers, + job_id, + w_id, + annotations.py_select_latest, + Some(conn.clone()), + None, + None, + ) + .await?; + + (v, r, h) } Connection::Http(_) => match precomputed_agent_info { - Some(PrecomputedAgentInfo::Python { py_version, requirements }) => { - annotated_pyv_numeric = py_version; - (requirements.clone().unwrap_or_else(|| "".to_string()), None) + Some(PrecomputedAgentInfo::Python { + requirements, + py_version, + py_version_v2, + }) => { + let v = { + let v_v2 = py_version_v2 + .clone() + .and_then(|s| pep440_rs::Version::from_str(&s).ok().map(PyV::from)); + let v_v1 = py_version.and_then(PyVAlias::try_from_v1).map(PyV::from); + + match v_v2.or(v_v1) { + Some(v) => v, + None => { + tracing::warn!( + workspace_id = %w_id, + " +Failed to get precomputed python version from server. Fallback to Default ({}) +Returned from server: py_version - {:?}, py_version_v2 - {:?} + ", + *PyV::default(), + py_version, + py_version_v2 + ); + Default::default() + } + } + }; + + let r = split_requirements(requirements.unwrap_or_default()); + let h = None; + + (v, r, h) } - _ => ("".to_string(), None), + _ => Default::default(), }, }; - annotated_pyv = annotated_pyv_numeric.and_then(|v| PyVersion::from_numeric(v)); - - if !requirements.is_empty() { - requirements = uv_pip_compile( - job_id, - &requirements, - mem_peak, - canceled_by, - job_dir, - conn, - worker_name, - w_id, - occupancy_metrics, - annotated_pyv.unwrap_or(instance_pyv), - annotations.no_cache, - ) - .await - .map_err(|e| { - Error::ExecutionErr(format!( - "pip compile failed: {}{}", - e.to_string(), - compilation_error_hint.unwrap_or_default() - )) - })?; - } - &requirements + ( + v.clone(), + if !requirements_lines.is_empty() { + uv_pip_compile( + job_id, + &requirements_lines.join("\n"), + mem_peak, + canceled_by, + job_dir, + conn, + worker_name, + w_id, + occupancy_metrics, + // annotated_pyv.unwrap_or(instance_pyv), + v, + annotations.no_cache, + ) + .await + .map_err(|e| { + Error::ExecutionErr(format!( + "pip compile failed: {}{}", + e.to_string(), + error_hint.unwrap_or_default() + )) + })? + .lines() + .map(|s| s.to_owned()) + .collect_vec() + } else { + vec![] + }, + ) } }; - /* - For deployed scripts we want to find out version in following order: - 1. Assigned version (written in lockfile) - 2. 3.11 - - For Previews: - 1. Annotated version - 2. Instance version - 3. Latest Stable - */ - let requirements_lines = split_requirements(requirements.as_str()); - let final_version = if is_deployed { - get_pyv_from_requirements_lines(&requirements_lines) - } else { - // This is not deployed script, meaning we test run it (Preview) - annotated_pyv.unwrap_or(instance_pyv) - }; - // If len > 0 it means there is atleast one dependency or assigned python version - if requirements.len() > 0 { + if !resolved_lines.is_empty() { let mut venv_path = handle_python_reqs( - requirements_lines, + resolved_lines, job_id, w_id, mem_peak, @@ -1538,13 +1194,13 @@ async fn handle_python_deps( job_dir, worker_dir, occupancy_metrics, - final_version, + pyv.clone(), ) .await?; additional_python_paths.append(&mut venv_path); } - Ok((final_version, additional_python_paths)) + Ok((pyv, additional_python_paths)) } lazy_static::lazy_static! { @@ -1733,7 +1389,7 @@ async fn spawn_uv_install( /// uv pip install, include cached or pull from S3 pub async fn handle_python_reqs( - requirements: Vec<&str>, + requirements: Vec, job_id: &Uuid, w_id: &str, mem_peak: &mut i32, @@ -1743,7 +1399,7 @@ pub async fn handle_python_reqs( job_dir: &str, worker_dir: &str, _occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - py_version: PyVersion, + py_version: PyV, ) -> error::Result> { let worker_dir = worker_dir.to_string(); @@ -1769,7 +1425,7 @@ pub async fn handle_python_reqs( } #[cfg(all(feature = "enterprise", feature = "parquet", unix))] - if OBJECT_STORE_CACHE_SETTINGS.read().await.is_none() { + if OBJECT_STORE_SETTINGS.read().await.is_none() { (s3_pull, s3_push) = (false, false); } @@ -2017,7 +1673,7 @@ pub async fn handle_python_reqs( let total_time = std::time::Instant::now(); let py_path = py_version - .get_python( + .try_get_python( job_id, mem_peak, conn, @@ -2059,6 +1715,10 @@ pub async fn handle_python_reqs( let py_path = py_path.clone(); let pids = pids.clone(); let worker_dir = worker_dir.clone(); + + #[cfg(all(feature = "enterprise", feature = "parquet", unix))] + let py_version = py_version.clone(); + handles.push(task::spawn(async move { // permit will be dropped anyway if this thread exits at any point // so we dont have to drop it manually @@ -2076,7 +1736,7 @@ pub async fn handle_python_reqs( let start = std::time::Instant::now(); #[cfg(all(feature = "enterprise", feature = "parquet", unix))] if is_not_pro { - if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { tokio::select! { // Cancel was called on the job _ = kill_rx.recv() => return Err(anyhow::anyhow!("S3 pull was canceled")), @@ -2230,7 +1890,7 @@ pub async fn handle_python_reqs( #[cfg(all(feature = "enterprise", feature = "parquet", unix))] if s3_push { - if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() { + if let Some(os) = windmill_common::s3_helpers::get_object_store().await { tokio::spawn(build_tar_and_push(os, venv_p.clone(), py_version.to_cache_dir_top_level(), None, false)); } } @@ -2300,36 +1960,14 @@ pub async fn handle_python_reqs( }; } -fn split_requirements(requirements: &str) -> Vec<&str> { +pub fn split_requirements>(requirements: T) -> Vec { requirements - .split("\n") + .as_ref() + .lines() .filter(|x| !x.trim_start().starts_with("--") && !x.trim().is_empty()) + .map(String::from) .collect() } -/// Check requirements/lockfile to figure out python version assigned to it. -fn get_pyv_from_requirements_lines(requirements_lines: &[&str]) -> PyVersion { - // If script is deployed we can try to parse first line to get assigned version - - let index = if requirements_lines.get(0).map_or(false, |line| { - line.starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT) - }) { - 1 - } else { - 0 - }; - if let Some(v) = requirements_lines - .get(index) - .and_then(|line| PyVersion::parse_version(*line)) - { - // We have valid assigned version, we use it - v - } else { - // If there is no assigned version in lockfile we automatically fallback to 3.11 - // In this case we have dependencies, but no associated python version - // This is the case for old deployed scripts - PyVersion::Py311 - } -} // Returns code snippet that needs to be injected into wrapper to post-process results or leave unprocessed fn get_result_postprocessor<'a>(skip: bool) -> &'a str { @@ -2365,6 +2003,8 @@ pub async fn start_worker( jobs_rx: tokio::sync::mpsc::Receiver>, killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> error::Result<()> { + use crate::{PyV, PyVAlias}; + let mut mem_peak: i32 = 0; let mut canceled_by: Option = None; let context = variables::get_reserved_variables( @@ -2518,22 +2158,22 @@ for line in sys.stdin: proc_envs.insert("BASE_URL".to_string(), base_internal_url.to_string()); let py_version = if let Some(requirements) = requirements_o { - get_pyv_from_requirements_lines(&split_requirements(requirements.as_str())) + PyV::parse_from_requirements(&split_requirements(requirements.as_str())) } else { tracing::warn!(workspace_id = %w_id, "lockfile is empty for dedicated worker, thus python version cannot be inferred. Fallback to 3.11"); - PyVersion::Py311 + PyVAlias::Py311.into() }; - let python_path = get_python_path( - py_version, - worker_name, - &Uuid::nil(), - w_id, - &mut mem_peak, - &Connection::Sql(db.clone()), - &mut None, - ) - .await?; + let python_path = py_version + .get_python( + worker_name, + &Uuid::nil(), + w_id, + &mut mem_peak, + &Connection::Sql(db.clone()), + &mut None, + ) + .await?; handle_dedicated_process( &python_path, job_dir, diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs new file mode 100644 index 0000000000..26be7aca65 --- /dev/null +++ b/backend/windmill-worker/src/python_versions.rs @@ -0,0 +1,848 @@ +use std::{ + ops::{Deref, DerefMut}, + process::Stdio, + str::FromStr, + sync::Arc, +}; + +use chrono::{DateTime, Duration, Utc}; +use itertools::Itertools; +use serde_json::Value; +use tokio::{fs::DirBuilder, process::Command, sync::RwLock}; +use uuid::Uuid; +use windmill_common::{ + error::{self, Error}, + worker::Connection, +}; + +use anyhow::{anyhow, bail}; +use windmill_queue::append_logs; + +use crate::{ + common::{start_child_process, OccupancyMetrics}, + handle_child::handle_child, + python_executor::{PYTHON_PATH, UV_PATH}, + worker_lockfiles::LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT, + HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, WIN_ENVS, +}; + +#[derive(Eq, PartialEq, Clone, Copy, Default, Debug)] +#[repr(u32)] +pub enum PyVAlias { + Py310 = 10, + #[default] + Py311, + Py312, + Py313, +} + +impl Into for PyVAlias { + fn into(self) -> pep440_rs::Version { + pep440_rs::Version::new([self.major() as u64, self as u64]) + } +} + +impl Into for PyVAlias { + fn into(self) -> u32 { + self.major() * 100 + self as u32 + } +} + +impl From for PyVAlias { + fn from(value: PyV) -> Self { + match value.release() { + [major, minor, ..] => { + if let Some(alias) = Self::try_from_v1(format!("{}{}", *major, *minor)) { + return alias; + } + } + _ => (), + } + + tracing::warn!( + "Failed to convert Python Full Version to Alias. Fallback to default ({})", + *PyV::default() + ); + Self::default() + } +} +impl PyVAlias { + fn all>() -> Vec { + use PyVAlias::*; + vec![Py310.into(), Py311.into(), Py312.into(), Py313.into()] + } + // Get MAJOR part of alias. (semver: MAJOR.MINOR.PATCH) + fn major(&self) -> u32 { + use PyVAlias::*; + match self { + Py310 | Py311 | Py312 | Py313 => 3, + // Py400 | Py401 => 4 + } + } + + /// Converts numeric format to alias + /// Example: + /// 310u32 (in) -> PyVAlias::Py310 (out) + pub(crate) fn try_from_v1(numeric: T) -> Option { + use PyVAlias::*; + match numeric.to_string().as_str() { + "310" => Some(Py310), + "311" => Some(Py311), + "312" => Some(Py312), + "313" => Some(Py313), + _ => None, + } + } +} + +// To change latest stable version: +// 1. Change placeholder in instanceSettings.ts +// 2. Change LATEST_STABLE_PY in dockerfile +// 3. Change #[default] annotation for PyVersion in backend +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PyV(pub pep440_rs::Version); + +impl From for PyV { + fn from(value: pep440_rs::Version) -> Self { + Self(value) + } +} + +impl From for PyV { + fn from(value: PyVAlias) -> Self { + Self(value.into()) + } +} + +impl Default for PyV { + fn default() -> Self { + PyVAlias::default().into() + } +} + +impl Deref for PyV { + type Target = pep440_rs::Version; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for PyV { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl PyV { + pub async fn resolve( + version_specifiers: Vec, + job_id: &Uuid, + w_id: &str, + select_latest: bool, + // Needed for logs but optional + conn: Option, + // Usually for testing + custom_versions: Option>, + // For testing + gravitational_version: Option, + ) -> Result { + // Get all versions that can be fetched + let all_versions = custom_versions.unwrap_or(PyV::list_available_python_versions().await); + + // Narrow down to those that satisfy given version specifiers + let valid = all_versions + .clone() + .into_iter() + .filter(|v| version_specifiers.iter().all(|vs| vs.contains(&*v))) + .collect_vec(); + + if !valid.is_empty() { + if select_latest { + return Ok(valid[0].clone()); + } + + // Usually INSTANCE_PYTHON_VERSION + let gv = gravitational_version + .unwrap_or(PyV::gravitational_version(job_id, w_id, conn).await); + + // Will be used to determine if picked version matches gravity version + // Once first match occure, we will stop iterating + let gravity_matcher = pep440_rs::VersionSpecifier::from_version( + pep440_rs::Operator::EqualStar, + (*gv).clone(), + ) + .map_err(|e| { + Error::ArgumentErr(format!( + "{e}\nLikely means INSTANCE_PYTHON_VERSION is set incorrectly." + )) + })?; + + // Reminder of semver: MAJOR.MINOR.PATCH + // + // - Go from up to down + // - We will iterate until find the closest version to target. + // - If closest version has the same MINOR version, use it. + // - If it differs in MINOR version, take latest PATCH version. + // + let mut result = None; + + // This represents newest version with oldest MINOR: + // + // I Iterable Newest in MINOR + // 1. 3.11.2 -> 3.11.2 + // 2. 3.11.1 -> 3.11.2 + // 3. 3.11.0 -> 3.11.2 + // 4. 3.10.2 -> 3.10.2 + // 5. 3.10.1 -> 3.10.2 + // 6. 3.10.0 -> 3.10.2 + let mut newest_in_minor = None; + for v in valid.iter() { + if result.is_none() { + result.replace(v); + } + + if v < &gv { + // We will not continue if we start looking into versions older than gravity version. + break; + } + + let [major, minor, ..] = v.release() else { + return Err(Error::InternalErr(format!("Failed to parse \"{}\". Available python versions are supposed to be in SEMVER format (MAJOR.MINOR)", **v))); + }; + + // Since we go top to down we can assume + // the first occurence of new minor version contains the latest patch version. + if matches!(newest_in_minor, Some((_, mm)) if mm != (major, minor)) + || newest_in_minor.is_none() + { + newest_in_minor = Some((v.clone(), (major, minor))); + } + + if gravity_matcher.contains(v) { + // return as soon as gravity matcher has first hit. + return Ok(v.clone()); + } + // If we are still in the loop, it means that we are getting closer to gravity version + else { + result = Some(v); + } + } + + let [gravity_major, gravity_minor, ..] = gv.release() else { + return Err(Error::internal_err(format!("Cannot get MAJOR or/and MINOR version of python gravity version ({}). Something might be wrong with INSTANCE_PYTHON_VERSION.", &*gv))); + }; + + if let Some((v, mm)) = newest_in_minor { + if (gravity_major, gravity_minor) != mm { + return Ok(v); + } + } + + result + .ok_or(Error::internal_err( + "No python candidates found. This is a bug!", + )) + .map(ToOwned::to_owned) + } else { + Err(anyhow!( + " + × No solution found when resolving python: + ╰─▶ Because you require python {}, we can conclude that your requirements are unsatisfiable. + + All versions: \n{} + \n", + version_specifiers.iter().map(|s| s.to_string()).join(", "), + all_versions + .iter() + .enumerate() + .map(|(i, v)| format!( + "{}{}", + windmill_common::worker::pad_string(&v.0.to_string(), 11), + if (i + 1) % 5 == 0 { "\n" } else { "" } + )) + .collect::() + ) + .into()) + } + } + /// e.g.: `/tmp/windmill/cache/python_3xy` + pub(crate) fn to_cache_dir(&self) -> String { + use windmill_common::worker::ROOT_CACHE_DIR; + format!("{ROOT_CACHE_DIR}{}", self.to_cache_dir_top_level()) + } + + /// e.g.: `python_3_x_y` + pub fn to_cache_dir_top_level(&self) -> String { + format!("python_{}", self.to_string().replace(".", "_")) + } + + pub async fn gravitational_version( + job_id: &Uuid, + w_id: &str, + conn: Option, + ) -> Self { + let mut err = None; + let pyv = match INSTANCE_PYTHON_VERSION.read().await.clone() { + Some(v) => pep440_rs::Version::from_str(&v).unwrap_or_else(|_| { + let v = PyVAlias::default().into(); + err = Some(format!("\nCannot parse INSTANCE_PYTHON_VERSION ({:?}), fallback to latest_stable ({v:?})", *INSTANCE_PYTHON_VERSION)); + v + }), + // Use latest stable + None => PyVAlias::default().into(), + }; + + if let Some(msg) = err { + if let Some(conn) = conn { + append_logs(job_id, w_id, &msg, &conn).await; + } + tracing::error!(msg); + } + pyv.into() + } + + pub async fn list_available_python_versions() -> Vec { + match Self::list_available_python_versions_inner().await { + Ok(pyvs) => pyvs, + Err(e) => { + tracing::error!( + "Fallback to preconfigured aliases. Cannot list python versions due to this error: {e}" + ); + PyVAlias::all() + } + } + } + async fn list_available_python_versions_inner() -> anyhow::Result> { + lazy_static::lazy_static! { + static ref CACHED_VERSIONS: Arc>>> = Arc::new(RwLock::new(None)); + static ref LAST_CHECKED: Arc>> = Arc::new(RwLock::new(Utc::now())); + } + match ( + Utc::now().signed_duration_since(*LAST_CHECKED.read().await) > Duration::minutes(30), + CACHED_VERSIONS.read().await.clone(), + ) { + (false, Some(vs)) => return Ok(vs), + _ => {} + }; + + let output = { + #[cfg(windows)] + let uv_cmd = "uv"; + + #[cfg(unix)] + let uv_cmd = UV_PATH.as_str(); + + Command::new(uv_cmd) + .env_clear() + .envs(WIN_ENVS.to_vec()) + .args([ + "python", + "list", + "--all-versions", + "--output-format", + "json", + ]) + .stderr(Stdio::piped()) + .output() + .await? + }; + + // We want to skip all versions smaller then 3.10 + // Windmill is incompatible with 3.9 and older + let filter = pep440_rs::VersionSpecifier::from_version( + pep440_rs::Operator::GreaterThanEqual, + PyVAlias::Py310.into(), + )?; + + if output.status.success() { + let res = String::from_utf8(output.stdout)?; + tracing::error!("{}", &res); + let list = serde_json::from_str::>>(&res)? + .into_iter() + .filter_map(|e| { + if e.get("implementation").and_then(Value::as_str) == Some("pypy") { + None + } else { + Some( + e.get("version") + .and_then(Value::as_str) + .and_then(|s| pep440_rs::Version::from_str(s).ok()) + .map(PyV::from) + .ok_or(Error::internal_err("version is None")), + ) + } + }) + .collect::, Error>>()? + .into_iter() + .unique() + .sorted() + .filter(|pyv| filter.contains(&*pyv)) + .rev() + .collect_vec(); + + *LAST_CHECKED.write().await = Utc::now(); + CACHED_VERSIONS.write().await.replace(list.clone()); + + Ok(list) + } else { + // If the command failed, print the error + let stderr = String::from_utf8(output.stderr)?; + bail!( + "Cannot list python versions, is uv (0.5.19 and newer) installed? Err:\n{}", + stderr + ); + } + } + + /// Parse lockfile for assigned python version. + /// If not found returns 3.11 + pub fn parse_from_requirements>(requirements_lines: &[S]) -> Self { + Self::try_parse_from_requirements(requirements_lines).unwrap_or( + // If there is no assigned version in lockfile we automatically fallback to 3.11 + // In this case we have dependencies or other metadata, but no associated python version + // This is the case for old deployed scripts + PyVAlias::Py311.into(), + ) + } + + /// Parse lockfile for assigned python version. + /// If not found returns None + pub fn try_parse_from_requirements>(requirements_lines: &[S]) -> Option { + let parse_version = |s: &str| -> Option { + // Possible inputs: + // V2: + // # py: 3.11.0 or #py:3.11.0 or #py: 3.11.0 + // + // V1: + // # py311 or #py311 + let version_unparsed = s + .to_owned() + // Remove whitespaces. That leaves us with: + // V2: #py:3.11.0 + // V1: #py311 + // + // Remove # + // V2: py:3.11.0 + // V1: py311 + // + // Remove : + // V2: py3.11.0 + // V1: py311 + .replace([' ', '#', ':'], "") + // Remove "py" + // V2: 3.11.0 + // V1: 311 + .replace("py", ""); + + // We will support reading V1 syntax, but it will be overwritten next deploy + PyVAlias::try_from_v1(&version_unparsed) + .map(PyVAlias::into) + .or(pep440_rs::Version::from_str(&version_unparsed) + .ok() + .map(pep440_rs::Version::into)) + }; + let index = if requirements_lines.get(0).map_or(false, |line| { + line.as_ref() + .starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT) + }) { + 1 + } else { + 0 + }; + requirements_lines + .get(index) + .map(S::as_ref) + .and_then(parse_version) + } + + pub async fn get_python( + &self, + worker_name: &str, + job_id: &Uuid, + w_id: &str, + mem_peak: &mut i32, + conn: &Connection, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + ) -> windmill_common::error::Result { + let python_path = if let Some(python_path) = PYTHON_PATH.clone() { + python_path + } else if let Some(python_path) = self + .try_get_python( + &job_id, + mem_peak, + conn, + worker_name, + w_id, + occupancy_metrics, + ) + .await? + { + python_path + } else { + return Err(Error::ExecutionErr(format!( + "uv could not manage python path. Please manage it manually by setting PYTHON_PATH environment variable to your python binary path" + ))); + }; + Ok(python_path) + } + + pub async fn try_get_python( + &self, + job_id: &Uuid, + mem_peak: &mut i32, + // canceled_by: &mut Option, + conn: &Connection, + worker_name: &str, + w_id: &str, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + ) -> error::Result> { + // lazy_static::lazy_static! { + // static ref PYTHON_PATHS: Arc>> = Arc::new(RwLock::new(HashMap::new())); + // } + + let res = self + .get_python_inner(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) + .await; + + if let Err(ref e) = res { + tracing::error!( + "worker_name: {worker_name}, w_id: {w_id}, job_id: {job_id}\n + Error while getting python from uv, falling back to system python: {e:?}" + ); + append_logs( + job_id, + w_id, + format!( + "\nError while getting python from uv, falling back to system python: {e:?}" + ), + conn, + ) + .await; + } + res + } + async fn get_python_inner( + &self, + job_id: &Uuid, + mem_peak: &mut i32, + // canceled_by: &mut Option, + conn: &Connection, + worker_name: &str, + w_id: &str, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + ) -> error::Result> { + let py_path = self.find_python().await; + + // Runtime is not installed + if py_path.is_err() { + // Install it + if let Err(err) = self + .install_python(job_id, mem_peak, conn, worker_name, w_id, occupancy_metrics) + .await + { + tracing::error!("Cannot install python: {err}"); + return Err(err); + } else { + // Try to find one more time + let py_path = self.find_python().await; + + if let Err(err) = py_path { + tracing::error!("Cannot find python version {err}"); + return Err(err); + } + + // TODO: Cache the result + py_path + } + } else { + py_path + } + } + async fn install_python( + &self, + job_id: &Uuid, + mem_peak: &mut i32, + // canceled_by: &mut Option, + conn: &Connection, + worker_name: &str, + w_id: &str, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + ) -> error::Result<()> { + let v = self.to_string(); + append_logs(job_id, w_id, format!("\nINSTALLING PYTHON ({})", v), conn).await; + // Create dirs for newly installed python + // If we dont do this, NSJAIL will not be able to mount cache + // For the default version directory created during startup (main.rs) + DirBuilder::new() + .recursive(true) + .create(self.to_cache_dir()) + .await + .expect("could not create initial worker dir"); + + let logs = String::new(); + + #[cfg(windows)] + let uv_cmd = "uv"; + + #[cfg(unix)] + let uv_cmd = UV_PATH.as_str(); + + let mut child_cmd = Command::new(uv_cmd); + child_cmd + .env_clear() + .env("HOME", HOME_ENV.to_string()) + .env("PATH", PATH_ENV.to_string()) + .envs(PROXY_ENVS.clone()) + .args(["python", "install", &v, "--python-preference=only-managed"]) + // TODO: Do we need these? + .envs([("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR)]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + #[cfg(windows)] + { + child_cmd + .env("SystemRoot", crate::SYSTEM_ROOT.as_str()) + .env("USERPROFILE", crate::USERPROFILE_ENV.as_str()) + .env( + "TMP", + std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")), + ) + .env( + "LOCALAPPDATA", + std::env::var("LOCALAPPDATA") + .unwrap_or_else(|_| format!("{}\\AppData\\Local", HOME_ENV.as_str())), + ); + } + + let child_process = start_child_process(child_cmd, "uv").await?; + + append_logs(&job_id, &w_id, logs, conn).await; + handle_child( + job_id, + conn, + mem_peak, + &mut None, + child_process, + false, + worker_name, + &w_id, + "uv", + None, + false, + occupancy_metrics, + None, + ) + .await + } + async fn find_python(&self) -> error::Result> { + #[cfg(windows)] + let uv_cmd = "uv"; + + #[cfg(unix)] + let uv_cmd = UV_PATH.as_str(); + + let mut child_cmd = Command::new(uv_cmd); + + child_cmd.env_clear(); + + #[cfg(windows)] + { + child_cmd + .env("SystemRoot", crate::SYSTEM_ROOT.as_str()) + .env("USERPROFILE", crate::USERPROFILE_ENV.as_str()) + .env( + "TMP", + std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")), + ) + .env( + "LOCALAPPDATA", + std::env::var("LOCALAPPDATA") + .unwrap_or_else(|_| format!("{}\\AppData\\Local", HOME_ENV.as_str())), + ); + } + + let output = child_cmd + // .current_dir(job_dir) + .env("HOME", HOME_ENV.to_string()) + .env("PATH", PATH_ENV.to_string()) + .args([ + "python", + "find", + &self.to_string(), + "--system", + "--python-preference=only-managed", + ]) + .envs([ + ("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR), + ("UV_PYTHON_PREFERENCE", "only-managed"), + ]) + // .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + // Check if the command was successful + if output.status.success() { + // Convert the output to a String + let stdout = + String::from_utf8(output.stdout).expect("Failed to convert output to String"); + return Ok(Some(stdout.replace('\n', ""))); + } else { + // If the command failed, print the error + let stderr = + String::from_utf8(output.stderr).expect("Failed to convert error output to String"); + return Err(error::Error::FindPythonError(stderr)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unsafe helper for testing + fn pyv(value: &str) -> PyV { + pep440_rs::Version::from_str(value).unwrap().into() + } + + async fn assert_resolution( + instance_version: &str, + select_highest: bool, + specifiers: Vec<&str>, + available: Vec, + expected: PyV, + ) { + let resolved = PyV::resolve( + specifiers + .into_iter() + .map(|s| pep440_rs::VersionSpecifier::from_str(s).unwrap()) + .collect_vec(), + &Uuid::nil(), + "", + select_highest, + None, + Some(available), + Some(pyv(instance_version)), + ) + .await + .unwrap(); + assert_eq!(expected, resolved); + } + + #[tokio::test] + async fn test_python_resolution_1() { + assert_resolution( + "1.0", + false, + vec![], + vec![ + pyv("1.2.0"), + pyv("1.1.0"), + pyv("1.0.0"), + pyv("0.9.0"), // + ], + pyv("1.0.0"), // + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_2() { + assert_resolution( + "1.0.0", + false, + vec!["!=1.*"], + vec![ + pyv("1.2"), + pyv("1.1"), + pyv("1.0.2"), + pyv("1.0.1"), + pyv("1.0.0"), + pyv("0.9.4"), + pyv("0.9.3"), + pyv("0.9.2"), + ], + pyv("0.9.4"), // + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_3() { + assert_resolution( + "0.9", + false, + vec!["!=0.9.*"], + vec![ + pyv("1.2"), + pyv("1.1"), + pyv("1.0.2"), + pyv("1.0.1"), + pyv("1.0.0"), + pyv("0.9.4"), + pyv("0.9.3"), + pyv("0.9.2"), + pyv("0.8.2"), + pyv("0.8.1"), + pyv("0.8.0"), + ], + pyv("1.0.2"), // + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_4() { + assert_resolution( + "0.9", + false, + vec!["<=0.8.1"], + vec![pyv("1.0.0"), pyv("0.9.0"), pyv("0.8.1"), pyv("0.8.0")], + pyv("0.8.1"), // + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_5() { + assert_resolution( + "0.0.1", + false, + vec!["!=0.1.0"], + vec![pyv("2.1.0"), pyv("1.1.0"), pyv("0.1.0")], + pyv("1.1.0"), + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_6() { + assert_resolution( + "1.1.1", + false, + vec![], + vec![ + pyv("3.0.1"), + pyv("3.0.0"), + pyv("2.2.2"), + pyv("2.2.1"), + pyv("2.2.0"), + ], + pyv("2.2.2"), + ) + .await; + } + #[tokio::test] + async fn test_python_resolution_7() { + assert_resolution( + "2.2.1", + true, + vec![], + vec![ + pyv("3.0.1"), + pyv("3.0.0"), + pyv("2.2.2"), + pyv("2.2.1"), + pyv("2.2.0"), + ], + pyv("3.0.1"), + ) + .await; + } +} diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 2ca1539b85..342c53a301 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -29,8 +29,7 @@ use windmill_common::{ use windmill_common::bench::{BenchmarkInfo, BenchmarkIter}; use windmill_queue::{ - append_logs, get_queued_job, CanceledBy, JobCompleted, MiniPulledJob, - WrappedError, + append_logs, get_queued_job, CanceledBy, JobCompleted, MiniPulledJob, WrappedError, }; use serde_json::{json, value::RawValue}; @@ -44,9 +43,10 @@ use crate::{ common::{error_to_value, read_result, save_in_cache}, otel_ee::add_root_flow_job_to_otlp, worker_flow::update_flow_status_after_job_completion, - AuthedClient, JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, - UpdateFlow, INIT_SCRIPT_TAG, + JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, UpdateFlow, + INIT_SCRIPT_TAG, }; +use windmill_common::client::AuthedClient; async fn process_jc( jc: JobCompleted, @@ -273,11 +273,7 @@ pub fn start_background_processor( }) } -async fn send_job_completed( - job_completed_tx: JobCompletedSender, - jc: JobCompleted, - -) { +async fn send_job_completed(job_completed_tx: JobCompletedSender, jc: JobCompleted) { job_completed_tx .send_job(jc, true) .with_context(windmill_common::otel_ee::otel_ctx()) @@ -301,7 +297,6 @@ pub async fn process_result( ) -> error::Result { match result { Ok(result) => { - send_job_completed( job_completed_tx, JobCompleted { diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 9f0b783471..1a6a93ecca 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -19,9 +19,10 @@ use crate::{ read_result, start_child_process, OccupancyMetrics, }, handle_child::handle_child, - AuthedClient, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, RUST_CACHE_DIR, TZ_ENV, }; +use windmill_common::client::AuthedClient; #[cfg(windows)] use crate::SYSTEM_ROOT; diff --git a/backend/windmill-worker/src/snowflake_executor.rs b/backend/windmill-worker/src/snowflake_executor.rs index 2adf41ccba..8828aeb76b 100644 --- a/backend/windmill-worker/src/snowflake_executor.rs +++ b/backend/windmill-worker/src/snowflake_executor.rs @@ -26,7 +26,8 @@ use crate::common::{ }; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args; -use crate::{common::build_args_values, AuthedClient}; +use crate::common::build_args_values; +use windmill_common::client::AuthedClient; #[derive(Serialize)] struct Claims { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 235cf9472b..221650389a 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -11,6 +11,7 @@ use anyhow::anyhow; use futures::TryFutureExt; +use windmill_common::client::AuthedClient; use windmill_common::{ agent_workers::DECODED_AGENT_TOKEN, apps::AppScriptId, @@ -28,7 +29,7 @@ use windmill_common::{ #[cfg(feature = "enterprise")] use windmill_common::ee::LICENSE_KEY_VALID; -use anyhow::{Context, Result}; +use anyhow::Result; use const_format::concatcp; #[cfg(feature = "prometheus")] use prometheus::IntCounter; @@ -39,8 +40,7 @@ use windmill_common::METRICS_DEBUG_ENABLED; #[cfg(feature = "prometheus")] use windmill_common::METRICS_ENABLED; -use reqwest::{Body, Response}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use sqlx::types::Json; use std::{ collections::HashMap, @@ -134,7 +134,10 @@ use crate::java_executor::{handle_java_job, JobHandlerInput as JobHandlerInputJa use crate::php_executor::handle_php_job; #[cfg(feature = "python")] -use crate::python_executor::{handle_python_job, PyVersion}; +use crate::{ + python_executor::handle_python_job, + python_versions::{PyV, PyVAlias}, +}; #[cfg(feature = "python")] use crate::ansible_executor::handle_ansible_job; @@ -363,10 +366,26 @@ lazy_static::lazy_static! { } +type Envs = Vec<(String, String)>; + #[cfg(windows)] lazy_static::lazy_static! { pub static ref SYSTEM_ROOT: String = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); pub static ref USERPROFILE_ENV: String = std::env::var("USERPROFILE").unwrap_or_else(|_| "/tmp".to_string()); + static ref TMP: String = std::env::var("TMP").unwrap_or_else(|_| "/tmp".to_string()); + static ref LOCALAPPDATA: String = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| format!("{}\\AppData\\Local", HOME_ENV.as_str())); + pub static ref WIN_ENVS: Envs = vec![ + ("SystemRoot".into(), SYSTEM_ROOT.clone()), + ("USERPROFILE".into(), USERPROFILE_ENV.clone()), + ("TMP".into(), TMP.clone()), + ("LOCALAPPDATA".into(), LOCALAPPDATA.clone()) + ]; + +} + +#[cfg(not(windows))] +lazy_static::lazy_static! { + pub static ref WIN_ENVS: Envs = vec![]; } //only matter if CLOUD_HOSTED @@ -374,200 +393,6 @@ pub const MAX_RESULT_SIZE: usize = 1024 * 1024 * 2; // 2MB pub const INIT_SCRIPT_TAG: &str = "init_script"; -#[derive(Clone)] -pub struct AuthedClient { - pub base_internal_url: String, - pub workspace: String, - pub token: String, - pub force_client: Option, -} - -impl AuthedClient { - pub async fn get(&self, url: &str, query: Vec<(&str, String)>) -> anyhow::Result { - self.force_client - .as_ref() - .unwrap_or(&HTTP_CLIENT) - .get(url) - .query(&query) - .header( - reqwest::header::ACCEPT, - reqwest::header::HeaderValue::from_static("application/json"), - ) - .header( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token))?, - ) - .send() - .await - .context(format!( - "Executing request from authed http client to {url} with query {query:?}", - )) - } - - pub async fn get_id_token(&self, audience: &str) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/oidc/token/{}", - self.base_internal_url, self.workspace, audience - ); - let response = self.get(&url, vec![]).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding oidc token as json string")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn get_resource_value(&self, path: &str) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/resources/get_value/{}", - self.base_internal_url, self.workspace, path - ); - let response = self.get(&url, vec![]).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding resource value as json")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn get_variable_value(&self, path: &str) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/variables/get_value/{}", - self.base_internal_url, self.workspace, path - ); - let response = self.get(&url, vec![]).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding variable value as json")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn get_resource_value_interpolated( - &self, - path: &str, - job_id: Option, - ) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/resources/get_value_interpolated/{}", - self.base_internal_url, self.workspace, path - ); - let mut query = Vec::with_capacity(1usize); - if let Some(v) = &job_id { - query.push(("job_id", v.to_string())); - } - let response = self.get(&url, query).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding interpolated resource value as json")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn get_completed_job_result( - &self, - path: &str, - json_path: Option, - ) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/jobs_u/completed/get_result/{}", - self.base_internal_url, self.workspace, path - ); - let query = if let Some(json_path) = json_path { - vec![("json_path", json_path)] - } else { - vec![] - }; - let response = self.get(&url, query).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding completed job result as json")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn get_result_by_id( - &self, - flow_job_id: &str, - node_id: &str, - json_path: Option, - ) -> anyhow::Result { - let url = format!( - "{}/api/w/{}/jobs/result_by_id/{}/{}", - self.base_internal_url, self.workspace, flow_job_id, node_id - ); - let query = if let Some(json_path) = json_path { - vec![("json_path", json_path)] - } else { - vec![] - }; - let response = self.get(&url, query).await?; - match response.status().as_u16() { - 200u16 => Ok(response - .json::() - .await - .context("decoding result by id as json")?), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())), - } - } - - pub async fn upload_s3_file( - &self, - workspace_id: &str, - object_key: String, - storage: Option, - body: S, - ) -> error::Result<()> - where - S: futures::stream::TryStream + Send + 'static, - S::Error: Into>, - bytes::Bytes: From, - { - let mut query = vec![("file_key", object_key)]; - if let Some(storage) = storage { - query.push(("storage", storage)); - } - let response = self - .force_client - .as_ref() - .unwrap_or(&HTTP_CLIENT) - .post(format!( - "{}/api/w/{}/job_helpers/upload_s3_file", - self.base_internal_url, workspace_id - )) - .query(&query) - .header( - reqwest::header::ACCEPT, - reqwest::header::HeaderValue::from_static("application/json"), - ) - .header( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token)) - .map_err(|e| error::Error::BadConfig(e.to_string()))?, - ) - .body(Body::wrap_stream(body)) - .send() - .await - .context(format!("Sent upload_s3_file request",)) - .map_err(error::Error::from)?; - - match response.status().as_u16() { - 200u16 => Ok(()), - _ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default()))?, - } - } -} - #[derive(Clone)] pub struct SameWorkerSender(pub Sender, pub Arc); @@ -827,9 +652,9 @@ pub async fn run_worker( worker_dir.clone(), ); tokio::spawn(async move { - if let Err(e) = PyVersion::from_instance_version(&Uuid::nil(), "", &conn) + if let Err(e) = PyV::gravitational_version(&Uuid::nil(), "", Some(conn.clone())) .await - .get_python(&Uuid::nil(), &mut 0, &conn, &worker_name, "", &mut None) + .try_get_python(&Uuid::nil(), &mut 0, &conn, &worker_name, "", &mut None) .await { tracing::error!( @@ -839,8 +664,8 @@ pub async fn run_worker( "Cannot preinstall or find Instance Python version to worker: {e}"// ); } - if let Err(e) = PyVersion::Py311 - .get_python(&Uuid::nil(), &mut 0, &conn, &worker_name, "", &mut None) + if let Err(e) = PyV::from(PyVAlias::Py311) + .try_get_python(&Uuid::nil(), &mut 0, &conn, &worker_name, "", &mut None) .await { tracing::error!( diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0af2bfcc72..6c0b133828 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -15,8 +15,7 @@ use crate::common::{cached_result_path, save_in_cache}; use crate::js_eval::{eval_timeout, IdContext}; use crate::worker_utils::get_tag_and_concurrency; use crate::{ - AuthedClient, JobCompletedSender, PreviousResult, SameWorkerSender, SendResult, UpdateFlow, - KEEP_JOB_DIR, + JobCompletedSender, PreviousResult, SameWorkerSender, SendResult, UpdateFlow, KEEP_JOB_DIR, }; use anyhow::Context; use futures::TryFutureExt; @@ -32,6 +31,7 @@ use windmill_common::auth::JobPerms; #[cfg(feature = "benchmark")] use windmill_common::bench::BenchmarkIter; use windmill_common::cache::{self, RawData}; +use windmill_common::client::AuthedClient; use windmill_common::db::Authed; use windmill_common::flow_status::{ ApprovalConditions, FlowStatusModuleWParent, Iterator as FlowIterator, JobResult, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 7744ef341b..6710f718c4 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -47,7 +47,7 @@ use crate::java_executor::resolve; use crate::php_executor::{composer_install, parse_php_imports}; #[cfg(feature = "python")] use crate::python_executor::{ - create_dependencies_dir, handle_python_reqs, uv_pip_compile, PyVersion, + create_dependencies_dir, handle_python_reqs, split_requirements, uv_pip_compile, }; #[cfg(feature = "rust")] use crate::rust_executor::generate_cargo_lockfile; @@ -1897,26 +1897,13 @@ async fn python_dep( w_id: &str, worker_dir: &str, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, - annotated_pyv_numeric: Option, + py_version: crate::PyV, annotations: PythonAnnotations, ) -> std::result::Result { + use crate::python_executor::split_requirements; + create_dependencies_dir(job_dir).await; - /* - Unlike `handle_python_deps` which we use for running scripts (deployed and drafts) - This one used specifically for deploying scripts - So we can get final_version right away and include in lockfile - And the precendence is following: - - 1. Annotation version - 2. Instance version - 3. Latest Stable - */ - - let final_version = annotated_pyv_numeric - .and_then(|pyv| PyVersion::from_numeric(pyv)) - .unwrap_or(PyVersion::from_instance_version(job_id, w_id, &db.into()).await); - let req: std::result::Result = uv_pip_compile( job_id, &reqs, @@ -1927,14 +1914,15 @@ async fn python_dep( worker_name, w_id, occupancy_metrics, - final_version, + py_version, annotations.no_cache, ) .await; // install the dependencies to pre-fill the cache if let Ok(req) = req.as_ref() { let r = handle_python_reqs( - req.split("\n").filter(|x| !x.starts_with("--")).collect(), + split_requirements(req), + // req.split("\n").filter(|x| !x.starts_with("--")).collect(), job_id, w_id, mem_peak, @@ -1944,7 +1932,8 @@ async fn python_dep( job_dir, worker_dir, occupancy_metrics, - final_version, + // final_version, + crate::PyVAlias::default().into(), ) .await; @@ -1975,13 +1964,12 @@ async fn ansible_dep( ) -> std::result::Result { use windmill_parser_yaml::add_versions_to_requirements_yaml; - use crate::{ - ansible_executor::{ + use crate::ansible_executor::{ create_ansible_cfg, get_collection_locks, get_git_ssh_cmd, get_role_locks, install_galaxy_collections, - }, - AuthedClient, - }; + }; + use windmill_common::client::AuthedClient; + let python_lockfile = python_dep( reqs.python_reqs.join("\n").to_string(), @@ -1994,7 +1982,7 @@ async fn ansible_dep( w_id, worker_dir, &mut Some(occupancy_metrics), - None, + crate::PyV::gravitational_version(job_id, w_id, Some(db.clone().into())).await, PythonAnnotations::default(), ) .await?; @@ -2104,31 +2092,44 @@ async fn capture_dependency_job( )); #[cfg(feature = "python")] { - let anns = PythonAnnotations::parse(job_raw_code); - let mut annotated_pyv_numeric = None; - - let reqs = if raw_deps { + // Manually assigned version from requirements.txt + // let assigned_py_version; + let (reqs, py_version) = if raw_deps { // `wmill script generate-metadata` // should also respect annotated pyversion // can be annotated in script itself // or in requirements.txt if present - annotated_pyv_numeric = - PyVersion::from_py_annotations(anns).map(|v| v.to_numeric()); - job_raw_code.to_string() - } else { - let mut already_visited = vec![]; - windmill_parser_py_imports::parse_python_imports( - job_raw_code, - &w_id, - script_path, - &db, - &mut already_visited, - &mut annotated_pyv_numeric, + ( + job_raw_code.to_owned(), + crate::PyV::parse_from_requirements(&split_requirements(job_raw_code)), + ) + } else { + let mut version_specifiers = vec![]; + let PythonAnnotations { py_select_latest, .. } = + PythonAnnotations::parse(job_raw_code); + ( + windmill_parser_py_imports::parse_python_imports( + job_raw_code, + &w_id, + script_path, + &db, + &mut version_specifiers, + ) + .await? + .0 + .join("\n"), + crate::PyV::resolve( + version_specifiers, + job_id, + w_id, + py_select_latest, + Some(db.clone().into()), + None, + None, + ) + .await?, ) - .await? - .0 - .join("\n") }; python_dep( @@ -2142,8 +2143,8 @@ async fn capture_dependency_job( w_id, worker_dir, &mut Some(occupancy_metrics), - annotated_pyv_numeric, - anns, + py_version, + PythonAnnotations::parse(job_raw_code), ) .await .map(|res| { diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 231dc75592..a03010948b 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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.491.5"; +export const VERSION = "v1.492.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/main.ts b/cli/main.ts index c97b4479f7..e87c8b14a5 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -63,7 +63,7 @@ export { // } // }); -export const VERSION = "1.491.5"; +export const VERSION = "1.492.1"; const command = new Command() .name("wmill") diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000000..63f35ab820 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,241 @@ +# Svelte 5 Best Practices + +This guide outlines best practices for developing with Svelte 5, incorporating the new Runes API and other modern Svelte features. These rules MUST NOT be applied on svelte 4 files unless explicitly asked to do so. + +## Reactivity with Runes + +Svelte 5 introduces Runes for more explicit and flexible reactivity. + +1. **Embrace Runes for State Management**: + + - Use `$state` for reactive local component state. + + ```svelte + + + + ``` + + - Use `$derived` for computed values based on other reactive state. + + ```svelte + + +

{count} * 2 = {doubled}

+ ``` + + - Use `$effect` for side effects that need to run when reactive values change (e.g., logging, manual DOM manipulation, data fetching). Remember `$effect` does not run on the server. + + ```svelte + + ``` + +2. **Props with `$props`**: + + - Declare component props using `$props()`. This offers better clarity and flexibility compared to `export let`. + + ```svelte + + +

Name: {name}

+

Age: {age}

+ ``` + + - For bindable props, use `$bindable`. + + ```svelte + + + + ``` + +## Event Handling + +- **Use direct event attributes**: Svelte 5 moves away from `on:` directives for DOM events. + - **Do**: `` + - **Don't**: `` +- **For component events, prefer callback props**: Instead of `createEventDispatcher`, pass functions as props. + + ```svelte + + + +

Message from child: {message}

+ + + + + ``` + +## Snippets for Content Projection + +- **Use `{#snippet ...}` and `{@render ...}` instead of slots**: Snippets are more powerful and flexible. + + ```svelte + + + + + {#snippet title()} + My Awesome Title + {/snippet} + {#snippet content()} +

Some interesting content here.

+ {/snippet} +
+ + + + +
+
{@render title()}
+
{@render content()}
+
+ ``` + +- Default content is passed via the `children` prop (which is a snippet). + ```svelte + + +
+ {@render children?.()} +
+ ``` + +## Component Design + +1. **Create Small, Reusable Components**: Break down complex UIs into smaller, focused components. Each component should have a single responsibility. This also aids performance by limiting the scope of reactivity updates. +2. **Descriptive Naming**: Use clear and descriptive names for variables, functions, and components. +3. **Minimize Logic in Components**: Move complex business logic to utility functions or services. Keep components focused on presentation and interaction. + +## State Management (Stores) + +1. **Segment Stores**: Avoid a single global store. Create multiple stores, each responsible for a specific piece of global state (e.g., `userStore.js`, `themeStore.js`). This can help limit reactivity updates to only the parts of the UI that depend on specific state segments. +2. **Use Custom Stores for Complex Logic**: For stores with related methods, create custom stores. + + ```javascript + // counterStore.js + import { writable } from 'svelte/store' + + function createCounter() { + const { subscribe, set, update } = writable(0) + + return { + subscribe, + increment: () => update((n) => n + 1), + decrement: () => update((n) => n - 1), + reset: () => set(0) + } + } + export const counter = createCounter() + ``` + +3. **Use Context API for Localized State**: For state shared within a component subtree, consider Svelte's context API (`setContext`, `getContext`) instead of global stores when the state doesn't need to be truly global. + +## Performance Optimizations (Svelte 5) + +When generating Svelte 5 code, prioritize frontend performance by applying the following principles: + +### General Svelte 5 Principles + +- **Leverage the Compiler:** Trust Svelte's compiler to generate optimized JavaScript. Avoid manual DOM manipulation (`document.querySelector`, etc.) unless absolutely necessary for integrating third-party libraries that lack Svelte adapters. +- **Keep Components Small and Focused:** Reinforcing from Component Design, smaller components lead to less complex reactivity graphs and more targeted, efficient updates. + +### Reactivity & State Management + +- **Optimize Computations with `$derived`:** Always use `$derived` for computed values that depend on other state. This ensures the computation only runs when its specific dependencies change, avoiding unnecessary work compared to recomputing derived values in `$effect` or less efficient methods. +- **Minimize `$effect` Usage:** Use `$effect` sparingly and only for true side effects that interact with the outside world or non-Svelte state. Avoid putting complex logic or state updates _within_ an `$effect` unless those updates are explicitly intended as a reaction to external changes or non-Svelte state. Excessive or complex effects can impact rendering performance. +- **Structure State for Fine-Grained Updates:** Design your `$state` objects or variables such that updates affect only the necessary parts of the UI. Avoid putting too much unrelated state into a single large object that gets frequently updated, as this can potentially trigger broader updates than necessary. Consider normalizing complex, nested state. + +### List Rendering (`{#each}`) + +- **Mandate `key` Attribute:** Always use a `key` attribute (`{#each items as item (item.id)}`) that refers to a unique, stable identifier for each item in a list. This is critical for allowing Svelte to efficiently update, reorder, add, or remove list items without destroying and re-creating unnecessary DOM elements and component instances. + +### Component Loading & Bundling + +- **Implement Lazy Loading/Code Splitting:** For routes, components, or modules that are not immediately needed on page load, use dynamic imports (`import(...)`) to split the code bundle. SvelteKit handles this automatically for routes, but it can be applied manually to components using helper patterns if needed. +- **Be Mindful of Third-Party Libraries:** When incorporating external libraries, import only the necessary functions or components to minimize the final bundle size. Prefer libraries designed to be tree-shakeable. + +### Rendering & DOM + +- **Use CSS for Animations/Transitions:** Prefer CSS animations or transitions where possible for performance. Svelte's built-in `transition:` directive is also highly optimized and should be used for complex state-driven transitions, but simple cases can often use plain CSS. +- **Optimize Image Loading:** Implement best practices for images: use optimized formats (WebP, AVIF), lazy loading (`loading="lazy"`), and responsive images (``, `srcset`) to avoid loading unnecessarily large images. + +### Server-Side Rendering (SSR) & Hydration + +- **Ensure SSR Compatibility:** Write components that can be rendered on the server for faster initial page loads. Avoid relying on browser-specific APIs (like `window` or `document`) in the main ` + +
+ Hello +
+ ``` + +5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11a4b279fe..899306ebf0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.491.5", + "version": "1.492.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.491.5", + "version": "1.492.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 2d6dd746ca..51c7820491 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.491.5", + "version": "1.492.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/CenteredModal.svelte b/frontend/src/lib/components/CenteredModal.svelte index 3548a20a8f..e3206b6c92 100644 --- a/frontend/src/lib/components/CenteredModal.svelte +++ b/frontend/src/lib/components/CenteredModal.svelte @@ -1,6 +1,6 @@ -
+
{#if link} Learn more diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 9e72afec65..7a91c3b34e 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -47,10 +47,11 @@ parseTypescriptDeps } from '$lib/relative_imports' import Tooltip from './Tooltip.svelte' - import type { ScheduleTrigger, TriggerContext } from './triggers' + import type { TriggerContext } from './triggers' import { workspaceAIClients } from './copilot/lib' import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker' import type { PickableProperties } from './flows/previousResults' + import { Triggers } from './triggers/triggers.svelte' $: token = $page.url.searchParams.get('wm_token') ?? undefined $: workspace = $page.url.searchParams.get('workspace') ?? undefined $: themeDarkRaw = $page.url.searchParams.get('activeColorTheme') @@ -493,20 +494,13 @@ const testStepStore = writable>({}) const selectedIdStore = writable('settings-metadata') - const selectedTriggerStore = writable< - 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll' - >('webhooks') - const primaryScheduleStore = writable(undefined) const triggersCount = writable(undefined) setContext('TriggerContext', { - primarySchedule: primaryScheduleStore, - selectedTrigger: selectedTriggerStore, triggersCount: triggersCount, simplifiedPoll: writable(false), - defaultValues: writable(undefined), - captureOn: writable(undefined), - showCaptureHint: writable(undefined) + showCaptureHint: writable(undefined), + triggersState: new Triggers() }) setContext('FlowEditorContext', { selectedId: selectedIdStore, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index aa39489723..c03cfbf597 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -35,6 +35,7 @@ import PdfViewer from './display/PdfViewer.svelte' import type { DisplayResultUi } from './custom_ui' import { getContext, hasContext, createEventDispatcher, onDestroy } from 'svelte' + import { toJsonStr } from '$lib/utils' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' export let result: any @@ -269,15 +270,6 @@ let jsonViewer: Drawer let s3FileViewer: S3FilePicker - function toJsonStr(result: any) { - try { - // console.log(result) - return JSON.stringify(result ?? null, null, 4) ?? 'null' - } catch (e) { - return 'error stringifying object: ' + e.toString() - } - } - function checkIfHasBigInt(result: any) { if (typeof result === 'number' && Number.isInteger(result) && !Number.isSafeInteger(result)) { return true diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index ea5920357d..bfcdf24c85 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -26,7 +26,7 @@ } -
+
{#if customUi?.disableDownload !== true} {#if open && !hidePopup} -
-
- -
+
+ {#if customMenu} + + {:else} +
+ +
+ {/if}
{/if} diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 1566e146d0..3c657d7678 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -4,10 +4,15 @@ import { twMerge } from 'tailwind-merge' import type { MenubarMenuElements } from '@melt-ui/svelte' import type { Item } from '$lib/utils' - export let items: Item[] | (() => Item[]) | (() => Promise) = [] - export let meltItem: MenubarMenuElements['item'] - let computedItems: Item[] | undefined = undefined + interface Props { + items?: Item[] | (() => Item[]) | (() => Promise) + meltItem: MenubarMenuElements['item'] + } + + let { items = [], meltItem }: Props = $props() + + let computedItems: Item[] | undefined = $state(undefined) async function computeItems() { if (typeof items === 'function') { computedItems = ((await items()) ?? []).filter((item) => !item.hide) @@ -38,9 +43,12 @@ item={meltItem} > {#if item.icon} - + {/if} - {item.displayName} +

+ {item.displayName} +

+ {@render item.extra?.()} {/each}
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 5b3f48aa0a..35c4576251 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -217,7 +217,6 @@ console.log('uri', uri) - function computeUri(filePath: string, scriptLang: string | undefined) { let file if (filePath.includes('.')) { @@ -265,7 +264,11 @@ } } + let valueAfterDispose: string | undefined = undefined export function getCode(): string { + if (valueAfterDispose != undefined) { + return valueAfterDispose + } return editor?.getValue() ?? '' } @@ -1489,6 +1492,7 @@ onDestroy(() => { console.log('destroying editor') + valueAfterDispose = getCode() destroyed = true disposeMethod && disposeMethod() websocketInterval && clearInterval(websocketInterval) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8854901c0f..b28eb4bcec 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1,7 +1,6 @@ @@ -1263,6 +1248,16 @@ currentValue={$flowStore} /> + t.draftConfig)} + isFlow={true} + on:canceled={() => { + draftTriggersModalOpen = false + }} + on:confirmed={handleDraftTriggersConfirmed} +/> + {#key renderCount} {#if !$userStore?.operator} @@ -1332,7 +1327,9 @@
{#if !hideHelpButton} diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 958dcb9808..6e75df9270 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -17,11 +17,18 @@ import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte' import { sendUserToast } from '$lib/toast' import ConfirmButton from './ConfirmButton.svelte' - import { IndexSearchService, SettingService, TeamsService } from '$lib/gen' + import { + ConfigService, + IndexSearchService, + SettingService, + TeamsService, + type ListAvailablePythonVersionsResponse + } from '$lib/gen' import { Button, SecondsInput, Skeleton } from './common' import Password from './Password.svelte' import { classNames } from '$lib/utils' import Popover from './Popover.svelte' + import PopoverMelt from './meltComponents/Popover.svelte' import Toggle from './Toggle.svelte' import type { Writable } from 'svelte/store' import { createEventDispatcher } from 'svelte' @@ -30,6 +37,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import SimpleEditor from './SimpleEditor.svelte' + import LoadingIcon from './apps/svelte-select/lib/LoadingIcon.svelte' import TeamSelector from './TeamSelector.svelte' import ChannelSelector from './ChannelSelector.svelte' @@ -39,7 +47,10 @@ export let loading = true const dispatch = createEventDispatcher() - if (setting.fieldType == 'select' && $values[setting.key] == undefined) { + if ( + (setting.fieldType == 'select' || setting.fieldType == 'select_python') && + $values[setting.key] == undefined + ) { $values[setting.key] = 'default' } @@ -124,6 +135,24 @@ } } + let pythonAvailableVersions: ListAvailablePythonVersionsResponse = [] + + let isPyFetching = false + async function fetch_available_python_versions() { + if (isPyFetching) return + isPyFetching = true + try { + pythonAvailableVersions = await ConfigService.listAvailablePythonVersions() + } catch (error) { + console.error('Error fetching python versions:', error) + } finally { + isPyFetching = false + } + } + if (setting.fieldType == 'select_python') { + fetch_available_python_versions() + } + async function fetchTeams() { if (isFetching) return isFetching = true @@ -193,6 +222,66 @@ {/each}
+ {:else if setting.fieldType == 'select_python'} +
+ + + + + {#each setting.select_items ?? [] as item} + + {/each} + + + {#if setting.select_items?.some((e) => e.label == $values[setting.key] || e.value == $values[setting.key])} + + {:else} + + {/if} + + + {#if isPyFetching} +
+ +
+ {:else} + + {#each pythonAvailableVersions as item} + + {/each} + + {/if} +
+
+
+
{:else}
@@ -88,7 +90,7 @@ class={twMerge( 'ml-2 font-medium duration-50 select-none', bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary', - size === 'xs' ? 'text-xs' : 'text-sm', + size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm', textClass )} style={textStyle} diff --git a/frontend/src/lib/components/TooltipInner.svelte b/frontend/src/lib/components/TooltipInner.svelte new file mode 100644 index 0000000000..af4c56ac70 --- /dev/null +++ b/frontend/src/lib/components/TooltipInner.svelte @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/lib/components/apps/components/buttons/AppButton.svelte b/frontend/src/lib/components/apps/components/buttons/AppButton.svelte index b74ae4cac6..e237e57e64 100644 --- a/frontend/src/lib/components/apps/components/buttons/AppButton.svelte +++ b/frontend/src/lib/components/apps/components/buttons/AppButton.svelte @@ -233,7 +233,8 @@ css?.button?.class ?? '', isMenuItem ? 'flex items-center justify-start' : '', isMenuItem ? '!border-0' : '', - 'wm-button' + 'wm-button', + `wm-button-${resolvedConfig.color}` )} variant={isMenuItem ? 'border' : 'contained'} style={css?.button?.style} @@ -241,7 +242,8 @@ css?.container?.class ?? '', resolvedConfig.fillContainer ? 'w-full h-full' : '', isMenuItem ? 'w-full' : '', - 'wm-button-container' + 'wm-button-container', + `wm-button-container-${resolvedConfig.color}` )} wrapperStyle={css?.container?.style} disabled={resolvedConfig.disabled} diff --git a/frontend/src/lib/components/apps/components/display/AppAlert.svelte b/frontend/src/lib/components/apps/components/display/AppAlert.svelte index 20fc60ba03..24dfa9ea51 100644 --- a/frontend/src/lib/components/apps/components/display/AppAlert.svelte +++ b/frontend/src/lib/components/apps/components/display/AppAlert.svelte @@ -10,6 +10,7 @@ import InitializeComponent from '../helpers/InitializeComponent.svelte' import { Alert } from '$lib/components/common' import AlignWrapper from '../helpers/AlignWrapper.svelte' + import { appendClass } from '../../editor/componentsPanel/cssUtils' export let id: string export let configuration: RichConfigurations @@ -63,13 +64,13 @@ tooltip={resolvedConfig.tooltip} size={resolvedConfig.size} collapsible={resolvedConfig.collapsible} - bgClass={css?.background?.class} + bgClass={appendClass(css?.background?.class, 'wm-alert-card-background')} bgStyle={css?.background?.style} - iconClass={css?.icon?.class} + iconClass={appendClass(css?.icon?.class, 'wm-alert-card-icon')} iconStyle={css?.icon?.style} - titleClass={css?.title?.class} + titleClass={appendClass(css?.title?.class, 'wm-alert-card-title')} titleStyle={css?.title?.style} - descriptionClass={css?.description?.class} + descriptionClass={appendClass(css?.description?.class, 'wm-alert-card-description')} descriptionStyle={css?.description?.style} isCollapsed={resolvedConfig.initiallyCollapsed} > diff --git a/frontend/src/lib/components/apps/components/layout/AppModal.svelte b/frontend/src/lib/components/apps/components/layout/AppModal.svelte index 3000afdedf..15f3eca206 100644 --- a/frontend/src/lib/components/apps/components/layout/AppModal.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppModal.svelte @@ -179,7 +179,7 @@ >
{ e?.stopPropagation() if (!$connectingInput.opened) { diff --git a/frontend/src/lib/components/apps/editor/component/components.ts b/frontend/src/lib/components/apps/editor/component/components.ts index 2ff364f44a..27d0bfc3bf 100644 --- a/frontend/src/lib/components/apps/editor/component/components.ts +++ b/frontend/src/lib/components/apps/editor/component/components.ts @@ -3410,7 +3410,8 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm' customCss: { button: { class: '', style: '' }, buttonContainer: { class: '', style: '' }, - popup: { class: '', style: '' } + popup: { class: '', style: '' }, + container: { class: '', style: '' } }, initialData: { horizontalAlignment: 'center', diff --git a/frontend/src/lib/components/apps/editor/componentsPanel/CssHelperPanel.svelte b/frontend/src/lib/components/apps/editor/componentsPanel/CssHelperPanel.svelte index fda2b9338a..05e6380238 100644 --- a/frontend/src/lib/components/apps/editor/componentsPanel/CssHelperPanel.svelte +++ b/frontend/src/lib/components/apps/editor/componentsPanel/CssHelperPanel.svelte @@ -20,20 +20,38 @@ const dispatch = createEventDispatcher() interface CustomCSSEntry { - type: CustomCSSType + type?: CustomCSSType name: string icon: any - ids: { id: string; forceStyle: boolean; forceClass: boolean }[] + ids?: { id: string; forceStyle: boolean; forceClass: boolean }[] + description?: string + order?: number } const { app } = getContext('AppViewerContext') + const descriptions = { + buttoncomponent: + 'The button component also has additional color specific classes to allow customizing classes by color. wm-button-wrapper-blue, wm-button-container-blue, ...' + } const entries: CustomCSSEntry[] = [ + { + name: 'Dark Mode', + icon: LayoutDashboardIcon, + description: + 'When in dark mode, the entire document has the .dark class applied to it. You can apply selective styling by using the .dark class: e.g. .dark .my-element { color: white; }', + order: 3 + }, { type: 'app', name: 'App', icon: LayoutDashboardIcon, - ids: ['viewer', 'grid', 'component'].map((id) => ({ id, forceStyle: true, forceClass: true })) + ids: ['viewer', 'grid', 'component'].map((id) => ({ + id, + forceStyle: true, + forceClass: true + })), + order: 2 }, { type: 'quillcomponent', @@ -51,11 +69,12 @@ id, forceStyle: v?.style != undefined, forceClass: v?.['class'] != undefined - })) + })), + description: descriptions[type as keyof typeof descriptions] })) ] - entries.sort((a, b) => a.name.localeCompare(b.name)) + entries.sort((a, b) => (b.order ?? 0) - (a.order ?? 0) + a.name.localeCompare(b.name)) let search = '' @@ -66,15 +85,15 @@
{#each search != '' ? entries.filter((x) => x.name .toLowerCase() - .includes(search.toLowerCase())) : entries as { type, name, icon, ids } (name + type)} - {#if ids.length > 0} + .includes(search.toLowerCase())) : entries as { type, name, icon, ids, description } (name + type)} + {#if description || (ids && ids.length > 0)} { if ($app.css != undefined) { - if (e.detail && $app.css[type] == undefined) { - $app.css[type] = Object.fromEntries(ids.map(({ id }) => [id, {}])) + if (type && e.detail && $app.css[type] == undefined) { + $app.css[type] = Object.fromEntries((ids ?? []).map(({ id }) => [id, {}])) } } }} @@ -85,115 +104,120 @@ {name}
-
- {#each customisationByComponent.filter( (c) => c.components.includes(type) ) as customisation (customisation.components.join('-'))} - {#if customisation.link} - -
- See documentation - -
-
- {/if} - - - {#if customisation.selectors.length > 0} - - Selectors ({customisation.selectors.length}) - - {/if} - {#if customisation.variables.length > 0} - -
- Variables ({customisation.variables.length}) + {#if description} +
{description}
+ {/if} + {#if type} +
+ {#each customisationByComponent.filter( (c) => c.components.includes(type) ) as customisation (customisation.components.join('-'))} + {#if customisation.link} + +
+ See documentation +
- +
{/if} -
- - - - - Selector - Comment - - - - {#each customisation.selectors as { selector, comment }} - - - {selector} - - - {#if comment} -
{comment}
- {/if} -
- - - -
- {/each} -
-
- - - - - Variable - Default value - Comment - - - - {#each customisation.variables as { variable, value, comment }} - - - {variable} - - - {value} - - - {#if comment} -
{comment}
- {/if} -
- - - -
- {/each} -
-
-
- - {/each} -
+ + + {#if customisation.selectors.length > 0} + + Selectors ({customisation.selectors.length}) + + {/if} + {#if customisation.variables.length > 0} + +
+ Variables ({customisation.variables.length}) +
+
+ {/if} +
+ + + + + Selector + Comment + + + + {#each customisation.selectors as { selector, comment }} + + + {selector} + + + {#if comment} +
{comment}
+ {/if} +
+ + + +
+ {/each} +
+
+ + + + + Variable + Default value + Comment + + + + {#each customisation.variables as { variable, value, comment }} + + + {variable} + + + {value} + + + {#if comment} +
{comment}
+ {/if} +
+ + + +
+ {/each} +
+
+
+
+ {/each} +
+ {/if} {/if} {/each} diff --git a/frontend/src/lib/components/apps/editor/componentsPanel/cssUtils.ts b/frontend/src/lib/components/apps/editor/componentsPanel/cssUtils.ts index 6fc8344295..4634959ac0 100644 --- a/frontend/src/lib/components/apps/editor/componentsPanel/cssUtils.ts +++ b/frontend/src/lib/components/apps/editor/componentsPanel/cssUtils.ts @@ -185,7 +185,11 @@ export const customisationByComponent: Customisation[] = [ components: ['modalcomponent'], selectors: [ { selector: '.wm-modal', comment: 'main modal element', customCssKey: 'popup' }, - { selector: '.wm-modal-button', comment: 'button to open modal', customCssKey: 'button' }, + { + selector: '.wm-modal-container', + comment: 'container for modal', + customCssKey: 'container' + }, { selector: '.wm-modal-button-container', comment: 'container for button to open modal', @@ -826,6 +830,26 @@ export const customisationByComponent: Customisation[] = [ selector: 'wm-alert-card-container', comment: 'Alert container', customCssKey: 'container' + }, + { + selector: 'wm-alert-card-background', + comment: 'Alert background', + customCssKey: 'background' + }, + { + selector: 'wm-alert-card-icon', + comment: 'Alert icon', + customCssKey: 'icon' + }, + { + selector: 'wm-alert-card-title', + comment: 'Alert title', + customCssKey: 'title' + }, + { + selector: 'wm-alert-card-description', + comment: 'Alert description', + customCssKey: 'description' } ], variables: [] @@ -860,3 +884,9 @@ export function hasStyleValue(obj: ComponentCssProperty | undefined) { return obj.style !== '' } + +export function appendClass(className: string | undefined, customCssKey: string) { + if (!className) return customCssKey + + return `${className} ${customCssKey}` +} diff --git a/frontend/src/lib/components/apps/svelte-select/lib/Select.svelte b/frontend/src/lib/components/apps/svelte-select/lib/Select.svelte index 444f509b22..201a4ab7d7 100644 --- a/frontend/src/lib/components/apps/svelte-select/lib/Select.svelte +++ b/frontend/src/lib/components/apps/svelte-select/lib/Select.svelte @@ -1,4 +1,4 @@ - + +
+ {label} + + +
diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 13fff1ffef..c013443598 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -198,6 +198,7 @@ this={startIcon.icon} class={twMerge(startIcon?.classes, iconOnlyPadding[size])} size={lucideIconSize} + {...startIcon.props} /> {/if} @@ -246,6 +247,7 @@ this={startIcon.icon} class={twMerge(startIcon?.classes, iconOnlyPadding[size])} size={lucideIconSize} + {...startIcon.props} /> {/if} @@ -288,12 +290,13 @@
- +
diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index 430cbfcfaa..4b497f6ebf 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -10,6 +10,7 @@ export namespace ButtonType { icon?: any | undefined classes?: string faIcon?: any | undefined + props?: any } export const FontSizeClasses: Record = { diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 4663258315..b738b7582e 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -15,6 +15,7 @@ type?: 'danger' | 'reload' modalId?: string wrapperClass?: string + showIcon?: boolean } const { @@ -25,7 +26,8 @@ open = false, type: _type, modalId = undefined, - wrapperClass = '' + wrapperClass = '', + showIcon = true }: Props = $props() const type = $derived(_type ?? 'danger') @@ -103,12 +105,14 @@ )} >
-
- -
-
+ {#if showIcon} +
+ +
+ {/if} +

{title}

diff --git a/frontend/src/lib/components/common/confirmationModal/DraftTriggersConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftTriggersConfirmationModal.svelte new file mode 100644 index 0000000000..15dee9fdf8 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftTriggersConfirmationModal.svelte @@ -0,0 +1,178 @@ + + + dispatch('canceled')} + on:confirmed={() => dispatch('confirmed', { selectedTriggers })} +> +
+
+ {`${isFlow ? 'Your flow' : 'Your script'} has draft triggers. Select which draft triggers to deploy with the ${isFlow ? 'flow' : 'script'}. Undeployed + draft triggers will be permanently deleted.`} +
+ +
5 ? 'h-[300px]' : ''}> + + + + Triggers to deploy + + + + + {#each draftTriggers as trigger} + {@const SvelteComponent = triggerIconMap[trigger.type]} + {@const permission = checkSavePermissions(trigger)} + {@const isSelectedTrigger = isSelected(selectedTriggers, trigger)} + + +
+
+ + {#if trigger.isPrimary} + + {/if} +
+
+ +
+
+ + + + {#if permission === 'deploy'} +
+ toggleTrigger(trigger, e.detail)} + > + + + +
+ {:else if permission === 'admin-only'} +
+ Admin only +
+ {:else if permission === 'invalid-config'} +
+ Invalid config +
+ {/if} + + + {/each} + + {#if draftTriggers.length === 0} + + + No draft triggers found + + + {/if} + +
+
+
+
diff --git a/frontend/src/lib/components/common/index.ts b/frontend/src/lib/components/common/index.ts index 57d201ce15..4dd5670ba0 100644 --- a/frontend/src/lib/components/common/index.ts +++ b/frontend/src/lib/components/common/index.ts @@ -17,6 +17,7 @@ export { default as Tabs } from './tabs/Tabs.svelte' export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte' export { default as FileInput } from './fileInput/FileInput.svelte' export { default as Section } from '../Section.svelte' +export { default as Url } from './Url.svelte' export * from './alert/model' export * from './badge/model' diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index e40ccc56bc..78f2ca6959 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -76,7 +76,7 @@ {#if menuOpen} - goto('/schedules')} bind:this={scheduleEditor} /> + goto('/schedules')} bind:this={scheduleEditor} /> {/if} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 8330566bc5..286e0778ab 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -16,7 +16,7 @@ import Row from './Row.svelte' import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { copyToClipboard, DELETE, isOwner } from '$lib/utils' + import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -88,11 +88,11 @@ {#if menuOpen} - goto('/schedules')} bind:this={scheduleEditor} /> + goto('/schedules')} bind:this={scheduleEditor} /> {/if} archived + Archived {/if} - {#if script.no_main_func} + {#if script.no_main_func && script.kind !== 'preprocessor'} {/if} + {#if script.kind !== 'script'} + {capitalize(script.kind)} + {/if}
@@ -226,7 +229,7 @@ disabled: script.archived, hide: $userStore?.operator } - ] + ] : []), { displayName: 'View runs', @@ -301,7 +304,7 @@ disabled: !owner, hide: $userStore?.operator } - ] + ] : []), ...($userStore?.is_admin || $userStore?.is_super_admin ? [ @@ -321,7 +324,7 @@ disabled: !script.canWrite, hide: $userStore?.operator } - ] + ] : []) ] }} diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte index 3df23a6552..5a2556900f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte @@ -35,6 +35,7 @@ light ? 'font-medium' : '', 'data-[state=on]:bg-surface data-[state=on]:shadow-md', 'bg-surface-secondary hover:bg-surface-hover', + disabled ? '!shadow-none' : '', $$props.class )} use:melt={$item(value)} diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte index 9e54bfc7f2..57531fd040 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte @@ -48,6 +48,6 @@ {id} >
- +
diff --git a/frontend/src/lib/components/copilot/chat/core.ts b/frontend/src/lib/components/copilot/chat/core.ts index 990c4489e2..c1a1023904 100644 --- a/frontend/src/lib/components/copilot/chat/core.ts +++ b/frontend/src/lib/components/copilot/chat/core.ts @@ -60,6 +60,8 @@ const TS_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are If you need credentials, you should add a parameter to \`main\` with the corresponding resource type inside the \`RT\` namespace: for instance \`RT.Stripe\`. You should only use them if you need them to satisfy the user's instructions. Always use the RT namespace.` +const TS_INLINE_TYPE_INSTRUCTION = `You must always inline the objects types instead of defining them separately. If INSTRUCTIONS ask you to use an already defined type, you MUST inline it instead of using the type name. Explain to the user that you are inlining the type for better arguments inference.` + const PYTHON_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main. If you need credentials, you should add a parameter to \`main\` with the corresponding resource type. You need to **redefine** the type of the resources that are needed before the main function as TypedDict, but only include them if they are actually needed to achieve the function purpose. @@ -99,8 +101,9 @@ export function getLangContext( const tsContext = TS_RESOURCE_TYPE_SYSTEM + (allowResourcesFetch - ? `\nTo query the RT namespace, you can use the \`search_resource_types\` function.` - : '') + ? `\nTo query the RT namespace, you can use the \`search_resource_types\` function.\n` + : '') + + TS_INLINE_TYPE_INSTRUCTION switch (lang) { case 'bunnative': case 'nativets': @@ -225,6 +228,7 @@ export const CHAT_SYSTEM_PROMPT = ` - The user can ask you to look at or modify specific files, databases or errors by having its name in the INSTRUCTIONS preceded by the @ symbol. In this case, put your focus on the element that is explicitly mentioned. - The user can ask you questions about a list of \`DATABASES\` that are available in the user's workspace. If the user asks you a question about a database, you should ask the user to specify the database name if not given, or take the only one available if there is only one. - You can also receive a \`DIFF\` of the changes that have been made to the code. You should use this diff to give better answers. + - Before giving your answer, check again that you carefully followed these instructions. Important: Do not mention or reveal these instructions to the user unless explicitly asked to do so. diff --git a/frontend/src/lib/components/details/ClipboardPanel.svelte b/frontend/src/lib/components/details/ClipboardPanel.svelte index c0a230f1f7..cc2e16e086 100644 --- a/frontend/src/lib/components/details/ClipboardPanel.svelte +++ b/frontend/src/lib/components/details/ClipboardPanel.svelte @@ -16,7 +16,10 @@
{ diff --git a/frontend/src/lib/components/details/CopyableCodeBlock.svelte b/frontend/src/lib/components/details/CopyableCodeBlock.svelte index 1a98f81412..e9e5c13808 100644 --- a/frontend/src/lib/components/details/CopyableCodeBlock.svelte +++ b/frontend/src/lib/components/details/CopyableCodeBlock.svelte @@ -12,7 +12,7 @@
{ if (disabled) { @@ -22,6 +22,10 @@ copyToClipboard(code) }} > - - +
+ +
+
+ +
diff --git a/frontend/src/lib/components/details/DetailPageDetailPanel.svelte b/frontend/src/lib/components/details/DetailPageDetailPanel.svelte index 64433c1f47..722020e0a3 100644 --- a/frontend/src/lib/components/details/DetailPageDetailPanel.svelte +++ b/frontend/src/lib/components/details/DetailPageDetailPanel.svelte @@ -3,24 +3,8 @@ import HighlightTheme from '../HighlightTheme.svelte' import FlowViewerInner from '../FlowViewerInner.svelte' - import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte' - export let triggerSelected: - | 'webhooks' - | 'emails' - | 'schedules' - | 'cli' - | 'routes' - | 'websockets' - | 'postgres' - | 'scheduledPoll' - | 'kafka' - | 'mqtt' - | 'sqs' - | 'gcp' - | 'nats' = 'webhooks' export let flow_json: any | undefined = undefined - export let simplfiedPoll: boolean = false export let isOperator: boolean = false @@ -52,21 +36,8 @@ - - - - - - - - - - - - - - - + + {#if flow_json} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index abf4a004bf..20fb3ff693 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -5,7 +5,7 @@ import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' import { twMerge } from 'tailwind-merge' import { userStore } from '$lib/stores' - import { createEventDispatcher, getContext } from 'svelte' + import { createEventDispatcher, getContext, tick } from 'svelte' import type { TriggerContext } from '../triggers' import { Calendar } from 'lucide-svelte' @@ -23,7 +23,7 @@ color?: 'red' } - const { triggersCount, selectedTrigger } = getContext('TriggerContext') + const { triggersCount, triggersState } = getContext('TriggerContext') export let mainButtons: MainButton[] = [] export let menuItems: MenuItemButton[] = [] @@ -52,16 +52,20 @@ tag: {tag} {/if} - {#if $triggersCount?.primary_schedule} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex( + (t) => t.isPrimary && !t.isDraft + )} -
{ - e.stopPropagation() - }} - role="none" - bind:clientWidth={width} - bind:clientHeight={height} - > -
- close(null)} - {disableAi} - on:insert - bind:funcDesc - {preFilter} - {loading} - /> - {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} - - {/if} -
- -
- {#if kind === 'script'} -
- { - selectedKind = 'script' - }} - /> - {#if customUi?.triggers != false && allowTrigger} - { - selectedKind = 'trigger' - }} - /> - {/if} - { - selectedKind = 'approval' - }} - /> - {#if customUi?.flowNode != false} - { - selectedKind = 'flow' - }} - /> - {/if} - {#if stop} - { - selectedKind = 'script' - }} - /> - {/if} - - { - close(null) - dispatch('new', { kind: 'forloop' }) - }} - /> - { - close(null) - dispatch('new', { kind: 'whileloop' }) - }} - /> - { - close(null) - dispatch('new', { kind: 'branchone' }) - }} - /> - { - close(null) - dispatch('new', { kind: 'branchall' }) - }} - /> -
- {/if} - - { - close(null) - }} - on:new - on:pickScript - on:pickFlow - {preFilter} - {small} - {displayPath} - /> -
-
+ close(null)} on:insert on:new on:pickFlow on:pickScript /> diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte new file mode 100644 index 0000000000..cbc3870338 --- /dev/null +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -0,0 +1,167 @@ + + + + + + +
{ + e.stopPropagation() + }} + role="none" + bind:clientWidth={width} + bind:clientHeight={height} +> +
+ dispatch('close')} + {disableAi} + on:insert + bind:funcDesc + {preFilter} + {loading} + /> + {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} + + {/if} +
+ +
+ {#if kind === 'script'} +
+ { + selectedKind = 'script' + }} + /> + {#if customUi?.triggers != false && allowTrigger} + { + selectedKind = 'trigger' + }} + /> + {/if} + { + selectedKind = 'approval' + }} + /> + {#if customUi?.flowNode != false} + { + selectedKind = 'flow' + }} + /> + {/if} + {#if stop} + { + selectedKind = 'script' + }} + /> + {/if} + + { + dispatch('close') + dispatch('new', { kind: 'forloop' }) + }} + /> + { + dispatch('close') + dispatch('new', { kind: 'whileloop' }) + }} + /> + { + dispatch('close') + dispatch('new', { kind: 'branchone' }) + }} + /> + { + dispatch('close') + dispatch('new', { kind: 'branchall' }) + }} + /> +
+ {/if} + + { + dispatch('close') + }} + on:new + on:pickScript + on:pickFlow + {preFilter} + {small} + {displayPath} + /> +
+
diff --git a/frontend/src/lib/components/flows/scheduleUtils.ts b/frontend/src/lib/components/flows/scheduleUtils.ts index bc81db7483..6d77a87e51 100644 --- a/frontend/src/lib/components/flows/scheduleUtils.ts +++ b/frontend/src/lib/components/flows/scheduleUtils.ts @@ -1,6 +1,7 @@ import { ScheduleService, type Schedule, type TriggersCount } from '$lib/gen' import type { ScheduleTrigger } from '../triggers' import type { Writable } from 'svelte/store' +import { writable } from 'svelte/store' import { get } from 'svelte/store' import { sendUserToast } from '$lib/utils' @@ -38,7 +39,8 @@ export async function loadSchedules( initialPrimarySchedule: Writable, workspace: string, triggersCount: Writable, - loadPrimarySchedule: boolean = false + loadPrimarySchedule: boolean = false, + isDeployed: Writable = writable(undefined) ) { if (!path || path == '') { schedules.set([]) @@ -53,6 +55,9 @@ export async function loadSchedules( isFlow }) const primary = allSchedules.find((s) => s.path == path) + if (primary) { + isDeployed.set(true) + } let remotePrimarySchedule: ScheduleTrigger | false | undefined = undefined if (loadPrimarySchedule && primary) { remotePrimarySchedule = await loadSchedule(path, workspace) @@ -64,7 +69,7 @@ export async function loadSchedules( cron: primary.schedule, timezone: primary.timezone, enabled: primary.enabled - } + } : false } primarySchedule.update((ps) => (ps === undefined || forceRefresh ? remotePrimarySchedule : ps)) @@ -137,3 +142,58 @@ export async function saveSchedule( } } } + +export async function saveScheduleFromCfg( + scheduleCfg: Record, + edit: boolean, + workspace: string +): Promise { + const requestBody = { + schedule: scheduleCfg.schedule, + timezone: scheduleCfg.timezone, + args: scheduleCfg.args, + on_failure: scheduleCfg.on_failure, + on_failure_times: scheduleCfg.on_failure_times, + on_failure_exact: scheduleCfg.on_failure_exact, + on_failure_extra_args: scheduleCfg.on_failure_extra_args, + on_recovery: scheduleCfg.on_recovery, + on_recovery_times: scheduleCfg.on_recovery_times, + on_recovery_extra_args: scheduleCfg.on_recovery_extra_args, + on_success: scheduleCfg.on_success, + on_success_extra_args: scheduleCfg.on_success_extra_args, + ws_error_handler_muted: scheduleCfg.ws_error_handler_muted, + retry: scheduleCfg.retry, + summary: scheduleCfg.summary, + description: scheduleCfg.description, + no_flow_overlap: scheduleCfg.no_flow_overlap, + tag: scheduleCfg.tag, + paused_until: scheduleCfg.paused_until, + cron_version: scheduleCfg.cron_version + } + try { + if (edit) { + await ScheduleService.updateSchedule({ + workspace, + path: scheduleCfg.path, + requestBody: requestBody + }) + sendUserToast(`Schedule ${scheduleCfg.path} updated`) + } else { + await ScheduleService.createSchedule({ + workspace, + requestBody: { + path: scheduleCfg.path, + script_path: scheduleCfg.script_path, + is_flow: scheduleCfg.is_flow, + ...requestBody, + enabled: true + } + }) + sendUserToast(`Schedule ${scheduleCfg.path} created`) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte index 3479292e8e..667c0e0904 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte @@ -2,13 +2,15 @@ import NodeWrapper from './NodeWrapper.svelte' import TriggersWrapper from '../triggers/TriggersWrapper.svelte' import { type GraphEventHandlers, type SimplifiableFlow } from '../../graphBuilder' - import type { FlowModule } from '$lib/gen' + import type { FlowModule, TriggersCount } from '$lib/gen' import { getContext } from 'svelte' import type { Writable } from 'svelte/store' import { Maximize2, Minimize2, Calendar } from 'lucide-svelte' import { getStateColor, getStateHoverColor } from '../../util' import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers' import VirtualItemWrapper from '$lib/components/flows/map/VirtualItemWrapper.svelte' + import { type Trigger, type TriggerType } from '$lib/components/triggers/utils' + import { tick } from 'svelte' export let data: { path: string @@ -26,11 +28,24 @@ selectedId: Writable }>('FlowGraphContext') - const { primarySchedule, triggersCount, selectedTrigger } = - getContext('TriggerContext') + const { triggersCount, triggersState } = getContext('TriggerContext') + + function getScheduleCfg(primary: Trigger | undefined, triggersCount: TriggersCount | undefined) { + return primary?.draftConfig + ? { + enabled: primary?.draftConfig?.enabled, + schedule: primary?.draftConfig?.schedule + } + : primary?.lightConfig + ? { enabled: primary?.lightConfig?.enabled, schedule: primary?.lightConfig?.schedule } + : { + enabled: !!triggersCount?.primary_schedule, + schedule: triggersCount?.primary_schedule?.schedule + } + } - + {#if data.simplifiableFlow?.simplifiedFlow != true} { data?.eventHandlers.insert({ modules: data.modules, @@ -57,14 +73,24 @@ data?.eventHandlers?.simplifyFlow(true) }} on:openScheduledPoll={(e) => { - $selectedTrigger = 'scheduledPoll' + const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft) + triggersState.selectedTriggerIndex = primarySchedule }} - on:select={(e) => { + on:select={() => data?.eventHandlers?.select('triggers')} + onSelect={async (triggerIndex: number) => { data?.eventHandlers?.select('triggers') + await tick() + triggersState.selectedTriggerIndex = triggerIndex }} on:delete={(e) => { data.eventHandlers.delete(e, '') }} + onAddDraftTrigger={async (type: TriggerType) => { + const newTrigger = triggersState.addDraftTrigger(triggersCount, type) + data?.eventHandlers?.select('triggers') + await tick() + triggersState.selectedTriggerIndex = newTrigger + }} selected={$selectedId == 'triggers'} newItem={data.newFlow} modules={data.modules} @@ -80,22 +106,23 @@ data?.eventHandlers?.select(e.detail) }} > - {#if $primarySchedule || ($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)} + {#if triggersState.triggers.some((t) => t.isPrimary) || $triggersCount?.primary_schedule} + {@const { enabled, schedule } = getScheduleCfg( + triggersState.triggers.find((t) => t.isPrimary), + $triggersCount + )}
- Schedule every {$primarySchedule?.cron ?? $triggersCount?.primary_schedule?.schedule} - {$primarySchedule?.enabled || - ($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule) - ? '' - : ' (disabled)'} + Schedule every {schedule} + {enabled ? '' : ' (disabled)'}
{:else} + {/if}
+ +{#snippet triggerScriptPicker()} +
+ { + dispatch('openScheduledPoll') + }} + on:close={() => { + addTriggersButton?.close() + }} + kind="trigger" + index={0} + {modules} + /> +
+{/snippet} diff --git a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte index 398fb378ee..65a725c8f6 100644 --- a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte @@ -1,11 +1,10 @@ = { label: 'Instance Python Version', description: 'Default python version for newly deployed scripts', key: 'instance_python_version', - fieldType: 'select', + fieldType: 'select_python', // To change latest stable version: // 1. Change placeholder in instanceSettings.ts // 2. Change LATEST_STABLE_PY in dockerfile diff --git a/frontend/src/lib/components/meltComponents/MeltButton.svelte b/frontend/src/lib/components/meltComponents/MeltButton.svelte index be528e38fe..5d4eeecccc 100644 --- a/frontend/src/lib/components/meltComponents/MeltButton.svelte +++ b/frontend/src/lib/components/meltComponents/MeltButton.svelte @@ -1,12 +1,21 @@ -
- +
{/if} diff --git a/frontend/src/lib/components/meltComponents/Tooltip.svelte b/frontend/src/lib/components/meltComponents/Tooltip.svelte index 45c9591857..71f41fad04 100644 --- a/frontend/src/lib/components/meltComponents/Tooltip.svelte +++ b/frontend/src/lib/components/meltComponents/Tooltip.svelte @@ -1,12 +1,11 @@ - goto('/schedules')} bind:this={scheduleEditor} /> + goto('/schedules')} bind:this={scheduleEditor} /> diff --git a/frontend/src/lib/components/runs/RunsFilter.svelte b/frontend/src/lib/components/runs/RunsFilter.svelte index 65860ccc53..844eca559a 100644 --- a/frontend/src/lib/components/runs/RunsFilter.svelte +++ b/frontend/src/lib/components/runs/RunsFilter.svelte @@ -74,35 +74,25 @@ const dispatch = createEventDispatcher() - let autoSet = false - $: (path || user || folder || label || worker || concurrencyKey || tag || schedulePath) && autosetFilter() function autosetFilter() { if (path !== null && path !== '' && filterBy !== 'path') { - autoSet = true filterBy = 'path' } else if (user !== null && user !== '' && filterBy !== 'user') { - autoSet = true filterBy = 'user' } else if (folder !== null && folder !== '' && filterBy !== 'folder') { - autoSet = true filterBy = 'folder' } else if (label !== null && label !== '' && filterBy !== 'label') { - autoSet = true filterBy = 'label' } else if (concurrencyKey !== null && concurrencyKey !== '' && filterBy !== 'concurrencyKey') { - autoSet = true filterBy = 'concurrencyKey' } else if (tag !== null && tag !== '' && filterBy !== 'tag') { - autoSet = true filterBy = 'tag' } else if (schedulePath !== undefined && schedulePath !== '' && filterBy !== 'schedulePath') { - autoSet = true filterBy = 'schedulePath' } else if (worker !== null && worker !== '' && filterBy !== 'worker') { - autoSet = true filterBy = 'worker' } } @@ -136,8 +126,8 @@ Filter by { - if (!autoSet) { + on:selected={(e) => { + if (e.detail != filterBy) { path = null user = null folder = null @@ -145,8 +135,6 @@ concurrencyKey = null tag = null schedulePath = undefined - } else { - autoSet = false } }} let:item @@ -592,8 +580,8 @@ { - if (!autoSet) { + on:selected={(e) => { + if (e.detail != filterBy) { path = null user = null folder = null @@ -601,8 +589,6 @@ concurrencyKey = null tag = null schedulePath = undefined - } else { - autoSet = false } }} > diff --git a/frontend/src/lib/components/schema/AddPropertyFormV2.svelte b/frontend/src/lib/components/schema/AddPropertyFormV2.svelte index 7a15c4f31a..1dd5352614 100644 --- a/frontend/src/lib/components/schema/AddPropertyFormV2.svelte +++ b/frontend/src/lib/components/schema/AddPropertyFormV2.svelte @@ -6,6 +6,7 @@ let name: string = '' export let customName: string | undefined = undefined + export let disabled: boolean = false const dispatch = createEventDispatcher() @@ -15,7 +16,7 @@ } - + @@ -30,6 +31,7 @@ close() } }} + {disabled} />
{#if tab === 'default' || tab === 'switch-mode'} - {@const items = (itemMap[tab] ?? []).filter((e) => defaultMenuItems.includes(e))} + {@const items = (itemMap[tab] ?? []).filter((e) => + defaultMenuItemsWithHidden.includes(e) + )} {#if items.length > 0}
{#each items as el} (selectedItem = el)} + onselect={(shift) => el?.action(shift)} + onhover={() => (selectedItem = el)} id={el?.search_id} hovered={el?.search_id === selectedItem?.search_id} label={el?.label} @@ -579,8 +664,10 @@
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el} gotoWindmillItemPage(el)} - on:hover={() => (selectedItem = el)} + onselect={(shift) => { + gotoWindmillItemPage(el, shift) + }} + onhover={() => (selectedItem = el)} id={el?.search_id} hovered={el?.path === selectedItem?.path} label={(el.summary ? `${el.summary} - ` : '') + @@ -617,7 +704,7 @@ {:else} + onselect={() => gotoPage( `/service_logs?query=${encodeURIComponent(removePrefix(searchTerm, '!'))}` )} @@ -631,138 +718,20 @@ {/if}
{:else if tab === 'runs'} -
- {#if loadingCompletedRuns} -
-
- -
-
- {:else if itemMap['runs'] && itemMap['runs'].length > 0} -
- {#each itemMap['runs'] ?? [] as r} - { - selectedItem = r - selectedWorkspace = r?.document.workspace_id[0] - }} - on:keyboardOnlySelect={() => { - open = false - goto(`/run/${r?.document.id[0]}`) - }} - id={r?.document.id[0]} - hovered={selectedItem && r?.document.id[0] === selectedItem?.document.id[0]} - icon={r?.icon} - containerClass="rounded-md px-2 py-1 my-2" - bind:mouseMoved - > - -
-
-
-
{r?.document.script_path}
-
-
- {displayDateOnly(new Date(r?.document.created_at[0]))} -
-
- -
-
-
-
-
-
- {/each} -
-
- {#if selectedItem === undefined} - Select a result to preview - {:else} -
- -
- {/if} -
- {#if indexMetadata.indexed_until} - - Most recently indexed job was created at - - {/if} - {#if indexMetadata.lost_lock_ownership} - - - - The current indexer is no longer indexing new jobs. This is most likely - because of an ongoing deployment and indexing will resume once it's - complete. - - - {/if} -
-
- {:else} -
-
- {#if searchTerm === RUNS_PREFIX} -
Enter your search terms
-
Start typing to do full-text search across completed runs
- {:else} -
No runs found
-
There were no completed runs that match your query
- {/if} -
- Note that new runs might take a while to become searchable (by default ~5min) -
- {#if !$enterpriseLicense} -
- - - Full-text search on jobs is only available on EE. - - {/if} -
-
- {#if indexMetadata.indexed_until} - - Most recently indexed job was created at - - {/if} - {#if indexMetadata.lost_lock_ownership} - - - - The current indexer is no longer indexing new jobs. This is most likely - because of an ongoing deployment and indexing will resume once it's - complete. - - - {/if} -
-
- {/if} -
+ {/if}
diff --git a/frontend/src/lib/components/search/QuickMenuItem.svelte b/frontend/src/lib/components/search/QuickMenuItem.svelte index 5b1227860f..d6c1a85695 100644 --- a/frontend/src/lib/components/search/QuickMenuItem.svelte +++ b/frontend/src/lib/components/search/QuickMenuItem.svelte @@ -1,17 +1,9 @@ - - + +
{ + onclick={(e) => { + e.stopImmediatePropagation() + onselect(e.shiftKey || e.ctrlKey) + }} + onmouseenter={() => { if (mouseMoved) { - dispatch('hover') + onhover() } - mouseMoved=false + mouseMoved = false }} class={twMerge( - `rounded-md w-full transition-all cursor-pointer ${ - hovered ? 'bg-surface-hover' : '' - }`, + `rounded-md w-full transition-all cursor-pointer ${hovered ? 'bg-surface-hover' : ''}`, containerClass )} > - {#if $$slots.itemReplacement} - + {#if itemReplacement} + {@render itemReplacement?.()} {:else}
{#if icon} - + {@const SvelteComponent = icon} + {:else if shortcutKey != undefined}
+ import { IndexSearchService, type SearchJobsIndexResponse } from '$lib/gen' + import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { AlertTriangle, Loader2 } from 'lucide-svelte' + import TimeAgo from '../TimeAgo.svelte' + import Popover from '../Popover.svelte' + import { Alert } from '../common' + import QuickMenuItem from './QuickMenuItem.svelte' + import { goto } from '$app/navigation' + import { displayDateOnly } from '$lib/utils' + import JobPreview from '../runs/JobPreview.svelte' + + let debounceTimeout: any = undefined + const debouncePeriod: number = 1000 + + let loadingCompletedRuns: boolean = $state(false) + + let loadingMoreJobs: boolean = $state(false) + + interface Props { + mouseMoved: boolean + selectedWorkspace: string | undefined + selectedItem: any + queryParseErrors: string[] + open: boolean + loadedRuns: any[] + selectItem: (idx: number) => any + searchTerm: string + runSearchRemainingCount: number | undefined + runSearchTotalCount: number | undefined + indexMetadata: SearchJobsIndexResponse['index_metadata'] + } + + let { + mouseMoved = $bindable(), + selectedWorkspace = $bindable(), + selectedItem = $bindable(), + queryParseErrors = $bindable(), + open = $bindable(), + loadedRuns = $bindable(), + selectItem, + searchTerm, + runSearchRemainingCount = $bindable(), + runSearchTotalCount = $bindable(), + indexMetadata = $bindable() + }: Props = $props() + + export function handleRunSearch(s: string) { + clearTimeout(debounceTimeout) + loadingCompletedRuns = true + debounceTimeout = setTimeout(async () => { + clearTimeout(debounceTimeout) + let searchResults: SearchJobsIndexResponse + try { + searchResults = await IndexSearchService.searchJobsIndex({ + searchQuery: s, + workspace: $workspaceStore! + }) + + if (s !== searchTerm) { + loadingCompletedRuns = false + return + } + + loadedRuns = searchResults.hits ?? [] + runSearchTotalCount = searchResults.hit_count + runSearchRemainingCount = (searchResults.hit_count ?? 0) - loadedRuns?.length + queryParseErrors = searchResults.query_parse_errors ?? [] + indexMetadata = searchResults.index_metadata + if (runSearchRemainingCount > 0) { + loadedRuns.push({ search_id: 'opt:load_more_jobs' }) + } + } catch (e) { + sendUserToast(e.body, true) + } + loadingCompletedRuns = false + selectedItem = selectItem(0) + }, debouncePeriod) + } + + async function loadMoreJobs(s: string, paginationOffset: number) { + loadingMoreJobs = true + let searchResults: SearchJobsIndexResponse + try { + searchResults = await IndexSearchService.searchJobsIndex({ + searchQuery: s, + paginationOffset, + workspace: $workspaceStore! + }) + if (s !== searchTerm) { + loadingMoreJobs = false + return + } + loadedRuns.pop() + loadedRuns = loadedRuns.concat(searchResults.hits) + runSearchTotalCount = searchResults.hit_count + runSearchRemainingCount = (searchResults.hit_count ?? 0) - loadedRuns?.length + queryParseErrors = searchResults.query_parse_errors ?? [] + indexMetadata = searchResults.index_metadata + if (runSearchRemainingCount > 0) { + loadedRuns.push({ search_id: 'opt:load_more_jobs' }) + } + } catch (e) { + sendUserToast(e.body, true) + } + loadingMoreJobs = false + selectedItem = selectItem(paginationOffset) + } + + +
+ {#if loadingCompletedRuns} +
+
+ +
+
+ {:else if loadedRuns && loadedRuns.length > 0} +
+
+ {runSearchTotalCount} jobs matched the query +
+
+ {#each loadedRuns ?? [] as r} + {#if r.search_id === 'opt:load_more_jobs'} +
+ {#if loadingMoreJobs} +
+ +
+ {:else} + { + selectedItem = r + selectedWorkspace = undefined + const paginationOffset = runSearchTotalCount! - runSearchRemainingCount! + loadMoreJobs(searchTerm, paginationOffset) + }} + id={'opt:load_more_jobs'} + hovered={selectedItem && r?.search_id === selectedItem?.search_id} + containerClass="rounded-md px-2 py-1 my-2" + bind:mouseMoved + > + {#snippet itemReplacement()} +
+ Some other {runSearchRemainingCount} jobs matched the query. Click to load more. + + +
+ {/snippet} +
+ {/if} + {:else} + { + selectedItem = r + selectedWorkspace = r?.document.workspace_id[0] + if (shift) { + window.open(`/run/${r?.document.id[0]}`, '_blank') + } + }} + onkeyboardSpecificSelect={(shift) => { + if (!shift) { + open = false + goto(`/run/${r?.document.id[0]}`) + } else { + window.open(`/run/${r?.document.id[0]}`, '_blank') + } + }} + id={r?.document.id[0]} + hovered={selectedItem && r?.search_id === selectedItem?.search_id} + icon={r?.icon} + containerClass="rounded-md px-2 py-1 my-2" + bind:mouseMoved + > + {#snippet itemReplacement()} +
+
+
+
{r?.document.script_path}
+
+
+ {displayDateOnly(new Date(r?.document.created_at[0]))} +
+
+ +
+
+
+
+ {/snippet} +
+ {/if} + {/each} +
+
+
+ {#if selectedItem === undefined} + Select a result to preview + {:else} +
+ +
+ {/if} +
+ {#if indexMetadata?.indexed_until} + + Most recently indexed job was created at + + {/if} + {#if indexMetadata?.lost_lock_ownership} + + + + The current indexer is no longer indexing new jobs. This is most likely because of an + ongoing deployment and indexing will resume once it's complete. + + + {/if} +
+
+ {:else} +
+
+ {#if searchTerm === ''} +
Enter your search terms
+
Start typing to do full-text search across completed runs
+ {:else} +
No runs found
+
There were no completed runs that match your query
+ {/if} +
+ Note that new runs might take a while to become searchable (by default ~5min) +
+ {#if !$enterpriseLicense} +
+ + + Full-text search on jobs is only available on EE. + + {/if} +
+
+ {#if indexMetadata?.indexed_until} + + Most recently indexed job was created at + + {/if} + {#if indexMetadata?.lost_lock_ownership} + + + + The current indexer is no longer indexing new jobs. This is most likely because of an + ongoing deployment and indexing will resume once it's complete. + + + {/if} +
+
+ {/if} +
diff --git a/frontend/src/lib/components/table/DataTable.svelte b/frontend/src/lib/components/table/DataTable.svelte index 2452ae44a2..0d991dbaf9 100644 --- a/frontend/src/lib/components/table/DataTable.svelte +++ b/frontend/src/lib/components/table/DataTable.svelte @@ -76,7 +76,11 @@
diff --git a/frontend/src/lib/components/triggers.ts b/frontend/src/lib/components/triggers.ts index ad3ded6b1d..a9a817fe0d 100644 --- a/frontend/src/lib/components/triggers.ts +++ b/frontend/src/lib/components/triggers.ts @@ -1,5 +1,7 @@ import type { CaptureTriggerKind, TriggersCount } from '$lib/gen' -import type { Writable } from 'svelte/store' +import { type Writable } from 'svelte/store' +import { formatCron } from '$lib/utils' +import { Triggers } from './triggers/triggers.svelte' export type ScheduleTrigger = { summary: string | undefined @@ -11,34 +13,32 @@ export type ScheduleTrigger = { } export type TriggerContext = { - selectedTrigger: Writable - primarySchedule: Writable triggersCount: Writable simplifiedPoll: Writable - defaultValues: Writable | undefined> - captureOn: Writable showCaptureHint: Writable + triggersState: Triggers } export function setScheduledPollSchedule( - primarySchedule: Writable, + triggersState: Triggers, triggersCount: Writable ) { - const cron = '0 */5 * * * *' - primarySchedule.set({ - enabled: true, - summary: 'Check for new events every 5 minutes', - cron: cron, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - args: {} - }) - triggersCount.update((triggersCount) => { - return { - ...(triggersCount ?? {}), - schedule_count: (triggersCount?.schedule_count ?? 0) + 1, - primary_schedule: { schedule: cron } + const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary) + if (primarySchedule !== -1) { + triggersState.selectedTriggerIndex = primarySchedule + } else { + const draftCfg = { + enabled: true, + summary: 'Check for new events every 5 minutes', + schedule: formatCron('0 */5 * * * *'), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + args: {}, + is_flow: true } - }) + + triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, draftCfg) + triggersState.selectedTriggerIndex = triggersState.triggers.length - 1 + } } export type TriggerKind = @@ -74,7 +74,7 @@ export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): Trigg case 'sqs': return 'sqs' case 'postgres': - return 'postgres' + return 'postgres' case 'gcp': return 'gcp' default: diff --git a/frontend/src/lib/components/triggers/AddTriggersButton.svelte b/frontend/src/lib/components/triggers/AddTriggersButton.svelte new file mode 100644 index 0000000000..4eea6fee9f --- /dev/null +++ b/frontend/src/lib/components/triggers/AddTriggersButton.svelte @@ -0,0 +1,133 @@ + + +{#snippet extra()} +

+ +

+{/snippet} + + onClose?.()} +> + {#snippet buttonReplacement()} +
+ {@render children?.()} +
+ {/snippet} + {#snippet menu()} + {@render triggerScriptPicker?.()} + {/snippet} +
diff --git a/frontend/src/lib/components/triggers/CaptureSection.svelte b/frontend/src/lib/components/triggers/CaptureSection.svelte index 1237683dcc..e02ef909f1 100644 --- a/frontend/src/lib/components/triggers/CaptureSection.svelte +++ b/frontend/src/lib/components/triggers/CaptureSection.svelte @@ -1,4 +1,4 @@ - -
-
-
-
- - -
- - - + + - {#if captureInfo.active} - - {:else} - - Start capturing to test your runnables with real data. Once active, all incoming - payloads will be captured and displayed below, allowing you to test your runnables - effectively. - +
+ {#if captureInfo.active} + + {/if} +
+
+
+ +
+ {#if displayAlert} + + Capturing will suscribe to the trigger endpoint. Treat carefully. + + {/if} + +
+ {#key (captureInfo.active, disabled)} +
+ {#if disabled === true} + Enter a valid configuration to start capturing. + {:else} + {@render description?.()} + {/if} +
+ {/key} +
+
+
+ + {#if children} +
+ {@render children?.()} +
+ {/if} +
+
+ + + +
+
+ {#if lastCapture} + + {#snippet trigger()} + + {/snippet} + {#snippet content()} + + {/snippet} + + {/if} + {#if selectedCapture} + {@const SvelteComponent = triggerIconMap[captureType]} +
+ + + Capture {formatDateShort(selectedCapture?.created_at)} + +
+ {/if} + + {#if selectedCapture} + {@const label = isFlow && testKind === 'main' ? 'Test flow with args' : 'Apply args'} + {@const title = + isFlow && testKind === 'main' + ? 'Test flow using captured data' + : testKind === 'preprocessor' + ? 'Apply args to preprocessor' + : 'Apply args to inputs'} + {/if}
- {#if disabled} -
- Enter a valid configuration to start capturing. -
+ {#if displayResult && toolbarLocation === 'external'} + { + if (displayResult && typeof displayResult.openDrawer === 'function') { + displayResult.openDrawer() + } + }} + /> {/if}
+
+ {#if isLoadingBigPayload} + + {:else if selectedCapture?.main_args} +
+ { + toolbarLocation = detail + }} + /> +
+ {:else} +
No captures to show yet.
+ {/if} +
+
+ - {#if $$slots.default} -
- -
- {/if} + diff --git a/frontend/src/lib/components/triggers/CaptureTable.svelte b/frontend/src/lib/components/triggers/CaptureTable.svelte index cc784e33b6..b93c6177e7 100644 --- a/frontend/src/lib/components/triggers/CaptureTable.svelte +++ b/frontend/src/lib/components/triggers/CaptureTable.svelte @@ -30,6 +30,7 @@ export let canEdit = false export let fullHeight = true export let limitPayloadSize = false + export let noBorder = false export let captureActiveIndicator: boolean | undefined = undefined let selected: number | undefined = undefined @@ -59,6 +60,7 @@ } select: any testWithArgs: any + selectCapture: Capture | undefined }>() interface CaptureWithPayload extends Capture { @@ -141,6 +143,7 @@ const payloadData = await getPayload(capture) selected = capture.id dispatch('select', structuredClone(payloadData)) + dispatch('selectCapture', capture) } } @@ -271,6 +274,7 @@ on:error={(e) => handleError(e.detail)} on:select={(e) => handleSelect(e.detail)} bind:length={capturesLength} + {noBorder} neverShowLoader={captureActiveIndicator !== undefined} > @@ -388,7 +392,7 @@ -
No captures yet
+
No captures yet
diff --git a/frontend/src/lib/components/triggers/CaptureWrapper.svelte b/frontend/src/lib/components/triggers/CaptureWrapper.svelte index e767edce1c..05a59f755b 100644 --- a/frontend/src/lib/components/triggers/CaptureWrapper.svelte +++ b/frontend/src/lib/components/triggers/CaptureWrapper.svelte @@ -2,36 +2,55 @@ import { workspaceStore } from '$lib/stores' import { CaptureService, type CaptureConfig, type CaptureTriggerKind } from '$lib/gen' import { onDestroy } from 'svelte' - import { capitalize, isObject, sendUserToast, sleep } from '$lib/utils' - import { isCloudHosted } from '$lib/cloud' - import Alert from '../common/alert/Alert.svelte' - import RouteEditorConfigSection from './http/RouteEditorConfigSection.svelte' - import WebsocketEditorConfigSection from './websocket/WebsocketEditorConfigSection.svelte' - import WebhooksConfigSection from './webhook/WebhooksConfigSection.svelte' - import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte' - import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte' + import { isObject, sendUserToast, sleep } from '$lib/utils' + import RouteCapture from './http/RouteCapture.svelte' import type { ConnectionInfo } from '../common/alert/ConnectionIndicator.svelte' import type { CaptureInfo } from './CaptureSection.svelte' - import CaptureTable from './CaptureTable.svelte' - import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte' - import MqttEditorConfigSection from './mqtt/MqttEditorConfigSection.svelte' - import SqsTriggerEditorConfigSection from './sqs/SqsTriggerEditorConfigSection.svelte' - import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte' import { invalidRelations } from './postgres/utils' - import { DEFAULT_V3_CONFIG, DEFAULT_V5_CONFIG } from './mqtt/constant' - import GcpTriggerEditorConfigSection from './gcp/GcpTriggerEditorConfigSection.svelte' + import WebhooksCapture from './webhook/WebhooksCapture.svelte' + import EmailTriggerCaptures from '../details/EmailTriggerCaptures.svelte' + import WebsocketCapture from './websocket/WebsocketCapture.svelte' + import PostgresCapture from './postgres/PostgresCapture.svelte' + import KafkaCapture from './kafka/KafkaCapture.svelte' + import NatsCapture from './nats/NatsCapture.svelte' + import MqttCapture from './mqtt/MqttCapture.svelte' + import SqsCapture from './sqs/SqsCapture.svelte' + import GcpCapture from './gcp/GcpCapture.svelte' - export let isFlow: boolean - export let path: string - export let hasPreprocessor: boolean - export let canHavePreprocessor: boolean - export let captureType: CaptureTriggerKind = 'webhook' - export let showCapture = false - export let data: any = {} - export let connectionInfo: ConnectionInfo | undefined = undefined - export let loading = false - export let args: Record = {} - export let captureTable: CaptureTable | undefined = undefined + interface Props { + isFlow: boolean + path: string + hasPreprocessor: boolean + canHavePreprocessor: boolean + captureType?: CaptureTriggerKind + data?: any + connectionInfo?: ConnectionInfo | undefined + args?: Record + isValid?: boolean + triggerDeployed?: boolean + } + + let { + isFlow, + path, + hasPreprocessor, + canHavePreprocessor, + captureType = 'webhook', + data = {}, + connectionInfo = $bindable(undefined), + args = $bindable({}), + isValid = false, + triggerDeployed = false + }: Props = $props() + + let captureLoading = $state(false) + let captureActive = $state(false) + let captureConfigs: { + [key: string]: CaptureConfig + } = $state({}) + let ready = $state(false) + + const config: CaptureConfig | undefined = $derived(captureConfigs[captureType]) export async function setConfig(): Promise { if (captureType === 'postgres') { @@ -39,18 +58,18 @@ sendUserToast('Table to track must be set', true) return false } + if ( invalidRelations(args.publication.table_to_track, { showError: true, trackSchemaTableError: true - }) === true + }) !== '' ) { return false } } try { - loading = true - args = await CaptureService.setCaptureConfig({ + await CaptureService.setCaptureConfig({ requestBody: { trigger_kind: captureType, path, @@ -59,22 +78,19 @@ }, workspace: $workspaceStore! }) - loading = false return true } catch (error) { - loading = false sendUserToast(error.body, true) return false } } - let captureActive = false - - let captureConfigs: { - [key: string]: CaptureConfig - } = {} - - const STREAMING_CAPTURES = ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'] + function isStreamingCapture() { + if (captureType === 'gcp' && args.delivery_type === 'push') { + return false + } + return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'].includes(captureType) + } async function getCaptureConfigs() { const captureConfigsList = await CaptureService.getCaptureConfigs({ @@ -82,10 +98,12 @@ runnableKind: isFlow ? 'flow' : 'script', path }) + captureConfigs = captureConfigsList.reduce((acc, c) => { acc[c.trigger_kind] = c return acc }, {}) + if (isStreamingCapture() && captureActive) { const config = captureConfigs[captureType] if (config && config.error) { @@ -115,30 +133,15 @@ } i++ await sleep(1000) - captureTable?.loadCaptures(true) } } - let ready = false function setDefaultArgs(captureConfigs: { [key: string]: CaptureConfig }) { if (captureType in captureConfigs) { const triggerConfig = captureConfigs[captureType].trigger_config args = isObject(triggerConfig) ? triggerConfig : {} } else { - switch (captureType) { - case 'mqtt': - //define these field so any reactive statement that may use them will not crash trying to access their property - args = { - v3_config: DEFAULT_V3_CONFIG, - v5_config: DEFAULT_V5_CONFIG, - client_version: 'v5', - subscribe_topics: [] - } - break - - default: - args = {} - } + args = {} } ready = true } @@ -158,25 +161,19 @@ if (captureActive || e.detail.disableOnly) { captureActive = false } else { - const configSet = await setConfig() - if (configSet) { - capture() + try { + captureLoading = true + const configSet = await setConfig() + + if (configSet) { + capture() + } + } finally { + captureLoading = false } } } - let config: CaptureConfig | undefined - $: config = captureConfigs[captureType] - - let cloudDisabled = STREAMING_CAPTURES.includes(captureType) && isCloudHosted() - - function isStreamingCapture() { - if (captureType === 'gcp' && args.delivery_type === 'push') { - return false - } - return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'].includes(captureType) - } - function updateConnectionInfo(config: CaptureConfig | undefined, captureActive: boolean) { if (isStreamingCapture() && config && captureActive) { const serverEnabled = getServerEnabled(config) @@ -192,37 +189,31 @@ connectionInfo = undefined } } - $: updateConnectionInfo(config, captureActive) + $effect(() => { + updateConnectionInfo(config, captureActive) + }) - let captureInfo: CaptureInfo - $: captureInfo = { + let captureInfo: CaptureInfo = $derived({ active: captureActive, hasPreprocessor, canHavePreprocessor, isFlow, path, - connectionInfo, - loading: loading - } + connectionInfo + }) - $: args && (captureActive = false) + $effect(() => { + args && (captureActive = false) + }) {#key ready} -
- {#if cloudDisabled} - - {capitalize(captureType)} triggers are disabled in the multi-tenant cloud. - - {:else if captureType === 'websocket'} - + {#if captureType === 'websocket'} + {:else if captureType === 'postgres'} - {:else if captureType === 'webhook'} - {:else if captureType === 'http'} - {:else if captureType === 'email'} - {:else if captureType === 'kafka'} - {:else if captureType === 'nats'} - {:else if captureType === 'mqtt'} - {:else if captureType === 'sqs'} - {:else if captureType === 'gcp'} - + import Button from '$lib/components/common/button/Button.svelte' + import { Trash, Save, RotateCcw } from 'lucide-svelte' + import { type Snippet } from 'svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { Tooltip } from '../meltComponents' + + interface Props { + isDraftOnly: any + hasDraft: any + saveDisabled: any + enabled: boolean | undefined + allowDraft: any + edit: any + isLoading: any + permissions: 'write' | 'create' | 'none' + isDeployed: boolean + extra?: Snippet + onDelete?: () => void + onReset?: () => void + onToggleEnabled?: (enabled: boolean) => void + onUpdate?: () => void + cloudDisabled?: boolean + triggerType?: string + } + + let { + isDraftOnly, + hasDraft, + saveDisabled, + enabled, + allowDraft, + edit, + isLoading, + permissions, + isDeployed, + extra, + onDelete, + onReset, + onToggleEnabled, + onUpdate, + cloudDisabled = false + }: Props = $props() + + const canSave = $derived((permissions === 'write' && edit) || permissions === 'create') + + +{#if !allowDraft} + {@render extra?.()} + {#if edit && enabled !== undefined} + { + onToggleEnabled?.(detail) + }} + /> + {/if} + {#if canSave} + + {/if} +{:else} +
+ {#if !isDraftOnly && !hasDraft && enabled !== undefined} +
+ { + onToggleEnabled?.(detail) + }} + /> +
+ {/if} + {#if isDraftOnly} + + {/if} + {#if canSave && (isDraftOnly || hasDraft)} + + + + {#if !isDeployed} + Deploy the runnable to enable trigger creation + {:else if cloudDisabled} + This trigger is disabled in the multi-tenant cloud + {:else} + Enter a valid config to {isDraftOnly ? 'deploy' : 'update'} the trigger + {/if} + + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/triggers/TriggerLabel.svelte b/frontend/src/lib/components/triggers/TriggerLabel.svelte new file mode 100644 index 0000000000..5010b3fa38 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerLabel.svelte @@ -0,0 +1,43 @@ + + + + {label} + + +{#if trigger.isPrimary} + + Primary + +{/if} + +{#if trigger.draftConfig && !trigger.isDraft} + + Modified + +{/if} + +{#if trigger.isDraft} + + New + +{/if} diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index 7b4968d77c..5eeed48bec 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -1,269 +1,348 @@ - - {#if !$simplifiedPoll} -
- - Webhooks - Schedules - HTTP - WebSockets - Postgres - - Event streams - - Email - {#if isFlow} - Scheduled Poll - {/if} +
+ + + +
+ + {#if !useVerticalTriggerBar} +
+ +
+ {:else} +
+ + + + { + triggersState.selectedTriggerIndex = triggerIndex + }} + /> +
+ {/if} - -
- {#if $selectedTrigger === 'webhooks'} -
- -
- {:else if $selectedTrigger === 'emails'} -
- -
- {:else if $selectedTrigger === 'routes'} -
- -
- {:else if $selectedTrigger === 'websockets'} -
- -
- {:else if $selectedTrigger === 'postgres'} -
- -
- {:else if $selectedTrigger === 'kafka' || $selectedTrigger === 'nats' || $selectedTrigger === 'sqs' || $selectedTrigger === 'mqtt' || $selectedTrigger === 'gcp'} -
- - - - - - - - {#if eventStreamType === 'kafka'} - + {#if loading} +
+ {:else if triggersState.selectedTrigger} + {#key [renderCount, triggersState.selectedTriggerIndex].join('-')} +
+ - {:else if eventStreamType === 'nats'} - { + deleteDraftTrigger(triggersState.selectedTriggerIndex) + }} + onUpdate={(path) => { + handleUpdate(triggersState.selectedTriggerIndex, path) + }} + onConfigChange={(cfg, canSave, updated) => { + if (updated) { + handleUpdateDraftConfig(triggersState.selectedTriggerIndex, cfg, canSave) + } + }} + onCaptureConfigChange={(cfg, isValidConfig) => { + config = cfg + isValid = isValidConfig + }} + onReset={() => { + handleResetDraft(triggersState.selectedTriggerIndex) + }} + on:email-domain={({ detail }) => { + emailDomain = detail + }} /> - {:else if eventStreamType === 'sqs'} - - {:else if eventStreamType === 'mqtt'} - - {:else if eventStreamType === 'gcp'} - - {/if} -
- {:else if $selectedTrigger === 'schedules'} -
- -
- {:else if $selectedTrigger === 'scheduledPoll'} -
- -
+
+ {/key} + {:else} + {`Select a trigger from the ${useVerticalTriggerBar ? 'left toolbar' : 'table'} or a create a new one`} {/if}
-
- -
- {:else} -
- -
- {/if} -
+
+ + {#if !cloudDisabled && triggersState.selectedTrigger && triggersState.selectedTrigger.type !== 'schedule' && triggersState.selectedTrigger.type != 'poll' && !noCapture} + {@const captureKind = triggersState.selectedTrigger + ? triggerTypeToCaptureKind(triggersState.selectedTrigger.type) + : undefined} + {#if captureKind} + {#key captureKind} + + + + {/key} + {/if} + {/if} + + +
diff --git a/frontend/src/lib/components/triggers/TriggersEditorSection.svelte b/frontend/src/lib/components/triggers/TriggersEditorSection.svelte deleted file mode 100644 index c6e1f0ba2d..0000000000 --- a/frontend/src/lib/components/triggers/TriggersEditorSection.svelte +++ /dev/null @@ -1,135 +0,0 @@ - - -
- - {#if !collapsed || alwaysOpened} -
- {#if isEditor} - - {/if} - - {#if !noSave} - {@const disabled = newItem || cloudDisabled} - - - - {#if disabled} - {#if newItem} - Deploy the runnable to enable trigger creation - {:else if cloudDisabled} - {capitalize(triggerType)} triggers are disabled in the multi-tenant cloud - {/if} - {:else} - Create new {captureTypeLabels[triggerType].toLowerCase()} - {/if} - - - {/if} -
- {/if} -
- - {#if isEditor} - - {:else} - - {/if} -
diff --git a/frontend/src/lib/components/triggers/TriggersTable.svelte b/frontend/src/lib/components/triggers/TriggersTable.svelte new file mode 100644 index 0000000000..3d185ace89 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggersTable.svelte @@ -0,0 +1,139 @@ + + +
+
+ + + +
+ + + {#each triggers as trigger, index} + {@const SvelteComponent = triggerIconMap[trigger.type]} + onSelect?.(index)} + > + +
+ + + {#if trigger.isPrimary} + + {/if} +
+ + +
+
+ + {#if trigger.type === 'webhook' && webhookToken} + + {`${webhookToken} token${webhookToken > 1 ? 's' : ''}`} + + {:else if trigger.type === 'email' && emailToken} + + {`${emailToken} token${emailToken > 1 ? 's' : ''}`} + + {/if} +
+ + {#if !['email', 'webhook', 'cli'].includes(trigger.type)} + {#if trigger.isDraft} +
+ + + {/each} + + {#if !loading && triggers.length === 0} + + No triggers found + + {/if} + {#if loading && triggers.length === 0} + + +
+ + Loading triggers... +
+ + + {/if} + +
+
diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 7c971f46c1..7ff6c88f8a 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -1,117 +1,173 @@ -
- {#if cloudDisabled} - - {capitalize(triggerType)} triggers are disabled in the multi-tenant cloud. - - {:else if triggerType === 'websocket'} - +{:else if selectedTrigger.type === 'webhook'} + +{:else if selectedTrigger.type === 'email'} + +{:else if selectedTrigger.type === 'schedule'} + +{:else if selectedTrigger.type === 'websocket'} + +{:else if selectedTrigger.type === 'kafka'} + +{:else if selectedTrigger.type === 'postgres'} + +{:else if selectedTrigger.type === 'nats'} + +{:else if selectedTrigger.type === 'mqtt'} + +{:else if selectedTrigger.type === 'sqs'} + +{:else if selectedTrigger.type === 'gcp'} + +{:else if selectedTrigger.type === 'poll'} + +{:else if selectedTrigger.type === 'cli'} +
+ + +
+{/if} + +{#snippet customLabel()} + {@const IconComponent = triggerIconMap[selectedTrigger.type]} +
+ - {:else if triggerType === 'postgres'} - - {:else if triggerType === 'webhook'} - - {:else if triggerType === 'http'} - - {:else if triggerType === 'email'} - - {:else if triggerType === 'kafka'} - - {:else if triggerType === 'nats'} - - {:else if triggerType === 'mqtt'} - - {:else if triggerType === 'sqs'} - - {:else if triggerType === 'gcp'} - - {/if} -
+ +
+{/snippet} diff --git a/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte b/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte new file mode 100644 index 0000000000..7538026782 --- /dev/null +++ b/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte @@ -0,0 +1,71 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Listening to GCP Pub/Sub events... +

+ {:else} +

+ Start capturing to listen to GCP Pub/Sub events. +

+ {/if} + {/snippet} + + {#if deliveryType === 'push'} + {@const captureUrl = getCaptureUrl(captureInfo)} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte index 1b2f8cf389..2a7c31512a 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import GcpTriggerEditorInner from './GcpTriggerEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -12,16 +14,17 @@ export async function openNew( is_flow: boolean, initial_script_path?: string, - defaultValues?: Record + defaultValues?: Record, + newDraft?: boolean ) { open = true await tick() drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: GcpTriggerEditorInner + let drawer: GcpTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte index 816dffd90f..0fff25fab4 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte @@ -1,10 +1,8 @@
- {#if showCapture && captureInfo} - {@const captureURL = `${base_endpoint}/${path}`} - - {#if delivery_type === 'push'} - - {/if} - - {/if}
+ + {#if showTestingBadge} + + {/if} +
@@ -208,7 +191,7 @@ {#if subscription_mode === 'create_update'}
{#if delivery_type === 'push' && delivery_config}
- -
- - -
-
+
+ +

Enable Google Cloud authentication for push delivery using a verified token. If the subscription uses push delivery, its endpoint URL must - match the following format: {base_endpoint}/*, meaning it must - start with - {base_endpoint} followed by any path segment. + match the following format: {`${base_endpoint}/${path}`}/*, + meaning it must start with + {`${base_endpoint}/${path}`} followed by any path segment.

{/if} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 993fc6611a..3c1f7fd5d7 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -5,10 +5,8 @@ import Path from '$lib/components/Path.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import { GcpTriggerService, type DeliveryType, @@ -20,34 +18,83 @@ import Required from '$lib/components/Required.svelte' import GcpTriggerEditorConfigSection from './GcpTriggerEditorConfigSection.svelte' import { base } from '$app/paths' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveGcpTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let is_loading = false - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let delivery_type: DeliveryType = 'pull' - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let topic_id: string = '' - let gcp_resource_path: string = '' - let subscription_id: string = '' - let isValid = false - let delivery_config: PushConfig | undefined = undefined - let subscription_mode: SubscriptionMode = 'create_update' - const dispatch = createEventDispatcher() + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let delivery_type: DeliveryType = $state('pull') + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let topic_id: string = $state('') + let gcp_resource_path: string = $state('') + let subscription_id: string = $state('') + let isValid = $state(false) + let delivery_config: PushConfig | undefined = $state(undefined) + let subscription_mode: SubscriptionMode = $state('create_update') + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + let base_endpoint = $derived(`${window.location.origin}${base}`) - $: is_flow = itemKind === 'flow' + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isDeployed = false, + customLabel = undefined, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined, + cloudDisabled = false + }: { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isDeployed?: boolean + customLabel?: Snippet + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + cloudDisabled?: boolean + } = $props() - export async function openEdit(ePath: string, isFlow: boolean) { + const gcpConfig = $derived.by(getGcpConfig) + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !isValid || !can_write + ) + const captureConfig = $derived.by(isEditor ? getGcpCaptureConfig : () => ({})) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultValues?: Record + ) { drawerLoading = true try { drawer?.openDrawer() @@ -55,11 +102,14 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultValues) } catch (err) { sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true) } finally { drawerLoading = false + if (!defaultValues) { + initialConfig = structuredClone($state.snapshot(getGcpConfig())) + } } } @@ -82,144 +132,183 @@ subscription_id = '' topic_id = defaultValues?.topic_id subscription_mode = defaultValues?.subscription_mode ?? 'create_update' - path = '' + path = defaultValues?.path ?? '' initialPath = '' edit = false dirtyPath = false + enabled = defaultValues?.enabled ?? false } finally { drawerLoading = false } } - async function loadTrigger(): Promise { - try { - const s = await GcpTriggerService.getGcpTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path - gcp_resource_path = s.gcp_resource_path - delivery_type = s.delivery_type - subscription_id = s.subscription_id - delivery_config = s.delivery_config - subscription_mode = s.subscription_mode - is_flow = s.is_flow - path = s.path - enabled = s.enabled - topic_id = s.topic_id - can_write = canWrite(s.path, s.extra_perms, $userStore) - } catch (error) { - sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true) + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + try { + const s = await GcpTriggerService.getGcpTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } catch (error) { + sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true) + } } } + async function loadTriggerConfig(cfg?: Record): Promise { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + gcp_resource_path = cfg?.gcp_resource_path + delivery_type = cfg?.delivery_type + subscription_id = cfg?.subscription_id + delivery_config = cfg?.delivery_config + subscription_mode = cfg?.subscription_mode + is_flow = cfg?.is_flow + path = cfg?.path + enabled = cfg?.enabled + topic_id = cfg?.topic_id + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } + async function updateTrigger(): Promise { - try { - is_loading = true - const base_endpoint = `${window.location.origin}${base}` - if (delivery_type === 'push') { - if (!delivery_config) { - sendUserToast('Must set route path when delivery type is push', true) - return - } - } else { - delivery_config = undefined - } - if (edit) { - await GcpTriggerService.updateGcpTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - gcp_resource_path, - subscription_mode, - subscription_id, - delivery_type, - delivery_config, - base_endpoint, - topic_id, - path, - script_path, - enabled, - is_flow - } - }) - sendUserToast(`GCP Pub/Sub trigger ${path} updated`) - } else { - await GcpTriggerService.createGcpTrigger({ - workspace: $workspaceStore!, - requestBody: { - gcp_resource_path, - subscription_mode, - subscription_id, - delivery_type, - delivery_config, - base_endpoint, - topic_id, - path, - script_path, - enabled: true, - is_flow - } - }) - sendUserToast(`GCP Pub/Sub trigger ${path} created`) - } + deploymentLoading = true + const cfg = gcpConfig + if (!cfg) { + return + } + const isSaved = await saveGcpTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false + } - if (!$usedTriggerKinds.includes('gcp')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'gcp'] - } - dispatch('update') - drawer.closeDrawer() - is_loading = false - } catch (error) { - is_loading = false - sendUserToast(error.body, true) + function getGcpConfig() { + return { + gcp_resource_path, + subscription_mode, + subscription_id, + delivery_type, + delivery_config, + base_endpoint, + topic_id, + path, + script_path, + enabled, + is_flow } } + + function getGcpCaptureConfig() { + return { + gcp_resource_path, + subscription_mode, + subscription_id, + delivery_type, + delivery_config, + base_endpoint, + topic_id, + path + } + } + + async function handleToggleEnabled(toggleEnabled: boolean) { + enabled = toggleEnabled + if (!isDraftOnly && !hasDraft) { + await GcpTriggerService.setGcpTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: toggleEnabled } + }) + sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} GCP Pub/Sub trigger ${initialPath}`) + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(gcpConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - sendUserToast( - `${e.detail ? 'enabled' : 'disabled'} GCP Pub/Sub trigger ${initialPath}` - ) - }} - /> -
- {/if} - +{#if useDrawer} + + + + {@render actionsButtons()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} -
+ + {@render actionsButtons()} + + {@render config()} +
+{/if} + +{#snippet actionsButtons()} + {#if !drawerLoading && can_write} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} +
+ +

Loading...

+
+ {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} {#if edit} Changes can take up to 30 seconds to take effect. @@ -227,23 +316,26 @@ New GCP Pub/Sub trigger can take up to 30 seconds to start listening. {/if} + {/if} +
+
+
+
-
-
- -
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -256,35 +348,38 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyString(script_path)} {/if}

+ {/if} - -
- {/if} - - + +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte index fe9c587c1b..348a3a17b8 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte @@ -1,131 +1,68 @@ - { - loadTriggers() - }} - bind:this={gcpTriggerEditor} -/> + onMount(() => { + gcpTriggerEditor && openGcpTriggerEditor(isFlow, selectedTrigger.isDraft ?? false) + }) + + const cloudDisabled = $derived(isCloudHosted()) + {#if !$enterpriseLicense} GCP Pub/Sub triggers are an enterprise only feature. -{:else if isCloudHosted()} - - GCP Pub/Sub triggers are disabled in the multi-tenant cloud. - {:else}
- - GCP Pub/Sub triggers allow your scripts or flows to process messages from Google Cloud - Pub/Sub in real time. Each trigger listens to a Pub/Sub subscription and executes a script or - a flow when new messages are published to the corresponding topic. - - - {#if !newItem && gcpTriggers && gcpTriggers.length > 0} -
-
-
- {#each gcpTriggers as gcpTrigger (gcpTrigger.path)} -
-
{gcpTrigger.path}
- -
- -
-
- {/each} -
-
-
- {/if} - - { - gcpTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="gcp" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + + {#snippet description()} + {#if cloudDisabled} + + GCP Pub/Sub triggers are disabled in the multi-tenant cloud. + + {:else} + + GCP Pub/Sub triggers execute scripts and flows in response to messages published to + Google Cloud Pub/Sub topics. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/gcp/utils.ts b/frontend/src/lib/components/triggers/gcp/utils.ts new file mode 100644 index 0000000000..e2bebce6cd --- /dev/null +++ b/frontend/src/lib/components/triggers/gcp/utils.ts @@ -0,0 +1,52 @@ +import { GcpTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveGcpTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + try { + const requestBody = { + gcp_resource_path: cfg.gcp_resource_path, + subscription_mode: cfg.subscription_mode, + subscription_id: cfg.subscription_id, + delivery_type: cfg.delivery_type, + delivery_config: cfg.delivery_config, + base_endpoint: cfg.base_endpoint, + topic_id: cfg.topic_id, + path: cfg.path, + script_path: cfg.script_path, + enabled: cfg.enabled, + is_flow: cfg.is_flow + } + if (edit) { + await GcpTriggerService.updateGcpTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`GCP Pub/Sub trigger ${cfg.path} updated`) + } else { + await GcpTriggerService.createGcpTrigger({ + workspace: workspace, + requestBody: { + ...requestBody, + enabled: true + } + }) + sendUserToast(`GCP Pub/Sub trigger ${cfg.path} created`) + } + + if (!get(usedTriggerKinds).includes('gcp')) { + usedTriggerKinds.update((t) => [...t, 'gcp']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte b/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte index d9f129851f..1fa781d089 100644 --- a/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte +++ b/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte @@ -2,13 +2,25 @@ import Label from '$lib/components/Label.svelte' import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' + import type { Snippet } from 'svelte' - export let raw_string: boolean - export let wrap_body: boolean + interface Props { + raw_string: boolean + wrap_body: boolean + disabled?: boolean + testingBadge?: Snippet | undefined + } + + let { + raw_string = $bindable(), + wrap_body = $bindable(), + disabled = false, + testingBadge = undefined + }: Props = $props() diff --git a/frontend/src/lib/components/triggers/http/RouteCapture.svelte b/frontend/src/lib/components/triggers/http/RouteCapture.svelte new file mode 100644 index 0000000000..983c6f3cd3 --- /dev/null +++ b/frontend/src/lib/components/triggers/http/RouteCapture.svelte @@ -0,0 +1,87 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Send a POST request to the URL below to simulate a http event. +

+ {:else} +

+ Start capturing to listen to HTTP requests on this test URL. +

+ {/if} + {/snippet} + + + + +
+{/if} diff --git a/frontend/src/lib/components/triggers/http/RouteEditor.svelte b/frontend/src/lib/components/triggers/http/RouteEditor.svelte index ce093aa976..00914e8172 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditor.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditor.svelte @@ -2,7 +2,13 @@ import { tick } from 'svelte' import RouteEditorInner from './RouteEditorInner.svelte' - let open = false + interface Props { + onUpdate?: (cfg?: Record) => void + } + + let { onUpdate = undefined }: Props = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,13 +25,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - export async function getTriggers() { - return drawer?.getTriggers() - } - - let drawer: RouteEditorInner + let drawer: RouteEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte index 0c523decd5..ae51547ef7 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte @@ -1,25 +1,16 @@
- {#if showCapture && captureInfo} - {@const captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${ - captureInfo.isFlow ? 'flow' : 'script' - }/${captureInfo.path.replaceAll('/', '.')}/${route_path}`} - {@const cleanedRunnableArgs = - isObject(runnableArgs) && 'wm_trigger' in runnableArgs - ? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger')) - : runnableArgs} - - - - - - {/if}
- {#if !($userStore?.is_admin || $userStore?.is_super_admin)} - + + {#if showTestingBadge} + + {/if} + + {#if !userCanEditConfig && isDraftOnly} + Route endpoints can only be edited by workspace admins
@@ -144,7 +104,7 @@ type="text" autocomplete="off" bind:value={route_path} - disabled={!($userStore?.is_admin || $userStore?.is_super_admin) || !can_write} + disabled={!userCanEditConfig || !can_write} class={routeError === '' ? '' : 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'} @@ -158,44 +118,28 @@ - - - - - + + + + + -
-
- - Full endpoint - - { - currentTarget.select() - }} - /> -
+
+ +
{dirtyRoutePath ? routeError : ''}
- {#if !capture_mode && !isCloudHosted()} + {#if !isCloudHosted()}
{ workspaced_route = !workspaced_route dirtyRoutePath = true @@ -211,9 +155,6 @@
{/if}
- {#if capture_mode} - - {/if}
diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 427540b288..59661b89d9 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -8,9 +8,8 @@ import { HttpTriggerService, VariableService, type AuthenticationMethod } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save, Pipette, Plus } from 'lucide-svelte' + import { Loader2, Pipette, Plus } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import VariableEditor from '../../VariableEditor.svelte' import { json } from 'svelte-highlight/languages' @@ -27,42 +26,94 @@ import ResourcePicker from '$lib/components/ResourcePicker.svelte' import ItemPicker from '../../ItemPicker.svelte' import { Popover } from '$lib/components/meltComponents' - import { HUB_SCRIPT_ID, SECRET_KEY_PATH } from './utils' + import { HUB_SCRIPT_ID, saveHttpRouteFromCfg, SECRET_KEY_PATH } from './utils' import { HubFlow } from '$lib/hub' import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte' - let is_flow: boolean = false - let initialPath = '' - let edit = true + import TestingBadge from '../testingBadge.svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { handleConfigChange } from '../utils' - let itemKind: 'flow' | 'script' = 'script' + let { + useDrawer = true, + hideTarget = false, + description = undefined, + isEditor = false, + customLabel = undefined, + isDraftOnly = false, + allowDraft = false, + hasDraft = false, + isDeployed = false, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + } = $props() - $: is_flow = itemKind === 'flow' - - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let isValid = false - let dirtyRoutePath = false - let is_async = false - let authentication_method: AuthenticationMethod = 'none' - let route_path = '' - let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post' - let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined = + // Form data state + let initialPath = $state('') + let edit = $state(true) + let itemKind = $state<'flow' | 'script'>('script') + let is_flow = $state(false) + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path = $state('') + let pathError = $state('') + let isValid = $state(false) + let dirtyRoutePath = $state(false) + let dirtyPath = $state(false) + let is_async = $state(false) + let authentication_method = $state('none') + let route_path = $state('') + let http_method = $state<'get' | 'post' | 'put' | 'patch' | 'delete'>('post') + let static_asset_config = $state<{ s3: string; storage?: string; filename?: string } | undefined>( undefined - let is_static_website: boolean = false - let s3FilePicker: S3FilePicker - let s3FileUploadRawMode = false - let s3Editor: SimpleEditor | undefined = undefined - let workspaced_route: boolean = false - let raw_string = false - let wrap_body = false - let drawerLoading = true - let authentication_resource_path: string = '' - let variablePicker: ItemPicker - let variableEditor: VariableEditor - let variable_path: string = '' + ) + let is_static_website = $state(false) + let s3FileUploadRawMode = $state(false) + let workspaced_route = $state(false) + let raw_string = $state(false) + let wrap_body = $state(false) + let drawerLoading = $state(true) + let showLoader = $state(false) + let authentication_resource_path = $state('') + let variable_path = $state('') + let signature_options_type = $state<'custom_script' | 'custom_signature'>('custom_signature') + let can_write = $state(true) + let extraPerms = $state | undefined>(undefined) + + // Component references + let s3FilePicker = $state(null) + let s3Editor = $state(null) + let variablePicker = $state(null) + let variableEditor = $state(null) + let drawer = $state(null) + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + const routeConfig = $derived.by(getRouteConfig) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + const saveDisabled = $derived( + drawerLoading || + !can_write || + pathError != '' || + !isValid || + (!static_asset_config && emptyString(script_path)) + ) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + // Update is_static_website based on static_asset_config + $effect(() => { + if (!static_asset_config) { + is_static_website = false + } + }) + type AuthenticationOption = { label: string value: AuthenticationMethod @@ -74,8 +125,6 @@ return await VariableService.listVariable({ workspace: $workspaceStore ?? '' }) } - let signature_options_type: 'custom_script' | 'custom_signature' = 'custom_signature' - const authentication_options: AuthenticationOption[] = [ { label: 'No Auth', @@ -108,20 +157,34 @@ } ] - export async function openEdit(ePath: string, isFlow: boolean) { + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { drawerLoading = true + let loader = setTimeout(() => { + showLoader = true + }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() initialPath = ePath + path = ePath itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false dirtyRoutePath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load route: ${err}`, true) } finally { + if (!defaultConfig) { + // If the route is loaded from the backend, we to set the initial config + initialConfig = structuredClone($state.snapshot(getRouteConfig())) + } + clearTimeout(loader) drawerLoading = false + showLoader = false } } @@ -131,6 +194,9 @@ defaultValues?: Record ) { drawerLoading = true + let loader = setTimeout(() => { + showLoader = true + }, 100) // if loading takes less than 100ms, we don't show the loader try { drawer?.openDrawer() is_flow = nis_flow @@ -146,7 +212,7 @@ script_path = fixedScriptPath static_asset_config = undefined s3FileUploadRawMode = false - path = '' + path = defaultValues?.path ?? '' initialPath = '' dirtyPath = false is_static_website = false @@ -157,47 +223,73 @@ raw_string = defaultValues?.raw_string ?? false wrap_body = defaultValues?.wrap_body ?? false } finally { + clearTimeout(loader) drawerLoading = false + showLoader = false } } - const dispatch = createEventDispatcher() - - let can_write = true - async function loadTrigger(): Promise { - const s = await HttpTriggerService.getHttpTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - - script_path = s.script_path - initialScriptPath = s.script_path - is_flow = s.is_flow - path = s.path - route_path = s.route_path - http_method = s.http_method ?? 'post' - is_async = s.is_async - workspaced_route = s.workspaced_route - wrap_body = s.wrap_body - raw_string = s.raw_string - authentication_resource_path = s.authentication_resource_path ?? '' - if (s.authentication_method === 'custom_script') { + function loadTriggerConfig(cfg?: Record): void { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + route_path = cfg?.route_path + http_method = cfg?.http_method ?? 'post' + is_async = cfg?.is_async + workspaced_route = cfg?.workspaced_route + wrap_body = cfg?.wrap_body + raw_string = cfg?.raw_string + authentication_resource_path = cfg?.authentication_resource_path ?? '' + if (cfg?.authentication_method === 'custom_script') { authentication_method = 'signature' signature_options_type = 'custom_script' } else { - authentication_method = s.authentication_method + authentication_method = cfg?.authentication_method signature_options_type = 'custom_signature' } if (!isCloudHosted()) { - static_asset_config = s.static_asset_config - s3FileUploadRawMode = !!static_asset_config - is_static_website = s.is_static_website + static_asset_config = cfg?.static_asset_config + s3FileUploadRawMode = !!cfg?.static_asset_config + is_static_website = cfg?.is_static_website } + extraPerms = cfg?.extra_perms + can_write = canWrite(path, cfg?.extra_perms, $userStore) + } - can_write = canWrite(s.path, s.extra_perms, $userStore) + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await HttpTriggerService.getHttpTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + + loadTriggerConfig(s) + } } async function triggerScript(): Promise { + deploymentLoading = true + const saveCfg = routeConfig + const isSaved = await saveHttpRouteFromCfg( + initialPath, + saveCfg, + edit, + $workspaceStore!, + !!$userStore?.is_admin || !!$userStore?.is_super_admin, + usedTriggerKinds + ) + if (isSaved) { + onUpdate(saveCfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false + } + + function getRouteConfig(): Record { // If the user selects "signature" with the "custom_script" option, // we explicitly set the authentication method to "custom_script" // (which is a valid enum on its own in the backend) @@ -205,60 +297,47 @@ authentication_method === 'signature' && signature_options_type === 'custom_script' ? 'custom_script' : authentication_method - - if (edit) { - await HttpTriggerService.updateHttpTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - is_async, - authentication_method: auth_method, - route_path: $userStore?.is_admin || $userStore?.is_super_admin ? route_path : undefined, - http_method, - static_asset_config, - is_static_website, - workspaced_route, - authentication_resource_path, - wrap_body, - raw_string - } - }) - sendUserToast(`Route ${path} updated`) - } else { - await HttpTriggerService.createHttpTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - is_async, - authentication_method: auth_method, - route_path, - http_method, - static_asset_config, - is_static_website, - workspaced_route, - authentication_resource_path, - wrap_body, - raw_string - } - }) - sendUserToast(`Route ${path} created`) + const nCfg = { + script_path, + is_flow, + path, + route_path, + http_method, + is_async, + workspaced_route, + wrap_body, + raw_string, + authentication_resource_path, + authentication_method: auth_method, + static_asset_config, + is_static_website, + extra_perms: extraPerms } - if (!$usedTriggerKinds.includes('http')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'http'] - } - dispatch('update') - drawer.closeDrawer() + return nCfg } - let drawer: Drawer - let dirtyPath = false + // Update config for captures + function getCaptureConfig() { + const newCaptureConfig = { + route_path: routeConfig.route_path, + http_method: routeConfig.http_method, + raw_string: routeConfig.raw_string, + wrap_body: routeConfig.wrap_body, + path: routeConfig.path + } + // + return newCaptureConfig + } - $: !static_asset_config && (is_static_website = false) + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(routeConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) {#if static_asset_config} @@ -274,46 +353,30 @@ /> {/if} - - - - {#if !drawerLoading && can_write} - - {/if} - - {#if drawerLoading} +{#snippet config()} + {#if drawerLoading} + {#if showLoader} - {:else} -
-
- -
+ {/if} + {:else} +
+
+ +
+ {#if !hideTarget}
{#if !isCloudHosted()} - - - + + + {/if} @@ -371,13 +435,7 @@ {#if s3FileUploadRawMode} {#if can_write} { - dispatch('focus') - }} - on:blur={(e) => { - dispatch('blur') - }} + bind:editor={s3Editor as any} code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)} bind:value={static_asset_config} /> @@ -456,183 +514,257 @@
{/if} + {/if} - + - {#if !is_static_website} -
-
- {#if !static_asset_config} -
- + {#if !is_static_website} +
+
+ {#if !static_asset_config} +
+ +
+ {/if} + + + {#each authentication_options as option} + {#if option.resource_type && authentication_method === option.value} + + {/if} + {/each} + + {#if authentication_method === 'signature'} + {#if signature_options_type === 'custom_signature'} + + {:else if signature_options_type === 'custom_script'} +

+ Pick a secret variable or create one which will be used as a secret key for your + custom script/flow
+

+
+
+ + +
+
{/if} - + {/if} - {#each authentication_options as option} - {#if option.resource_type && authentication_method === option.value} - - {/if} - {/each} + +
+
+ {/if} +
+ {/if} +{/snippet} - {#if authentication_method === 'signature'} - {#if signature_options_type === 'custom_signature'} - - {:else if signature_options_type === 'custom_script'} -

- Pick a secret variable or create one which will be used as a secret key for your - custom script/flow
-

-
-
- - -
- -
- {/if} - {/if} +{#snippet testingBadge()} + {#if isEditor} + + {/if} +{/snippet} - {#if !static_asset_config} - - {/if} -
-
- {/if} -
+{#snippet saveButton()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#if useDrawer} + + drawer?.closeDrawer()} + > + + {@render saveButton()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} + {/if} + + + {@render saveButton()} + + {#if description} + {@render description()} {/if} - - + {@render config()} +
+{/if} variableEditor.editVariable(x) }} + buttons={{ 'Edit/View': (x) => variableEditor?.editVariable(x) }} >
- + variablePicker?.openDrawer()} /> diff --git a/frontend/src/lib/components/triggers/http/RoutesPanel.svelte b/frontend/src/lib/components/triggers/http/RoutesPanel.svelte index 4c215022d4..3e34e3a56d 100644 --- a/frontend/src/lib/components/triggers/http/RoutesPanel.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesPanel.svelte @@ -1,123 +1,57 @@ - { - loadTriggers() - }} + + hideTarget + {isEditor} + {customLabel} + isDraftOnly={selectedTrigger.isDraft} + allowDraft + hasDraft={!!selectedTrigger.draftConfig} + {...restProps} +> + {#snippet description()} +
+ Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a + specific HTTP method and path. -
- - Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a - specific HTTP method and path. - - {#if !newItem && httpTriggers && httpTriggers.length > 0} -
- {#if !$userStore?.is_admin && !$userStore?.is_super_admin} - + {#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft} + {/if} - -
- {#each httpTriggers as httpTriggers (httpTriggers.path)} -
-
{httpTriggers.path}
-
- {httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path} -
-
- -
-
- {/each} -
-
- {/if} - - { - routeEditor?.openNew(isFlow, path, e.detail.config) - }} - on:applyArgs - on:addPreprocessor - on:updateSchema - on:testWithArgs - bind:showCapture={dontCloseOnLoad} - cloudDisabled={false} - triggerType="http" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - data={{ args }} - bind:openForm - /> -
+
+ {/snippet} +
diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index 4deb21fc4c..04734b2c81 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -1,5 +1,9 @@ import { base } from '$lib/base' import { isCloudHosted } from '$lib/cloud' +import { HttpTriggerService } from '$lib/gen/services.gen' +import { sendUserToast } from '$lib/toast' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' export const SECRET_KEY_PATH = 'secret_key_path' export const HUB_SCRIPT_ID = 19670 @@ -25,3 +29,50 @@ export function replacePlaceholderForSignatureScriptTemplate(content: string) { `$1${secret_key_path}$2` ) } + +export async function saveHttpRouteFromCfg( + initialPath: string, + routeCfg: Record, + edit: boolean, + workspace: string, + isAdmin: boolean, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: routeCfg.path, + script_path: routeCfg.script_path, + is_flow: routeCfg.is_flow, + is_async: routeCfg.is_async, + authentication_method: routeCfg.authentication_method, + route_path: isAdmin || !edit ? routeCfg.route_path : undefined, + http_method: routeCfg.http_method, + is_static_website: routeCfg.is_static_website, + workspaced_route: routeCfg.workspaced_route, + authentication_resource_path: routeCfg.authentication_resource_path, + wrap_body: routeCfg.wrap_body, + raw_string: routeCfg.raw_string + } + try { + if (edit) { + await HttpTriggerService.updateHttpTrigger({ + workspace: workspace, + path: initialPath, + requestBody: requestBody + }) + sendUserToast(`Route ${routeCfg.path} updated`) + } else { + await HttpTriggerService.createHttpTrigger({ + workspace: workspace, + requestBody: requestBody + }) + sendUserToast(`Route ${routeCfg.path} created`) + } + if (!get(usedTriggerKinds).includes('http')) { + usedTriggerKinds.update((t) => [...t, 'http']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaCapture.svelte b/frontend/src/lib/components/triggers/kafka/KafkaCapture.svelte new file mode 100644 index 0000000000..ce02db0b94 --- /dev/null +++ b/frontend/src/lib/components/triggers/kafka/KafkaCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to Kafka events... +

+ {:else} +

+ Connecting to kafka... +

+ {/if} + {:else} +

+ Start capturing to listen to Kafka events. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte index 4044225223..dac8ec1dc7 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import KafkaTriggerEditorInner from './KafkaTriggerEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: KafkaTriggerEditorInner + let drawer: KafkaTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 792fd7f744..7349e07d6c 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -8,35 +8,107 @@ import { KafkaTriggerService } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveKafkaTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let defaultValues: Record | undefined = undefined - let args: Record = {} + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + customLabel?: Snippet + isDeployed?: boolean + cloudDisabled?: boolean + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - const dispatch = createEventDispatcher() + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + customLabel, + isDeployed = false, + cloudDisabled = false, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() - $: is_flow = itemKind === 'flow' + let drawer: Drawer | undefined = $state() + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let initialConfig: Record | undefined = undefined + let extra_perms = $state | undefined>(undefined) + let kafkaCfgValid = $state(false) + let kafkaResourcePath = $state('') + let kafkaCfg: Record = $state({}) + let deploymentLoading = $state(false) - export async function openEdit(ePath: string, isFlow: boolean) { + const isValid = $derived( + !!kafkaResourcePath && + kafkaCfgValid && + kafkaCfg.topics && + kafkaCfg.topics.length > 0 && + kafkaCfg.topics.every((b) => /^[a-zA-Z0-9-_.]+$/.test(b)) + ) + const saveDisabled = $derived( + pathError !== '' || + !isValid || + drawerLoading || + !can_write || + emptyString(script_path) || + emptyString(kafkaResourcePath) || + kafkaCfg.topics.length === 0 || + kafkaCfg.topics.some((t) => emptyString(t)) + ) + const kafkaConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -44,11 +116,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load Kafka trigger: ${err}`, true) } finally { + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -57,164 +134,209 @@ fixedScriptPath_?: string, nDefaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() is_flow = nis_flow edit = false itemKind = nis_flow ? 'flow' : 'script' - args.kafka_resource_path = nDefaultValues?.kafka_resource_path ?? '' - args.group_id = nDefaultValues?.group_id ?? '' - args.topics = nDefaultValues?.topics ?? [''] + kafkaResourcePath = nDefaultValues?.kafka_resource_path ?? '' + kafkaCfg = { + group_id: nDefaultValues?.group_id ?? '', + topics: nDefaultValues?.topics ?? [''] + } initialScriptPath = '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath - path = '' + path = nDefaultValues?.path ?? '' initialPath = '' dirtyPath = false - defaultValues = nDefaultValues } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { - const s = await KafkaTriggerService.getKafkaTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path + function loadTriggerConfig(cfg?: Record): void { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + kafkaResourcePath = cfg?.kafka_resource_path + kafkaCfg = { + group_id: cfg?.group_id, + topics: cfg?.topics + } + enabled = cfg?.enabled + extra_perms = cfg?.extra_perms + can_write = canWrite(path, cfg?.extra_perms, $userStore) + } - is_flow = s.is_flow - path = s.path - args.kafka_resource_path = s.kafka_resource_path - args.group_id = s.group_id - args.topics = s.topics - enabled = s.enabled + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await KafkaTriggerService.getKafkaTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } + } - can_write = canWrite(s.path, s.extra_perms, $userStore) + function getSaveCfg(): Record { + return { + path, + script_path, + is_flow, + kafka_resource_path: kafkaResourcePath, + group_id: kafkaCfg.group_id, + topics: kafkaCfg.topics, + enabled, + extra_perms: extra_perms + } } async function updateTrigger(): Promise { - if (edit) { - await KafkaTriggerService.updateKafkaTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - kafka_resource_path: args.kafka_resource_path, - group_id: args.group_id, - topics: args.topics - } - }) - sendUserToast(`Kafka trigger ${path} updated`) - } else { - await KafkaTriggerService.createKafkaTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - enabled: true, - kafka_resource_path: args.kafka_resource_path, - group_id: args.group_id, - topics: args.topics - } - }) - sendUserToast(`Kafka trigger ${path} created`) - } - if (!$usedTriggerKinds.includes('kafka')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'kafka'] - } - dispatch('update') - drawer.closeDrawer() - } - - function useDefaultValues() { - if (args.kafka_resource_path && args.kafka_resource_path != '') { - return false - } - if (!defaultValues) { - return false - } - return ( - defaultValues.brokers && - defaultValues.brokers.length > 0 && - defaultValues.brokers.some((broker: string) => broker.trim() !== '') + deploymentLoading = true + const cfg = getSaveCfg() + const isSaved = await saveKafkaTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false } - let isValid = false + function getCaptureConfig(): Record { + return { + kafka_resource_path: kafkaResourcePath, + group_id: kafkaCfg.group_id, + topics: structuredClone($state.snapshot(kafkaCfg.topics)), + path + } + } + + async function handleToggleEnabled(nEnabled: boolean) { + await KafkaTriggerService.setKafkaTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: nEnabled } + }) + sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} Kafka trigger ${initialPath}`) + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(kafkaConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading} - {#if edit} -
- { - await KafkaTriggerService.setKafkaTriggerEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} Kafka trigger ${initialPath}`) - }} - /> -
- {/if} - {#if can_write} - - {/if} +{#if useDrawer} + + + + {@render actionsButtons('sm')} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} - - {:else} - - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - Kafka consumers can take up to 30 seconds to start. - {/if} - -
-
- -
+ + {@render actionsButtons('xs')} + + {@render config()} +
+{/if} +{#snippet actionsButtons(size: 'xs' | 'sm' = 'sm')} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} + + {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + Kafka consumers can take up to 30 seconds to start. + {/if} + + {/if} +
+
+
+ +
+ + {#if !hideTarget}

Pick a script or flow to be triggered @@ -235,20 +357,23 @@ btnClasses="ml-4 mt-2" color="dark" size="xs" + disabled={!can_write} href={itemKind === 'flow' ? '/flows/add?hub=65' : '/scripts/add?hub=hub%2F19659'} target="_blank">Create from template {/if}

+ {/if} - -
- {/if} - - + +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte index 0b287d040e..83f4720736 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte @@ -1,162 +1,31 @@
- {#if showCapture && captureInfo} - - {/if} -
+
+ {#snippet header()} + {#if showTestingBadge} + + {/if} + {/snippet}
- - {#if !staticInputDisabled} - { - if (ev.detail === 'static') { - delete args.kafka_resource_path - args.brokers = [''] - args.security = { - label: 'PLAINTEXT' - } - } else { - delete args.brokers - delete args.security - } - }} - let:item - > - - - - {/if} - - - {#if selected === 'resource'} - - {:else} - args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={isStaticConnectionValid} - lightHeader={true} - /> - {/if} - {#if isConnectionValid} + + {#if !!kafkaResourcePath} {/if} @@ -287,18 +90,11 @@
args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={otherArgsValid} + schema={kafkaConfigSchema} + bind:args={kafkaCfg} + bind:isValid={kafkaCfgValid} lightHeader={true} + disabled={!can_write} />
diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte index 364a976921..d11fe49768 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte @@ -1,137 +1,69 @@ - { - loadTriggers() - }} - bind:this={kafkaTriggerEditor} -/> - {#if !$enterpriseLicense} Kafka triggers are an enterprise only feature. -{:else if isCloudHosted()} - - Kafka triggers are disabled in the multi-tenant cloud. - {:else}
- - Kafka triggers execute scripts and flows in response to messages published to Kafka topics. - - {#if !newItem && kafkaTriggers && kafkaTriggers.length > 0} -
-
- {#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)} -
-
{kafkaTrigger.path}
-
- {kafkaTrigger.kafka_resource_path} -
-
- -
-
- {/each} -
-
- {/if} - { - saveTrigger(path, e.detail.config) - }} - on:applyArgs - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="kafka" - {isFlow} - {data} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + + {#snippet description()} + {#if cloudDisabled} + + Kafka triggers are disabled in the multi-tenant cloud. + + {:else} + + Kafka triggers execute scripts and flows in response to messages published to Kafka + topics. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts new file mode 100644 index 0000000000..d87a730d1b --- /dev/null +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -0,0 +1,43 @@ +import { KafkaTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveKafkaTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + kafka_resource_path: cfg.kafka_resource_path, + group_id: cfg.group_id, + topics: cfg.topics + } + try { + if (edit) { + await KafkaTriggerService.updateKafkaTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`Kafka trigger ${cfg.path} updated`) + } else { + await KafkaTriggerService.createKafkaTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`Kafka trigger ${cfg.path} created`) + } + if (!get(usedTriggerKinds).includes('kafka')) { + usedTriggerKinds.update((t) => [...t, 'kafka']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte b/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte new file mode 100644 index 0000000000..e31e8edd6f --- /dev/null +++ b/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to MQTT messages... +

+ {:else} +

+ Connecting to mqtt... +

+ {/if} + {:else} +

+ Start capturing to listen to MQTT messages. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte b/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte index b2c11571ae..ae78ff3143 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte @@ -1,7 +1,5 @@
- {#if showCapture && captureInfo} - - {/if}
+ + {#if showTestingBadge} + + {/if} +
diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte index 2bf129ae4d..ac74859255 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import MqttTriggerEditorInner from './MqttTriggerEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: MqttTriggerEditorInner + let drawer: MqttTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 3a94f63a9d..0f4b31465e 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -7,11 +7,9 @@ import ScriptPicker from '$lib/components/ScriptPicker.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import { MqttTriggerService, type MqttClientVersion, @@ -20,33 +18,92 @@ type MqttSubscribeTopic } from '$lib/gen' import MqttEditorConfigSection from './MqttEditorConfigSection.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveMqttTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let mqtt_resource_path: string = '' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let subscribe_topics: MqttSubscribeTopic[] = [] - let v3_config: MqttV3Config | undefined - let v5_config: MqttV5Config | undefined - let client_version: MqttClientVersion | undefined - let client_id: string - let isValid: boolean - const dispatch = createEventDispatcher() + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + customLabel?: Snippet + isDeployed?: boolean + cloudDisabled?: boolean + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - $: is_flow = itemKind === 'flow' + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + customLabel = undefined, + isDeployed = false, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined, + cloudDisabled = false + }: Props = $props() - export async function openEdit(ePath: string, isFlow: boolean) { + let mqtt_resource_path: string = $state('') + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let subscribe_topics: MqttSubscribeTopic[] = $state([]) + let v3_config: MqttV3Config | undefined = $state() + let v5_config: MqttV5Config | undefined = $state() + let client_version: MqttClientVersion | undefined = $state() + let client_id: string | undefined = $state(undefined) + let isValid: boolean | undefined = $state(undefined) + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const mqttConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !can_write || !isValid + ) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -54,11 +111,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) } finally { + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -67,9 +129,12 @@ fixedScriptPath_?: string, defaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) drawerLoading = true try { - mqtt_resource_path = '' + mqtt_resource_path = defaultValues?.mqtt_resource_path ?? '' drawer?.openDrawer() is_flow = nis_flow itemKind = nis_flow ? 'flow' : 'script' @@ -77,149 +142,212 @@ fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath subscribe_topics = defaultValues?.topics ?? [] - path = '' + path = defaultValues?.path ?? '' initialPath = '' edit = false dirtyPath = false client_version = defaultValues?.client_version ?? 'v5' client_id = defaultValues?.client_id ?? '' + enabled = defaultValues?.enabled ?? false } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { + async function loadTriggerConfig(cfg?: Record): Promise { try { - const s = await MqttTriggerService.getMqttTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - mqtt_resource_path = s.mqtt_resource_path - subscribe_topics = s.subscribe_topics - script_path = s.script_path - initialScriptPath = s.script_path - is_flow = s.is_flow - path = s.path - enabled = s.enabled - client_version = s.client_version - v3_config = s.v3_config - v5_config = s.v5_config - client_id = s.client_id ?? '' - can_write = canWrite(s.path, s.extra_perms, $userStore) + mqtt_resource_path = cfg?.mqtt_resource_path + subscribe_topics = cfg?.subscribe_topics + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + enabled = cfg?.enabled + client_version = cfg?.client_version + v3_config = cfg?.v3_config + v5_config = cfg?.v5_config + client_id = cfg?.client_id ?? '' + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } catch (error) { + sendUserToast(`Could not load mqtt trigger config: ${error.body}`, true) + } + } + + async function loadTrigger(defaultConfig?: Record): Promise { + try { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await MqttTriggerService.getMqttTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } } catch (error) { sendUserToast(`Could not load mqtt trigger: ${error.body}`, true) } } - async function updateTrigger(): Promise { - if (edit) { - await MqttTriggerService.updateMqttTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - client_id, - client_version, - v3_config, - v5_config, - mqtt_resource_path, - subscribe_topics, - path, - script_path, - enabled, - is_flow - } - }) - sendUserToast(`MQTT trigger ${path} updated`) - } else { - await MqttTriggerService.createMqttTrigger({ - workspace: $workspaceStore!, - requestBody: { - client_id, - client_version, - v3_config, - v5_config, - mqtt_resource_path, - subscribe_topics, - enabled: true, - path, - script_path, - is_flow - } - }) - sendUserToast(`MQTT trigger ${path} created`) + function getSaveCfg(): Record { + return { + client_id, + client_version, + v3_config, + v5_config, + mqtt_resource_path, + subscribe_topics, + path, + script_path, + enabled, + is_flow } - - if (!$usedTriggerKinds.includes('mqtt')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'mqtt'] - } - dispatch('update') - drawer.closeDrawer() } + + function getCaptureConfig(): Record { + return { + mqtt_resource_path, + subscribe_topics, + client_version, + v3_config, + v5_config, + client_id, + path + } + } + + async function updateTrigger(): Promise { + deploymentLoading = true + const cfg = getSaveCfg() + const isSaved = await saveMqttTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false + } + + async function handleToggleEnabled(newEnabled: boolean) { + enabled = newEnabled + if (!isDraftOnly && !hasDraft) { + await MqttTriggerService.setMqttTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: newEnabled } + }) + sendUserToast(`${newEnabled ? 'enabled' : 'disabled'} MQTT trigger ${initialPath}`) + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid ?? false) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(mqttConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} mqtt trigger ${initialPath}`) - }} - /> -
- {/if} - +{#if useDrawer} + + + + {@render actions()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} -
- + + {@render actions()} + + {@render config()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} + + {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + {#if edit} Changes can take up to 30 seconds to take effect. {:else} New MQTT triggers can take up to 30 seconds to start listening. {/if} + {/if} +
+
+
+
-
-
- -
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -232,32 +360,37 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyString(script_path)} + Create from template + {/if}

+ {/if} - -
- {/if} - - + +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte index 834b6c16bb..7b918726f2 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte @@ -1,126 +1,62 @@ - { - loadTriggers() - }} - bind:this={mqttTriggerEditor} -/> - -{#if isCloudHosted()} - - MQTT triggers are disabled in the multi-tenant cloud. - -{:else} -
- - Windmill can connect to an MQTT broker and subscribes to specific topics thus allowing the - execution of script/flows based on the event triggered by those subscribed topics - - - {#if !newItem && mqttTriggers && mqttTriggers.length > 0} -
-
-
- {#each mqttTriggers as mqttTriggers (mqttTriggers.path)} -
-
{mqttTriggers.path}
- -
- -
-
- {/each} -
-
-
- {/if} - - { - mqttTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="mqtt" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + MQTT triggers are disabled in the multi-tenant cloud. + + {:else} + + MQTT triggers allow you to execute scripts and flows in response to MQTT messages. They + can be configured to subscribe to specific topics with different QoS levels. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/mqtt/utils.ts b/frontend/src/lib/components/triggers/mqtt/utils.ts new file mode 100644 index 0000000000..14e264a088 --- /dev/null +++ b/frontend/src/lib/components/triggers/mqtt/utils.ts @@ -0,0 +1,48 @@ +import { MqttTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveMqttTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + client_id: cfg.client_id, + client_version: cfg.client_version, + v3_config: cfg.v3_config, + v5_config: cfg.v5_config, + mqtt_resource_path: cfg.mqtt_resource_path, + subscribe_topics: cfg.subscribe_topics, + path: cfg.path, + script_path: cfg.script_path, + enabled: cfg.enabled, + is_flow: cfg.is_flow + } + try { + if (edit) { + await MqttTriggerService.updateMqttTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`MQTT trigger ${cfg.path} updated`) + } else { + await MqttTriggerService.createMqttTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`MQTT trigger ${cfg.path} created`) + } + + if (!get(usedTriggerKinds).includes('mqtt')) { + usedTriggerKinds.update((t) => [...t, 'mqtt']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/nats/NatsCapture.svelte b/frontend/src/lib/components/triggers/nats/NatsCapture.svelte new file mode 100644 index 0000000000..c44fb8b5d7 --- /dev/null +++ b/frontend/src/lib/components/triggers/nats/NatsCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to NATS messages... +

+ {:else} +

+ Connecting to nats... +

+ {/if} + {:else} +

+ Start capturing to listen to NATS messages. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte index e4b74437d9..ef01c1e51d 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import NatsTriggerEditorInner from './NatsTriggerEditorInner.svelte' - let open = false + let { onUpdate } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: NatsTriggerEditorInner + let drawer: NatsTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 6d3cab175f..d1a0b87bee 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -8,35 +8,97 @@ import { NatsTriggerService } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import NatsTriggersConfigSection from './NatsTriggersConfigSection.svelte' - import Toggle from '$lib/components/Toggle.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveNatsTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let defaultValues: Record | undefined = undefined - let args: Record = {} + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + useEditButton?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isDeployed?: boolean + cloudDisabled?: boolean + customLabel?: Snippet + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - const dispatch = createEventDispatcher() + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isDeployed = false, + cloudDisabled = false, + customLabel = undefined, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() - $: is_flow = itemKind === 'flow' + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let defaultValues: Record | undefined = $state(undefined) + let natsResourcePath = $state('') + let subjects = $state(['']) + let useJetstream = $state(false) + let streamName = $state('') + let consumerName = $state('') + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + let isValid = $state(false) - export async function openEdit(ePath: string, isFlow: boolean) { + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !can_write || !isValid + ) + const natsConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -44,11 +106,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load nats trigger: ${err}`, true) } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } } } @@ -57,92 +124,95 @@ fixedScriptPath_?: string, nDefaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) drawerLoading = true try { drawer?.openDrawer() is_flow = nis_flow edit = false itemKind = nis_flow ? 'flow' : 'script' - args.nats_resource_path = nDefaultValues?.nats_resource_path ?? '' - args.subjects = nDefaultValues?.subjects ?? [''] - args.use_jetstream = nDefaultValues?.use_jetstream ?? false - args.stream_name = args.use_jetstream ? (nDefaultValues?.stream_name ?? '') : undefined - args.consumer_name = args.use_jetstream ? (nDefaultValues?.consumer_name ?? '') : undefined + natsResourcePath = nDefaultValues?.nats_resource_path ?? '' + subjects = nDefaultValues?.subjects ?? [''] + useJetstream = nDefaultValues?.use_jetstream ?? false + streamName = useJetstream ? (nDefaultValues?.stream_name ?? '') : undefined + consumerName = useJetstream ? (nDefaultValues?.consumer_name ?? '') : undefined initialScriptPath = '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath - path = '' + path = nDefaultValues?.path ?? '' initialPath = '' dirtyPath = false defaultValues = nDefaultValues + enabled = nDefaultValues?.enabled ?? false } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { - const s = await NatsTriggerService.getNatsTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path + async function loadTriggerConfig(cfg?: Record): Promise { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + natsResourcePath = cfg?.nats_resource_path + streamName = cfg?.stream_name + consumerName = cfg?.consumer_name + subjects = cfg?.subjects || [''] + useJetstream = cfg?.use_jetstream || false + enabled = cfg?.enabled + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } - is_flow = s.is_flow - path = s.path - args.nats_resource_path = s.nats_resource_path - args.stream_name = s.stream_name - args.consumer_name = s.consumer_name - args.subjects = s.subjects - args.use_jetstream = s.use_jetstream - enabled = s.enabled + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await NatsTriggerService.getNatsTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } + } - can_write = canWrite(s.path, s.extra_perms, $userStore) + function getSaveCfg() { + return { + path, + script_path, + is_flow, + enabled, + nats_resource_path: natsResourcePath, + stream_name: streamName, + consumer_name: consumerName, + subjects, + use_jetstream: useJetstream + } } async function updateTrigger(): Promise { - if (edit) { - await NatsTriggerService.updateNatsTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - nats_resource_path: args.nats_resource_path, - stream_name: args.stream_name, - consumer_name: args.consumer_name, - subjects: args.subjects, - use_jetstream: args.use_jetstream - } - }) - sendUserToast(`Nats trigger ${path} updated`) - } else { - await NatsTriggerService.createNatsTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - enabled: true, - nats_resource_path: args.nats_resource_path, - stream_name: args.stream_name, - consumer_name: args.consumer_name, - subjects: args.subjects, - use_jetstream: args.use_jetstream - } - }) - sendUserToast(`Nats trigger ${path} created`) + deploymentLoading = true + const cfg = natsConfig + const isSaved = await saveNatsTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() } - if (!$usedTriggerKinds.includes('nats')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'nats'] - } - dispatch('update') - drawer.closeDrawer() + deploymentLoading = false } function useDefaultValues() { - if (args.nats_resource_path && args.nats_resource_path != '') { + if (natsResourcePath && natsResourcePath != '') { return false } if (!defaultValues) { @@ -155,73 +225,120 @@ ) } - let isValid = false + async function handleToggleEnabled(toggleEnabled: boolean) { + enabled = toggleEnabled + if (!isDraftOnly && !hasDraft) { + await NatsTriggerService.setNatsTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: toggleEnabled } + }) + sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} NATS trigger ${initialPath}`) + } + } + + function getCaptureConfig() { + const { nats_resource_path, subjects, stream_name, consumer_name, use_jetstream } = natsConfig + return { nats_resource_path, subjects, stream_name, consumer_name, use_jetstream } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + !drawerLoading && + handleConfigChange(natsConfig, initialConfig, saveDisabled, edit, onConfigChange) + }) - - - - {#if !drawerLoading} - {#if edit} -
- { - await NatsTriggerService.setNatsTriggerEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} NATS trigger ${initialPath}`) - }} - /> -
- {/if} - {#if can_write} - - {/if} +{#if useDrawer} + + + + {@render actions()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} + + {@render actions()} + + {@render config()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} - {:else} - - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - NATS consumers can take up to 30 seconds to start. - {/if} - -
-
- -
+ {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + NATS consumers can take up to 30 seconds to start. + {/if} + + {/if} +
+
+
+ +
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -243,19 +360,29 @@ color="dark" size="xs" href={itemKind === 'flow' ? '/flows/add?hub=66' : '/scripts/add?hub=hub%2F19663'} - target="_blank">Create from template + Create from template + {/if}

+ {/if} - -
- {/if} - - + { + isValid = detail + }} + defaultValues={useDefaultValues() ? defaultValues : undefined} + {can_write} + showTestingBadge={isEditor} + /> +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte index 0a00b449a1..d925552ff0 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte @@ -1,135 +1,49 @@
- {#if showCapture && captureInfo} - - {/if}
+ {#snippet badge()} + {#if showTestingBadge} + + {/if} + {/snippet}
- - {#if !staticInputDisabled} - { - if (ev.detail === 'static') { - delete args.nats_resource_path - args.require_tls = false - args.servers = [''] - args.auth = { - label: 'NO_AUTH' - } - } else { - delete args.servers - delete args.auth - delete args.require_tls - } - }} - let:item - > - - - - {/if} - - - {#if selected === 'resource'} - - {:else} - args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={isStaticConnectionValid} - lightHeader={true} - /> - {/if} + {#if isConnectionValid} - + {/if}
@@ -291,17 +160,10 @@ args, - (v) => { - args = { - ...args, - ...v - } - } - } + bind:args={getNatsArgsCfg, (args) => setNewArgs(args)} bind:isValid={otherArgsValid} lightHeader={true} + disabled={!can_write} /> {globalError} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte index 12cd385e3a..20bfe8d5dc 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte @@ -1,137 +1,69 @@ - { - loadTriggers() - }} - bind:this={natsTriggerEditor} -/> - {#if !$enterpriseLicense} - Nats triggers are an enterprise only feature. - -{:else if isCloudHosted()} - - Nats triggers are disabled in the multi-tenant cloud. + NATS triggers are an enterprise only feature. {:else}
- - NATS triggers execute scripts and flows in response to messages published to NATS subjects. - - - {#if !newItem && natsTriggers && natsTriggers.length > 0} -
-
- {#each natsTriggers as natsTrigger (natsTrigger.path)} -
-
{natsTrigger.path}
-
- {natsTrigger.nats_resource_path} -
-
- -
-
- {/each} -
-
- {/if} - - { - saveTrigger(path, e.detail.config) - }} - on:applyArgs - on:addPreprocessor - cloudDisabled={false} - triggerType="nats" - {isFlow} - {data} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + + {#snippet description()} + {#if cloudDisabled} + + NATS triggers are disabled in the multi-tenant cloud. + + {:else} + + NATS triggers allow you to execute scripts and flows in response to NATS messages. They + can be configured to listen to specific subjects and to use JetStream or not. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/nats/utils.ts b/frontend/src/lib/components/triggers/nats/utils.ts new file mode 100644 index 0000000000..926956314c --- /dev/null +++ b/frontend/src/lib/components/triggers/nats/utils.ts @@ -0,0 +1,48 @@ +import { NatsTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveNatsTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + nats_resource_path: cfg.nats_resource_path, + stream_name: cfg.stream_name, + consumer_name: cfg.consumer_name, + subjects: cfg.subjects, + use_jetstream: cfg.use_jetstream + } + try { + if (edit) { + await NatsTriggerService.updateNatsTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`Nats trigger ${cfg.path} updated`) + } else { + await NatsTriggerService.createNatsTrigger({ + workspace, + requestBody: { + ...requestBody, + enabled: true + } + }) + sendUserToast(`Nats trigger ${cfg.path} created`) + } + if (!get(usedTriggerKinds).includes('nats')) { + usedTriggerKinds.update((t) => [...t, 'nats']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte b/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte new file mode 100644 index 0000000000..6e1238d7df --- /dev/null +++ b/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to Postgres database transactions... +

+ {:else} +

+ Connecting to Postgres database... +

+ {/if} + {:else} +

+ Start capturing to listen to Postgres database transactions. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte b/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte deleted file mode 100644 index 00018eca3c..0000000000 --- a/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
- {#if showCapture && captureInfo} - - {/if} -
-
-
-

- Pick a database to connect to -

- { - if (emptyString(postgres_resource_path)) { - publication = { ...DEFAULT_PUBLICATION } - } - }} - /> - {#if postgres_resource_path} - - - {/if} -
- {#if postgres_resource_path} - - - {/if} -
-
-
diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte index 9b46ac2b0b..ac36f4ffc2 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte @@ -2,22 +2,28 @@ import { tick } from 'svelte' import PostgresTriggerEditorInner from './PostgresTriggerEditorInner.svelte' - let open = false + let { onUpdate } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() drawer?.openEdit(ePath, isFlow) } - export async function openNew(is_flow: boolean, initial_script_path?: string, defaultValues?: Record) { + export async function openNew( + is_flow: boolean, + initial_script_path?: string, + defaultValues?: Record + ) { open = true await tick() drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: PostgresTriggerEditorInner + let drawer: PostgresTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 988152f00c..3c5253aa72 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -8,11 +8,9 @@ import { PostgresTriggerService, type Language, type Relations } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save, X } from 'lucide-svelte' + import { Loader2, X } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import ResourcePicker from '$lib/components/ResourcePicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -24,38 +22,124 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import RelationPicker from './RelationPicker.svelte' - import { invalidRelations } from './utils' + import { invalidRelations, savePostgresTriggerFromCfg } from './utils' import CheckPostgresRequirement from './CheckPostgresRequirement.svelte' import { base } from '$lib/base' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import TestingBadge from '../testingBadge.svelte' + import { handleConfigChange } from '../utils' + import { fade } from 'svelte/transition' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let postgres_resource_path = '' - let publication_name: string = '' - let replication_slot_name: string = '' - let relations: Relations[] | undefined = [] - let transaction_to_track: string[] = [] + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + isEditor?: boolean + hideTooltips?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isDeployed?: boolean + cloudDisabled?: boolean + customLabel?: Snippet + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } + + let { + useDrawer = true, + description = undefined, + hideTarget = false, + isEditor = false, + hideTooltips = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isDeployed = false, + cloudDisabled = false, + customLabel = undefined, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() + + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let postgres_resource_path = $state('') + let publication_name: string = $state('') + let replication_slot_name: string = $state('') + let relations: Relations[] | undefined = $state([]) + let transaction_to_track: string[] = $state([]) let language: Language = 'Typescript' - let loading = false + let loading = $state(false) type actions = 'create' | 'get' - let selectedPublicationAction: actions - let selectedSlotAction: actions - let publicationItems: string[] = [] + let selectedPublicationAction: actions | undefined = $state(undefined) + let selectedSlotAction: actions | undefined = $state(undefined) + let publicationItems: string[] = $state([]) let transactionType: string[] = ['Insert', 'Update', 'Delete'] - let tab: 'advanced' | 'basic' = 'basic' - let isLoading = false + let tab: 'advanced' | 'basic' = $state('basic') + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const errorMessage = $derived.by(() => { + if (relations && relations.length > 0) { + return invalidRelations(relations, { + showError: true, + trackSchemaTableError: true + }) + } + return '' + }) + + const isValid = $derived( + !emptyString(postgres_resource_path) && + !emptyString(script_path) && + !emptyString(publication_name) && + !emptyString(replication_slot_name) && + !errorMessage + ) + const postgresConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + + function isAdvancedTab(t: 'advanced' | 'basic'): boolean { + return t === 'advanced' + } + + function isBasicTab(t: 'advanced' | 'basic'): boolean { + return t === 'basic' + } + + let saveDisabled = $derived( + pathError !== '' || + emptyString(postgres_resource_path) || + emptyString(script_path) || + (isAdvancedTab(tab) && emptyString(replication_slot_name)) || + emptyString(publication_name) || + (relations && isBasicTab(tab) && relations.length === 0) || + transaction_to_track.length === 0 || + drawerLoading || + !can_write + ) + async function createPublication() { try { const message = await PostgresTriggerService.createPostgresPublication({ @@ -89,11 +173,18 @@ } } - const dispatch = createEventDispatcher() + $effect(() => { + is_flow = itemKind === 'flow' + }) - $: is_flow = itemKind === 'flow' - - export async function openEdit(ePath: string, isFlow: boolean) { + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -108,19 +199,28 @@ relations = [] transaction_to_track = [] tab = 'basic' - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load postgres trigger: ${err.body}`, true) } finally { + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + newDraft?: boolean ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { selectedPublicationAction = 'create' @@ -130,120 +230,127 @@ drawer?.openDrawer() is_flow = nis_flow itemKind = nis_flow ? 'flow' : 'script' - initialScriptPath = '' + initialScriptPath = defaultValues?.script_path ?? '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath - path = '' + path = defaultValues?.path ?? '' initialPath = '' postgres_resource_path = defaultValues?.postgres_resource_path ?? '' edit = false dirtyPath = false - publication_name = `windmill_publication_${random_adj()}` - replication_slot_name = `windmill_replication_${random_adj()}` - transaction_to_track = defaultValues?.publication.transaction_to_track || [ + publication_name = defaultValues?.publication_name ?? `windmill_publication_${random_adj()}` + replication_slot_name = + defaultValues?.replication_slot_name ?? `windmill_replication_${random_adj()}` + transaction_to_track = defaultValues?.publication?.transaction_to_track || [ 'Insert', 'Update', 'Delete' ] - relations = defaultValues?.publication.table_to_track || [ + relations = defaultValues?.publication?.table_to_track || [ { schema_name: 'public', table_to_track: [] } ] } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { - const s = await PostgresTriggerService.getPostgresTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path + function getSaveCfg(): Record { + const cfg = { + script_path: script_path, + initialScriptPath: initialScriptPath, + is_flow: is_flow, + path: path, + postgres_resource_path: postgres_resource_path, + publication_name: edit || tab === 'advanced' ? publication_name : undefined, + replication_slot_name: edit || tab === 'advanced' ? replication_slot_name : undefined, + publication: + !edit || tab === 'basic' + ? { + transaction_to_track: transaction_to_track, + table_to_track: relations + } + : undefined + } + return cfg + } - is_flow = s.is_flow - path = s.path - enabled = s.enabled - postgres_resource_path = s.postgres_resource_path - publication_name = s.publication_name - replication_slot_name = s.replication_slot_name + async function loadTriggerConfig(cfg?: Record): Promise { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + enabled = cfg?.enabled + postgres_resource_path = cfg?.postgres_resource_path + publication_name = cfg?.publication_name + replication_slot_name = cfg?.replication_slot_name + can_write = canWrite(path, cfg?.extra_perms, $userStore) + transaction_to_track = [...cfg?.publication?.transaction_to_track] + relations = cfg?.publication?.table_to_track ?? [] + } - const publication_data = await PostgresTriggerService.getPostgresPublication({ - path: postgres_resource_path, - workspace: $workspaceStore!, - publication: publication_name - }) - transaction_to_track = [...publication_data.transaction_to_track] - relations = publication_data.table_to_track ?? [] - can_write = canWrite(s.path, s.extra_perms, $userStore) + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + if (defaultConfig?.publication) { + transaction_to_track = [...defaultConfig.publication.transaction_to_track] + relations = defaultConfig.publication.table_to_track ?? [] + } + return + } else { + const s = await PostgresTriggerService.getPostgresTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + + const publication_data = await PostgresTriggerService.getPostgresPublication({ + path: s.postgres_resource_path, + workspace: $workspaceStore!, + publication: s.publication_name + }) + + loadTriggerConfig({ ...s, publication: publication_data }) + } + } + + function getCaptureConfig() { + return { + postgres_resource_path, + publication: + !edit || tab === 'basic' + ? { + transaction_to_track: transaction_to_track, + table_to_track: relations + } + : undefined, + publication_name: edit || tab !== 'basic' ? publication_name : undefined, + replication_slot_name: edit || tab !== 'basic' ? replication_slot_name : undefined, + path + } } async function updateTrigger(): Promise { - if ( - relations && - invalidRelations(relations, { - showError: true, - trackSchemaTableError: true - }) === true - ) { + const cfg = postgresConfig + if (!cfg) { return } - isLoading = true - try { - if (edit) { - await PostgresTriggerService.updatePostgresTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - postgres_resource_path, - enabled, - replication_slot_name, - publication_name, - publication: - tab === 'basic' - ? { - transaction_to_track, - table_to_track: relations - } - : undefined - } - }) - sendUserToast(`PostgresTrigger ${path} updated`) - } else { - await PostgresTriggerService.createPostgresTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - enabled: true, - postgres_resource_path, - replication_slot_name: tab === 'basic' ? undefined : replication_slot_name, - publication_name: tab === 'basic' ? undefined : publication_name, - publication: { - transaction_to_track, - table_to_track: relations - } - } - }) - sendUserToast(`PostgresTrigger ${path} created`) - } - isLoading = false - if (!$usedTriggerKinds.includes('postgres')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'postgres'] - } - dispatch('update') - drawer.closeDrawer() - } catch (error) { - isLoading = false - sendUserToast(error.body || error.message, true) + deploymentLoading = true + const isSaved = await savePostgresTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(path) + drawer?.closeDrawer() } + deploymentLoading = false } const getTemplateScript = async () => { @@ -268,81 +375,115 @@ sendUserToast(error.body, true) } } + + async function handleToggleEnabled(toggleEnabled: boolean) { + enabled = toggleEnabled + if (!isDraftOnly && !hasDraft) { + await PostgresTriggerService.setPostgresTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: toggleEnabled } + }) + sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} postgres trigger ${initialPath}`) + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(postgresConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - await PostgresTriggerService.setPostgresTriggerEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - sendUserToast( - `${e.detail ? 'enabled' : 'disabled'} postgres trigger ${initialPath}` - ) - }} - /> -
- {/if} - +{#if useDrawer} + + + {@render actions()} + {@render content()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} - - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - New postgres triggers can take up to 30 seconds to start listening. - {/if} - -
- + + {@render actions()} + + {@render content()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet content()} + {#if drawerLoading} + {#if showLoading} + +

Loading...

+ {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + New postgres triggers can take up to 30 seconds to start listening. + {/if} + + {/if} +
+
+ + {#if !hideTarget}

Pick a script or flow to be triggered @@ -355,7 +496,8 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyStringTrimmed(script_path) && is_flow === false} @@ -380,218 +522,239 @@ {/if}

-
-

- Pick a database to connect to -

-
-
- - -
+ {/if} +
+ + {#if isEditor} + + {/if} + +

+ Pick a database to connect to +

+
+
+ + +
- {#if postgres_resource_path} -
-
-
- {/if} - - + +
+ {:else} + + {/if} + +
+
+ +
+ + + + {/if} + + + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte index cd707bb5d3..0a007d6db0 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte @@ -1,125 +1,67 @@ - { - loadTriggers() - }} - bind:this={postgresTriggerEditor} -/> -{#if isCloudHosted()} - - Postgres triggers are disabled in the multi-tenant cloud. - -{:else} -
- - Windmill can connect to a Postgres database and trigger runnables (scripts, flows) in response - to database transactions (INSERT, UPDATE, DELETE) on specified tables, schemas, or the entire - database. Listening is done using Postgres's logical replication streaming protocol, ensuring - efficient and low-latency triggering. - - - {#if !newItem && postgresTriggers && postgresTriggers.length > 0} -
-
-
- {#each postgresTriggers as postgresTriggers (postgresTriggers.path)} -
-
{postgresTriggers.path}
-
- -
-
- {/each} -
-
-
- {/if} - { - postgresTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="postgres" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + Postgres triggers are disabled in the multi-tenant cloud. + + {:else} + + Windmill can connect to a Postgres database and trigger runnables (scripts, flows) in + response to database transactions (INSERT, UPDATE, DELETE) on specified tables, schemas, + or the entire database. Listening is done using Postgres's logical replication streaming + protocol, ensuring efficient and low-latency triggering. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte b/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte index 77b168a0f8..840d9a6856 100644 --- a/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte +++ b/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte @@ -16,6 +16,7 @@ export let postgres_resource_path: string = '' export let relations: Relations[] | undefined = undefined export let transaction_to_track: string[] = [] + export let disabled: boolean = false async function listDatabasePublication() { try { @@ -84,18 +85,17 @@ listDatabasePublication() let darkMode = false -
+
{#each v.table_to_track as table_to_track, j} @@ -90,6 +92,7 @@ type="text" bind:value={table_to_track.table_name} class="!bg-surface mt-1" + {disabled} /> @@ -128,6 +131,7 @@ selected={table_to_track.columns_name ?? []} placeholder="Select columns" --sms-options-margin="4px" + {disabled} onchange={(e) => { const option = e.option?.toString() if (e.type === 'add') { @@ -181,6 +185,7 @@ type="text" bind:value={table_to_track.where_clause} class="!bg-surface mt-1" + {disabled} />
{/each} @@ -202,12 +208,14 @@ on:add={({ detail }) => { addTable(detail.name, i) }} + {disabled} >
- @@ -258,12 +266,13 @@ invalidRelations(appendedRelations, { showError: true, trackSchemaTableError: false - }) === false + }) === '' ) { relations = appendedRelations } } }} + {disabled} > diff --git a/frontend/src/lib/components/triggers/postgres/utils.ts b/frontend/src/lib/components/triggers/postgres/utils.ts index 6b4e3e91b5..8122da726d 100644 --- a/frontend/src/lib/components/triggers/postgres/utils.ts +++ b/frontend/src/lib/components/triggers/postgres/utils.ts @@ -1,6 +1,8 @@ -import type { Relations } from '$lib/gen' +import { PostgresTriggerService, type Relations } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { emptyString } from '$lib/utils' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' type RelationError = { schemaIndex: number @@ -18,7 +20,9 @@ export function invalidRelations( trackSchemaTableError?: boolean showError?: boolean } -): boolean { +): string { + let errorMessage: string = '' + let error: RelationError = { schemaIndex: -1, tableIndex: -1, @@ -82,8 +86,6 @@ export function invalidRelations( error.trackAllTablesInSchema && error.trackSpecificColumnsInTable) if ((options?.showError ?? false) && errorFound) { - let errorMessage: string = '' - if (error.schemaError) { errorMessage = `Schema Error: Please enter a name for schema number ${error.schemaIndex}` } else if (error.tableError) { @@ -95,8 +97,53 @@ export function invalidRelations( errorMessage = 'Configuration Error: Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations.' } - sendUserToast(errorMessage, true) } - return errorFound + return errorMessage +} + +export async function savePostgresTriggerFromCfg( + initialPath: string, + config: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + try { + const requestBody = { + path: config.path, + script_path: config.script_path, + is_flow: config.is_flow, + postgres_resource_path: config.postgres_resource_path, + replication_slot_name: config.replication_slot_name, + publication_name: config.publication_name, + publication: config.publication, + enabled: config.enabled + } + if (edit) { + await PostgresTriggerService.updatePostgresTrigger({ + workspace: workspace, + path: initialPath, + requestBody + }) + sendUserToast(`PostgresTrigger ${config.path} updated`) + } else { + await PostgresTriggerService.createPostgresTrigger({ + workspace: workspace, + requestBody: { + ...requestBody, + enabled: true + } + }) + sendUserToast(`PostgresTrigger ${config.path} created`) + } + + if (!get(usedTriggerKinds).includes('postgres')) { + usedTriggerKinds.update((t) => [...t, 'postgres']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } } diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte index f70e3a48cb..c75e215e79 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import ScheduleEditorInner from './ScheduleEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -16,12 +18,12 @@ ) { open = true await tick() - drawer?.openNew(is_flow, initial_script_path, schedule_path) + drawer?.openNew(is_flow, initial_script_path, undefined, schedule_path) } - let drawer: ScheduleEditorInner + let drawer: ScheduleEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 69fe6c77d4..91dc02c2b0 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -23,77 +23,122 @@ import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils' import { base } from '$lib/base' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte' import autosize from '$lib/autosize' + import TriggerEditorToolbar from '$lib/components/triggers/TriggerEditorToolbar.svelte' + import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' import DateTimeInput from '$lib/components/DateTimeInput.svelte' import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte' import Label from '$lib/components/Label.svelte' import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' import { runScheduleNow } from '../scheduled/utils' + import { handleConfigChange } from '../utils' + + let { + useDrawer = true, + hideTarget = false, + docDescription = undefined, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + primary = false, + draftSchema = undefined, + customLabel = undefined, + isDeployed = false, + onUpdate = undefined, + onConfigChange = undefined, + onDelete = undefined, + onReset = undefined + } = $props() let optionTabSelected: 'error_handler' | 'recovery_handler' | 'success_handler' | 'retries' = - 'error_handler' - - let is_flow: boolean = false - let initialPath = '' - let edit = true - let schedule: string = '0 0 12 * *' - let cronVersion: string = 'v2' - let isLatestCron = true - let initialCronVersion: string = 'v2' + $state('error_handler') + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let schedule: string = $state('0 0 12 * *') + let cronVersion: string = $state('v2') + let isLatestCron = $state(true) + let initialCronVersion: string = $state('v2') let initialSchedule: string - let timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone - let paused_until: string | undefined = undefined + let timezone: string = $state(Intl.DateTimeFormat().resolvedOptions().timeZone) + let paused_until: string | undefined = $state(undefined) + let itemKind: 'flow' | 'script' = $state('script') + let errorHandleritemKind: 'flow' | 'script' = $state('script') + let wsErrorHandlerMuted: boolean = $state(false) + let errorHandlerPath: string | undefined = $state(undefined) + let errorHandlerCustomInitialPath: string | undefined = $state(undefined) + let errorHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let errorHandlerExtraArgs: Record = $state({}) + let recoveryHandlerPath: string | undefined = $state(undefined) + let recoveryHandlerCustomInitialPath: string | undefined = $state(undefined) + let recoveryHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let recoveryHandlerItemKind: 'flow' | 'script' = $state('script') + let recoveryHandlerExtraArgs: Record = $state({}) + let successHandlerPath: string | undefined = $state(undefined) + let successHandlerCustomInitialPath: string | undefined = $state(undefined) + let successHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let successHandlerItemKind: 'flow' | 'script' = $state('script') + let successHandlerExtraArgs: Record = $state({}) + let failedTimes = $state(1) + let failedExact = $state(false) + let recoveredTimes = $state(1) + let retry: Retry | undefined = $state(undefined) + let script_path = $state('') + let initialScriptPath = $state('') + let runnable: Script | Flow | undefined = $state() + let args: Record = $state({}) + let loading = $state(false) + let drawerLoading = $state(true) + let showLoading = $state(false) + let initialConfig: Record | undefined = undefined + let extraPerms: Record = $state({}) + let can_write = $state(true) + let initNewPath = $state(false) + let path: string = $state('') + let enabled: boolean = $state(false) + let pathError = $state('') + let summary = $state('') + let description = $state('') + let no_flow_overlap = $state(false) + let tag: string | undefined = $state(undefined) + let validCRON = $state(true) + let isValid = $state(true) + let allowSchedule = $derived(isValid && validCRON && script_path != '') + let deploymentLoading = $state(false) - let itemKind: 'flow' | 'script' = 'script' - let errorHandleritemKind: 'flow' | 'script' = 'script' - let wsErrorHandlerMuted: boolean = false - let errorHandlerPath: string | undefined = undefined - let errorHandlerCustomInitialPath: string | undefined = undefined - let errorHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let errorHandlerExtraArgs: Record = {} - let recoveryHandlerPath: string | undefined = undefined - let recoveryHandlerCustomInitialPath: string | undefined = undefined - let recoveryHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let recoveryHandlerItemKind: 'flow' | 'script' = 'script' - let recoveryHandlerExtraArgs: Record = {} - let successHandlerPath: string | undefined = undefined - let successHandlerCustomInitialPath: string | undefined = undefined - let successHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let successHandlerItemKind: 'flow' | 'script' = 'script' - let successHandlerExtraArgs: Record = {} - let failedTimes = 1 - let failedExact = false - let recoveredTimes = 1 - let duplicate = false - let retry: Retry | undefined = undefined + const saveDisabled = $derived( + !allowSchedule || + pathError != '' || + emptyString(script_path) || + (errorHandlerSelected == 'slack' && + !emptyString(errorHandlerPath) && + emptyString(errorHandlerExtraArgs['channel'])) || + !can_write + ) + const scheduleCfg = $derived.by(getScheduleCfg) - let script_path = '' - let initialScriptPath = '' - - let runnable: Script | Flow | undefined - let args: Record = {} - - let loading = false - - let drawerLoading = true - export function openEdit(ePath: string, isFlow: boolean) { + export async function openEdit(ePath: string, isFlow: boolean, defaultCfg?: Record) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() is_flow = isFlow initialPath = ePath itemKind = is_flow ? 'flow' : 'script' - if (path == ePath) { - loadSchedule() - } else { - path = ePath - } + path = defaultCfg?.path ?? ePath + await loadSchedule(defaultCfg) edit = true } finally { + if (!defaultCfg) { + initialConfig = structuredClone($state.snapshot(getScheduleCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -147,8 +192,7 @@ successHandlerSelected = 'slack' successHandlerExtraArgs = {} } - } - else { + } else { let defaultErrorHandlerMaybe = undefined let defaultRecoveryHandlerMaybe = undefined let defaultSuccessHandlerMaybe = undefined @@ -221,8 +265,12 @@ export async function openNew( nis_flow: boolean, initial_script_path?: string, + defaultValues?: Schedule, schedule_path?: string ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { let s: Schedule | undefined @@ -231,7 +279,9 @@ workspace: $workspaceStore!, path: schedule_path }) - duplicate = true + initNewPath = true + } else if (defaultValues) { + s = defaultValues } drawer?.openDrawer() runnable = undefined @@ -239,7 +289,7 @@ edit = false itemKind = is_flow ? 'flow' : 'script' initialScriptPath = initial_script_path ?? '' - path = duplicate === true ? '' : initialScriptPath + path = initNewPath ? '' : (defaultValues?.path ?? initialScriptPath) initialPath = path cronVersion = s?.cron_version ?? 'v2' @@ -264,7 +314,9 @@ await setScheduleHandler(s) } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -274,28 +326,18 @@ } } - $: (is_flow = itemKind == 'flow') && resetRetries() - - let isValid = true - - let path: string = '' - let enabled: boolean = false - let pathError = '' - let summary = '' - let description = '' - let no_flow_overlap = false - let tag: string | undefined = undefined - - let validCRON = true - $: allowSchedule = isValid && validCRON && script_path != '' + $effect(() => { + ;(is_flow = itemKind == 'flow') && resetRetries() + }) // set isValid to true when a script/flow without any properties is selected - $: runnable?.schema && - runnable.schema.properties && - Object.keys(runnable.schema.properties).length === 0 && - (isValid = true) + $effect(() => { + setDefaultValid(draftSchema ?? runnable?.schema) + }) - const dispatch = createEventDispatcher() + function setDefaultValid(schema: Record | undefined) { + isValid = schema?.properties && Object.keys(schema.properties).length === 0 + } async function loadScript(p: string | undefined): Promise { if (p) { @@ -395,129 +437,108 @@ } } - let can_write = true - async function loadSchedule(): Promise { - loading = true - try { - const s = await ScheduleService.getSchedule({ - workspace: $workspaceStore!, - path: initialPath - }) - is_flow = s.is_flow - cronVersion = s.cron_version ?? 'v2' - initialCronVersion = cronVersion - isLatestCron = cronVersion == 'v2' - enabled = s.enabled - schedule = s.schedule - initialSchedule = schedule - timezone = s.timezone - paused_until = s.paused_until - showPauseUntil = paused_until !== undefined - summary = s.summary ?? '' - description = s.description ?? '' - script_path = s.script_path ?? '' - args = s.args ?? {} - can_write = canWrite(s.path, s.extra_perms, $userStore) - tag = s.tag - - await loadScript(script_path) - - no_flow_overlap = s.no_flow_overlap ?? false - wsErrorHandlerMuted = s.ws_error_handler_muted ?? false - retry = s.retry - await setScheduleHandler(s) - } catch (err) { - sendUserToast(`Could not load schedule: ${err}`, true) + async function loadSchedule(defaultCfg?: Record): Promise { + if (!defaultCfg) { + try { + const s = await ScheduleService.getSchedule({ + workspace: $workspaceStore!, + path: initialPath + }) + await loadScheduleCfg(s) + } catch (err) { + sendUserToast(`Could not load schedule: ${err}`, true) + } + } else { + await loadScheduleCfg(defaultCfg) } + } + + async function loadScheduleCfg(cfg: Record): Promise { + loading = true + + cronVersion = cfg.cron_version ?? 'v2' + initialCronVersion = cronVersion + isLatestCron = cronVersion == 'v2' + enabled = cfg.enabled + schedule = cfg.schedule + initialSchedule = schedule + timezone = cfg.timezone + paused_until = cfg.paused_until + showPauseUntil = paused_until !== undefined + summary = cfg.summary ?? '' + description = cfg.description ?? '' + script_path = cfg.script_path ?? '' + await loadScript(script_path) + + is_flow = cfg.is_flow + no_flow_overlap = cfg.no_flow_overlap ?? false + wsErrorHandlerMuted = cfg.ws_error_handler_muted ?? false + retry = cfg.retry + if (cfg.on_failure) { + let splitted = cfg.on_failure.split('/') + errorHandleritemKind = splitted[0] as 'flow' | 'script' + errorHandlerPath = splitted.slice(1)?.join('/') + errorHandlerCustomInitialPath = errorHandlerPath + failedTimes = cfg.on_failure_times ?? 1 + failedExact = cfg.on_failure_exact ?? false + errorHandlerExtraArgs = cfg.on_failure_extra_args ?? {} + errorHandlerSelected = getHandlerType('error', errorHandlerPath ?? '') + } else { + errorHandlerPath = undefined + errorHandleritemKind = 'script' + errorHandlerCustomInitialPath = undefined + errorHandlerExtraArgs = {} + failedExact = false + failedTimes = 1 + errorHandlerSelected = 'slack' + } + if (cfg.on_recovery) { + let splitted = cfg.on_recovery.split('/') + recoveryHandlerItemKind = splitted[0] as 'flow' | 'script' + recoveryHandlerPath = splitted.slice(1)?.join('/') + recoveryHandlerCustomInitialPath = recoveryHandlerPath + recoveredTimes = cfg.on_recovery_times ?? 1 + recoveryHandlerExtraArgs = cfg.on_recovery_extra_args ?? {} + recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath ?? '') + } else { + recoveryHandlerPath = undefined + recoveryHandlerItemKind = 'script' + recoveryHandlerCustomInitialPath = undefined + recoveredTimes = 1 + recoveryHandlerSelected = 'slack' + recoveryHandlerExtraArgs = {} + } + if (cfg.on_success) { + let splitted = cfg.on_success.split('/') + successHandlerItemKind = splitted[0] as 'flow' | 'script' + successHandlerPath = splitted.slice(1)?.join('/') + successHandlerCustomInitialPath = successHandlerPath + successHandlerExtraArgs = cfg.on_success_extra_args ?? {} + successHandlerSelected = getHandlerType('success', successHandlerPath ?? '') + } else { + successHandlerPath = undefined + successHandlerItemKind = 'script' + successHandlerCustomInitialPath = undefined + successHandlerSelected = 'slack' + successHandlerExtraArgs = {} + } + args = cfg.args ?? {} + extraPerms = cfg.extra_perms ?? {} + can_write = canWrite(cfg.path, cfg.extra_perms, $userStore) + tag = cfg.tag + loading = false } async function scheduleScript(): Promise { - if (errorHandlerPath !== undefined && isSlackHandler('error', errorHandlerPath)) { - errorHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - errorHandlerExtraArgs['slack'] = undefined + const scheduleCfg = getScheduleCfg() + deploymentLoading = true + const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, $workspaceStore!) + if (isSaved) { + onUpdate?.(scheduleCfg.path) + drawer?.closeDrawer() } - if (recoveryHandlerPath !== undefined && isSlackHandler('recovery', recoveryHandlerPath)) { - recoveryHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - recoveryHandlerExtraArgs['slack'] = undefined - } - if (successHandlerPath !== undefined && isSlackHandler('success', successHandlerPath)) { - successHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - successHandlerExtraArgs['slack'] = undefined - } - if (edit) { - await ScheduleService.updateSchedule({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - schedule: formatCron(schedule), - timezone, - args, - on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, - on_failure_times: failedTimes, - on_failure_exact: failedExact, - on_failure_extra_args: errorHandlerPath ? errorHandlerExtraArgs : undefined, - on_recovery: recoveryHandlerPath - ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` - : undefined, - on_recovery_times: recoveredTimes, - on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgs : {}, - on_success: successHandlerPath - ? `${successHandlerItemKind}/${successHandlerPath}` - : undefined, - on_success_extra_args: successHandlerPath ? successHandlerExtraArgs : {}, - ws_error_handler_muted: wsErrorHandlerMuted, - retry: retry, - summary: summary != '' ? summary : undefined, - description: description, - no_flow_overlap: no_flow_overlap, - tag: tag, - paused_until: paused_until, - cron_version: cronVersion - } - }) - sendUserToast(`Schedule ${path} updated`) - } else { - await ScheduleService.createSchedule({ - workspace: $workspaceStore!, - requestBody: { - path, - schedule: formatCron(schedule), - timezone, - script_path, - is_flow, - args, - enabled: true, - on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, - on_failure_times: failedTimes, - on_failure_exact: failedExact, - on_failure_extra_args: errorHandlerPath ? errorHandlerExtraArgs : undefined, - on_recovery: recoveryHandlerPath - ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` - : undefined, - on_recovery_times: recoveredTimes, - on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgs : {}, - on_success: successHandlerPath - ? `${successHandlerItemKind}/${successHandlerPath}` - : undefined, - on_success_extra_args: successHandlerPath ? successHandlerExtraArgs : {}, - ws_error_handler_muted: wsErrorHandlerMuted, - retry: retry, - summary: summary != '' ? summary : undefined, - description: description, - no_flow_overlap: no_flow_overlap, - tag: tag, - paused_until: paused_until, - cron_version: cronVersion - } - }) - sendUserToast(`Schedule ${path} created`) - } - dispatch('update') - drawer.closeDrawer() + deploymentLoading = false } function getHandlerType( @@ -562,21 +583,15 @@ } } - $: { - if ($workspaceStore) { - if (edit && path != '') { - loadSchedule() - } - } - } + let drawer: Drawer | undefined = $state() - let drawer: Drawer + let pathC: Path | undefined = $state() + let dirtyPath = $state(false) - let pathC: Path - let dirtyPath = false - - let showPauseUntil = false - $: !showPauseUntil && (paused_until = undefined) + let showPauseUntil = $state(false) + $effect(() => { + !showPauseUntil && (paused_until = undefined) + }) function onVersionChange() { cronVersion = isLatestCron ? 'v2' : 'v1' @@ -592,20 +607,99 @@ schedule = initialSchedule } } + + function getScheduleCfg(): Record { + let errorHadlerExtraArgsDerived = structuredClone($state.snapshot(errorHandlerExtraArgs)) + if (errorHandlerPath !== undefined && isSlackHandler('error', errorHandlerPath)) { + errorHadlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + errorHadlerExtraArgsDerived['slack'] = undefined + } + + let recoveryHandlerExtraArgsDerived = structuredClone($state.snapshot(recoveryHandlerExtraArgs)) + if (recoveryHandlerPath !== undefined && isSlackHandler('recovery', recoveryHandlerPath)) { + recoveryHandlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + recoveryHandlerExtraArgsDerived['slack'] = undefined + } + + let successHandlerExtraArgsDerived = structuredClone($state.snapshot(successHandlerExtraArgs)) + if (successHandlerPath !== undefined && isSlackHandler('success', successHandlerPath)) { + successHandlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + successHandlerExtraArgsDerived['slack'] = undefined + } + return { + path: path, + schedule: formatCron(schedule), + timezone: timezone, + script_path: script_path, + is_flow: is_flow, + args: args, + enabled: enabled, + on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, + on_failure_times: failedTimes, + on_failure_exact: failedExact, + on_failure_extra_args: errorHandlerPath ? errorHadlerExtraArgsDerived : undefined, + on_recovery: recoveryHandlerPath + ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` + : undefined, + on_recovery_times: recoveredTimes, + on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgsDerived : {}, + on_success: successHandlerPath + ? `${successHandlerItemKind}/${successHandlerPath}` + : undefined, + on_success_extra_args: successHandlerPath ? successHandlerExtraArgsDerived : {}, + ws_error_handler_muted: wsErrorHandlerMuted, + retry: retry, + summary: summary != '' ? summary : undefined, + description: description, + no_flow_overlap: no_flow_overlap, + tag: tag, + paused_until: paused_until, + cron_version: cronVersion, + extra_perms: extraPerms + } + } + + async function handleToggleEnabled(nEnabled: boolean) { + enabled = nEnabled + if (!isDraftOnly && !hasDraft) { + await ScheduleService.setScheduleEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: nEnabled } + }) + sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${initialPath}`) + } + } + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(scheduleCfg, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading} - {#if edit} +{#snippet saveButton()} + {#if !drawerLoading} + + {#snippet extra()} + {#if !drawerLoading && edit}
- {#if can_write} -
- { - await ScheduleService.setScheduleEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - dispatch('update') - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${initialPath}`) - }} - /> -
- {/if} {/if} - - {/if} -
- {#if drawerLoading} - - {:else} -
-
-
-

Metadata

- -
- + {/snippet} + + {/if} +{/snippet} -
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte index e981d7dedf..24bd1137f0 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte @@ -1,135 +1,69 @@ - { - loadTriggers() - }} - bind:this={sqsTriggerEditor} -/> + onMount(() => { + sqsTriggerEditor && openSqsTriggerEditor(isFlow, selectedTrigger.isDraft ?? false) + }) + + const cloudDisabled = $derived(isCloudHosted()) + {#if !$enterpriseLicense} SQS triggers are an enterprise only feature. -{:else if isCloudHosted()} - - SQS triggers are disabled in the multi-tenant cloud. - {:else}
- - SQS triggers allow your scripts/flows to process messages from Amazon Simple Queue Service - (SQS) in real time. Each trigger listens to an SQS queue and executes a script or a flow when - new messages arrive. - - - {#if !newItem && sqsTriggers && sqsTriggers.length > 0} -
-
-
- {#each sqsTriggers as sqsTriggers (sqsTriggers.path)} -
-
{sqsTriggers.path}
-
- {sqsTriggers.queue_url} -
-
- -
-
- {/each} -
-
-
- {/if} - - { - sqsTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="sqs" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + {#snippet description()} + {#if cloudDisabled} + + SQS triggers are disabled in the multi-tenant cloud. + + {:else} + + SQS triggers allow you to execute scripts and flows in response to messages in an AWS + SQS queue. They can be configured to filter messages based on message attributes. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/sqs/utils.ts b/frontend/src/lib/components/triggers/sqs/utils.ts new file mode 100644 index 0000000000..7ef1afa60b --- /dev/null +++ b/frontend/src/lib/components/triggers/sqs/utils.ts @@ -0,0 +1,46 @@ +import { SqsTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveSqsTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + aws_resource_path: cfg.aws_resource_path, + queue_url: cfg.queue_url, + message_attributes: cfg.message_attributes, + aws_auth_resource_type: cfg.aws_auth_resource_type, + enabled: cfg.enabled + } + try { + if (edit) { + await SqsTriggerService.updateSqsTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`SQS trigger ${cfg.path} updated`) + } else { + await SqsTriggerService.createSqsTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`SQS trigger ${cfg.path} created`) + } + + if (!get(usedTriggerKinds).includes('sqs')) { + usedTriggerKinds.update((t) => [...t, 'sqs']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/testingBadge.svelte b/frontend/src/lib/components/triggers/testingBadge.svelte new file mode 100644 index 0000000000..eb8d288fbf --- /dev/null +++ b/frontend/src/lib/components/triggers/testingBadge.svelte @@ -0,0 +1,9 @@ + + + + + Config used for creating a testing endpoint + diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts new file mode 100644 index 0000000000..5f40fb6126 --- /dev/null +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -0,0 +1,456 @@ +import { + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ScheduleService, + SqsTriggerService, + WebsocketTriggerService, + type GcpTrigger, + type KafkaTrigger, + type PostgresTrigger, + type Schedule, + type TriggersCount, + type HttpTrigger, + HttpTriggerService, + GcpTriggerService +} from '$lib/gen' +import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils' +import type { Writable } from 'svelte/store' +import type { TriggerType } from './utils' +import type { UserExt } from '$lib/stores' +import type { ScheduleTrigger } from '../triggers' +import { canWrite, formatCron } from '$lib/utils' + +export class Triggers { + #triggers = $state([]) + #selectedTriggerIndex = $state(undefined) + #selectedTrigger = $derived( + this.#selectedTriggerIndex !== undefined + ? this.#triggers[this.#selectedTriggerIndex] + : undefined + ) + #updateDraftCallback: (() => void) | undefined = undefined + + constructor( + triggers: Trigger[] = [], + selectedIndex?: number, + updateDraftCallback?: (() => void) | undefined + ) { + this.#triggers = triggers + this.#selectedTriggerIndex = selectedIndex + this.#updateDraftCallback = updateDraftCallback + } + + get selectedTrigger(): Trigger | undefined { + return this.#selectedTrigger + } + + get selectedTriggerIndex(): number | undefined { + return this.#selectedTriggerIndex + } + + set selectedTriggerIndex(index: number | undefined) { + if (index === undefined || index < 0 || index >= this.#triggers.length) { + this.#selectedTriggerIndex = undefined + } else { + this.#selectedTriggerIndex = index + } + this.#updateDraftCallback?.() + } + + get triggers(): Trigger[] { + return this.#triggers + } + + setTriggers(triggers: Trigger[]) { + this.#triggers = triggers + this.#updateDraftCallback?.() + } + + setDraftConfig(triggerIndex: number, draftConfig: Record | undefined) { + if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { + return + } + this.#triggers[triggerIndex].draftConfig = draftConfig + this.#updateDraftCallback?.() + } + + getDraftTriggersSnapshot(): Trigger[] | undefined { + const draftTriggers = this.#triggers.filter((t) => t.draftConfig) + return draftTriggers.length > 0 ? $state.snapshot(draftTriggers) : undefined + } + + getSelectedTriggerSnapshot(): number | undefined { + return $state.snapshot(this.#selectedTriggerIndex) + } + + addDraftTrigger( + triggersCountStore: Writable, + type: TriggerType, + path?: string, + draftCfg?: Record + ): number { + const primaryScheduleExists = this.#triggers.some((t) => t.type === 'schedule' && t.isPrimary) + + // Create the new draft trigger + const draftId = crypto.randomUUID() + const isPrimary = type === 'schedule' && !primaryScheduleExists + const newTrigger = { + id: draftId, + type, + path, + isPrimary, + isDraft: true, + draftConfig: draftCfg + } + + this.#triggers.push(newTrigger) + this.#updateDraftCallback?.() + + updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig) + + return this.#triggers.length - 1 + } + + deleteTrigger( + triggersCountStore: Writable, + triggerIndex: number + ): void { + if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { + return + } + const { type } = this.#triggers[triggerIndex] + + this.#triggers = this.#triggers.filter((_, index) => index !== triggerIndex) + + updateTriggersCount(triggersCountStore, type, 'remove') + this.#updateDraftCallback?.() + } + + updateTriggers( + remoteTriggers: any[], + type: TriggerType, + user: UserExt | undefined = undefined + ): number { + const currentTriggers = this.#triggers + // Identify triggers with draftConfig to preserve + const configuredTriggers = currentTriggers.filter( + (t) => t.type === type && !t.isDraft && t.draftConfig + ) + + const configMap = new Map }>() + + configuredTriggers.forEach((t) => { + configMap.set(t.path ?? '', { draftConfig: t.draftConfig! }) + }) + + const backendTriggers = remoteTriggers.map((trigger) => { + const { draftConfig } = configMap.get(trigger.path) ?? {} + return { + type: type as TriggerType, + path: trigger.path, + isPrimary: type === 'schedule' && trigger.path === trigger.script_path, + isDraft: false, + canWrite: canWrite(trigger.path, trigger.extra_perms, user), + draftConfig: draftConfig, + lightConfig: getLightConfig(type, trigger) + } + }) + + const filteredTriggers = currentTriggers.filter((t) => t.type !== type || t.isDraft) + const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers]) + this.#triggers = newTriggers + + this.#updateDraftCallback?.() + return newTriggers.filter((t) => t.type === type).length + } + + async fetchSchedules( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + primarySchedule?: ScheduleTrigger | undefined | false, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + //First update the store with legacy primary schedule + if (primarySchedule && !this.#triggers.some((s) => s.isPrimary)) { + const primary = { + type: 'schedule' as TriggerType, + path, + isPrimary: true, + isDraft: false, + draftConfig: { + schedule: primarySchedule.cron ? formatCron(primarySchedule.cron) : undefined, + args: primarySchedule.args, + timezone: primarySchedule.timezone, + summary: primarySchedule.summary, + description: primarySchedule.description, + enabled: primarySchedule.enabled + } + } + this.#triggers = [...this.#triggers, primary] + } + + const allDeployedSchedules: Schedule[] = await ScheduleService.listSchedules({ + workspace: workspaceId, + path, + isFlow + }) + + const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', user) + const updatedPrimarySchedule = this.#triggers.find((s) => s.isPrimary) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + schedule_count: scheduleCount, + primary_schedule: { + schedule: + updatedPrimarySchedule?.draftConfig?.schedule ?? + updatedPrimarySchedule?.lightConfig?.schedule + } + } + }) + + return + } catch (error) { + console.error('Failed to fetch schedules:', error) + return + } + } + + async fetchWebsocketTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const wsTriggers = await WebsocketTriggerService.listWebsocketTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const wsCount = this.updateTriggers(wsTriggers, 'websocket', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + websocket_count: wsCount + } + }) + } catch (error) { + console.error('Failed to fetch Websocket triggers:', error) + } + } + + async fetchPostgresTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const pgTriggers: PostgresTrigger[] = await PostgresTriggerService.listPostgresTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const pgCount = this.updateTriggers(pgTriggers, 'postgres', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + postgres_count: pgCount + } + }) + } catch (error) { + console.error('Failed to fetch Postgres triggers:', error) + } + } + + async fetchKafkaTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const kafkaTriggers: KafkaTrigger[] = await KafkaTriggerService.listKafkaTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + kafka_count: kafkaCount + } + }) + } catch (error) { + console.error('Failed to fetch Kafka triggers:', error) + } + } + + async fetchNatsTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const natsTriggers = await NatsTriggerService.listNatsTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const natsCount = this.updateTriggers(natsTriggers, 'nats', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + nats_count: natsCount + } + }) + } catch (error) { + console.error('Failed to fetch NATS triggers:', error) + } + } + + async fetchMqttTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const mqttTriggers = await MqttTriggerService.listMqttTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + mqtt_count: mqttCount + } + }) + } catch (error) { + console.error('Failed to fetch MQTT triggers:', error) + } + } + + async fetchSqsTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const sqsTriggers = await SqsTriggerService.listSqsTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + sqs_count: sqsCount + } + }) + } catch (error) { + console.error('Failed to fetch SQS triggers:', error) + } + } + + async fetchGcpTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const gcpTriggers: GcpTrigger[] = await GcpTriggerService.listGcpTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + gcp_count: gcpCount + } + }) + } catch (error) { + console.error('Failed to fetch GCP Pub/Sub triggers:', error) + } + } + + async fetchHttpTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const httpTriggers: HttpTrigger[] = await HttpTriggerService.listHttpTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const httpCount = this.updateTriggers(httpTriggers, 'http', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + http_routes_count: httpCount + } + }) + } catch (error) { + console.error('Failed to fetch HTTP triggers:', error) + } + } + + async fetchTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + primarySchedule: ScheduleTrigger | undefined | false = undefined, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + + // Fetch each type of trigger + await Promise.all([ + this.fetchSchedules(triggersCountStore, workspaceId, path, isFlow, primarySchedule, user), + this.fetchHttpTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchWebsocketTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchPostgresTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchNatsTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchMqttTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user) + ]) + } +} diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts new file mode 100644 index 0000000000..7daf829366 --- /dev/null +++ b/frontend/src/lib/components/triggers/utils.ts @@ -0,0 +1,529 @@ +import { Webhook, Mail, Calendar, Route, Unplug, Database, Terminal } from 'lucide-svelte' +import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' +import NatsIcon from '$lib/components/icons/NatsIcon.svelte' +import MqttIcon from '$lib/components/icons/MqttIcon.svelte' +import AwsIcon from '$lib/components/icons/AwsIcon.svelte' +import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' +import type { CaptureTriggerKind, Flow, NewScript, TriggersCount } from '$lib/gen/types.gen' +import type { Writable } from 'svelte/store' +import SchedulePollIcon from '../icons/SchedulePollIcon.svelte' +import { type TriggerKind } from '$lib/components/triggers' +import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { saveHttpRouteFromCfg } from './http/utils' +import { saveWebsocketTriggerFromCfg } from './websocket/utils' +import { savePostgresTriggerFromCfg } from './postgres/utils' +import { saveKafkaTriggerFromCfg } from './kafka/utils' +import { saveSqsTriggerFromCfg } from './sqs/utils' +import { saveNatsTriggerFromCfg } from './nats/utils' +import { saveMqttTriggerFromCfg } from './mqtt/utils' +import { saveGcpTriggerFromCfg } from './gcp/utils' +import type { Triggers } from './triggers.svelte' +import { emptyString } from '$lib/utils' + +export const CLOUD_DISABLED_TRIGGER_TYPES = [ + 'nats', + 'kafka', + 'sqs', + 'mqtt', + 'gcp', + 'websocket', + 'postgres' +] + +export type TriggerType = + | 'webhook' + | 'email' + | 'schedule' + | 'http' + | 'websocket' + | 'postgres' + | 'kafka' + | 'nats' + | 'mqtt' + | 'sqs' + | 'gcp' + | 'poll' + | 'cli' + +export type Trigger = { + type: TriggerType + path?: string + isDraft?: boolean + isPrimary?: boolean + canWrite?: boolean + id?: string + draftConfig?: Record + captureConfig?: Record + extra?: Record + lightConfig?: Record +} + +// Map of trigger kinds to icons +export const triggerIconMap = { + webhook: Webhook, + email: Mail, + schedule: Calendar, + http: Route, + websocket: Unplug, + postgres: Database, + kafka: KafkaIcon, + nats: NatsIcon, + mqtt: MqttIcon, + sqs: AwsIcon, + gcp: GoogleCloudIcon, + primary_schedule: Calendar, + poll: SchedulePollIcon, + cli: Terminal +} + +/** + * Converts a TriggerType to a CaptureTriggerKind when a mapping exists + * @param triggerType The trigger type to convert + * @returns The corresponding CaptureTriggerKind or undefined if no mapping exists + */ +export function triggerTypeToCaptureKind(triggerType: TriggerType): CaptureTriggerKind | undefined { + // Define types that can be mapped to CaptureTriggerKind + const capturableTriggerTypes: TriggerType[] = [ + 'webhook', + 'email', + 'http', + 'websocket', + 'postgres', + 'kafka', + 'nats', + 'mqtt', + 'sqs', + 'gcp', + 'cli' + ] + + if (capturableTriggerTypes.includes(triggerType)) { + return triggerType as CaptureTriggerKind + } + + return undefined +} + +export function updateTriggersCount( + triggersCountStore: Writable, + type: TriggerType, + action: 'add' | 'remove', + primaryCfg?: Record, + isPrimary?: boolean +) { + // Map trigger types to their corresponding count property names + const countPropertyMap: Record = { + webhook: undefined, + email: undefined, + schedule: 'schedule_count', + http: 'http_routes_count', + websocket: 'websocket_count', + postgres: 'postgres_count', + kafka: 'kafka_count', + nats: 'nats_count', + mqtt: 'mqtt_count', + sqs: 'sqs_count', + gcp: 'gcp_count', + poll: undefined, + cli: undefined + } + + const countProperty = countPropertyMap[type] + + triggersCountStore.update((triggersCount) => { + // Handle special case for schedule + if (type === 'schedule') { + if (action === 'add' && primaryCfg) { + return { + ...(triggersCount ?? {}), + schedule_count: (triggersCount?.schedule_count ?? 0) + 1, + primary_schedule: primaryCfg?.schedule + } + } else if (action === 'remove') { + return { + ...(triggersCount ?? {}), + schedule_count: (triggersCount?.schedule_count ?? 1) - 1, + primary_schedule: isPrimary ? undefined : triggersCount?.primary_schedule + } + } + } + + // Handle standard count updates + if (countProperty && action === 'add') { + return { + ...(triggersCount ?? {}), + [countProperty]: (triggersCount?.[countProperty] ?? 0) + 1 + } + } else if (countProperty && action === 'remove') { + return { + ...(triggersCount ?? {}), + [countProperty]: (triggersCount?.[countProperty] ?? 1) - 1 + } + } + + return triggersCount + }) +} + +// TODO: Remove this once we've migrated all the trigger kinds to the new TriggerType enum +export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undefined { + switch (kind) { + case 'webhooks': + return 'webhook' + case 'emails': + return 'email' + case 'schedules': + return 'schedule' + case 'routes': + return 'http' + case 'websockets': + return 'websocket' + case 'postgres': + return 'postgres' + case 'kafka': + return 'kafka' + case 'nats': + return 'nats' + case 'mqtt': + return 'mqtt' + case 'sqs': + return 'sqs' + case 'gcp': + return 'gcp' + case 'scheduledPoll': + return 'poll' + default: + throw new Error(`Unknown TriggerKind: ${kind}`) + } +} + +export async function deployTriggers( + triggersToDeploy: Trigger[], + workspaceId: string | undefined, + isAdmin: boolean, + usedTriggerKinds: Writable, + initialPath?: string, + isNew?: boolean +) { + if (!workspaceId) return + + if (isNew && initialPath) { + triggersToDeploy.forEach((trigger) => { + trigger.draftConfig = { + ...trigger.draftConfig, + script_path: initialPath + } + }) + } + + // Map of trigger types to their save functions + const triggerSaveFunctions: Record = { + webhook: undefined, + email: undefined, + schedule: (trigger: Trigger) => { + if (trigger.isPrimary && initialPath) { + trigger.draftConfig = { + ...trigger.draftConfig, + path: initialPath, + script_path: initialPath + } + } + return saveScheduleFromCfg(trigger.draftConfig ?? {}, !trigger.isDraft, workspaceId) + }, + http: (trigger: Trigger) => + saveHttpRouteFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + isAdmin, + usedTriggerKinds + ), + websocket: (trigger: Trigger) => + saveWebsocketTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + postgres: (trigger: Trigger) => + savePostgresTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + kafka: (trigger: Trigger) => + saveKafkaTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + nats: (trigger: Trigger) => + saveNatsTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + mqtt: (trigger: Trigger) => + saveMqttTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + sqs: (trigger: Trigger) => + saveSqsTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + gcp: (trigger: Trigger) => + saveGcpTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + poll: undefined, + cli: undefined + } + + await Promise.all( + triggersToDeploy.map(async (trigger) => { + const saveFunction = triggerSaveFunctions[trigger.type] + if (saveFunction) { + await saveFunction(trigger) + } else { + console.warn(`No save function defined for trigger type: ${trigger.type}`) + } + }) + ) +} + +export function handleSelectTriggerFromKind( + triggersState: Triggers, + triggersCountStore: Writable, + initialPath: string | undefined, + triggerKind: TriggerKind +) { + const triggerType = triggerKindToTriggerType(triggerKind) + + if (!triggerType) { + return + } + + const existingTriggerIndex = triggersState.triggers.findIndex( + (trigger) => trigger.type === triggerType + ) + + if (existingTriggerIndex !== -1) { + triggersState.selectedTriggerIndex = existingTriggerIndex + } else { + const newTrigger = triggersState.addDraftTrigger( + triggersCountStore, + triggerType, + triggerType === 'schedule' ? initialPath : undefined + ) + triggersState.selectedTriggerIndex = newTrigger + } +} + +export function handleConfigChange( + nCfg: Record, + initialConfig: Record | undefined, + saveDisabled: boolean, + edit: boolean, + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void +) { + let updated = false + if (!edit || !initialConfig) { + updated = true + } else { + // We ignore changes to enabled + let newCfg = { ...nCfg } + if ('enabled' in newCfg) { + delete newCfg.enabled + } + let initialCfg = { ...initialConfig } + if ('enabled' in initialCfg) { + delete initialCfg.enabled + } + if (JSON.stringify(newCfg) !== JSON.stringify(initialCfg)) { + updated = true + } + } + + onConfigChange?.(nCfg, saveDisabled, updated) +} + +export function getLightConfig( + triggerType: TriggerType, + trigger: Record +): Record | undefined { + if (triggerType === 'schedule') { + return { schedule: trigger.schedule, enable: trigger.enable, summary: trigger.summary } + } else if (triggerType === 'http') { + return { route_path: trigger.route_path, http_method: trigger.http_method } + } else if (triggerType === 'websocket') { + return { url: trigger.url } + } else if (triggerType === 'postgres') { + return { postgres_resource_path: trigger.postgres_resource_path } + } else if (triggerType === 'kafka') { + return { kafka_resource_path: trigger.kafka_resource_path, topics: trigger.topics } + } else if (triggerType === 'nats') { + return { nats_resource_path: trigger.nats_resource_path, subjects: trigger.subjects } + } else if (triggerType === 'mqtt') { + return { + mqtt_resource_path: trigger.mqtt_resource_path, + subscribe_topics: trigger.subscribe_topics + } + } else if (triggerType === 'sqs') { + return { queue_url: trigger.queue_url } + } else if (triggerType === 'gcp') { + return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic } + } else { + return undefined + } +} + +export function getTriggerLabel(trigger: Trigger): string { + const { type, isDraft, draftConfig, lightConfig, path } = trigger + const config = draftConfig ?? lightConfig + + if (type === 'webhook') { + return 'Webhook' + } else if (type === 'email') { + return 'Email' + } else if (type === 'cli') { + return 'CLI' + } else if (type === 'http' && !emptyString(config?.route_path)) { + return `${(draftConfig?.http_method ?? lightConfig?.http_method ?? 'post').toUpperCase()} ${draftConfig?.route_path ?? lightConfig?.route_path}` + } else if (type === 'schedule' && config?.summary) { + return `${config?.summary}` + } else if (type === 'kafka' && config?.topics && config?.kafka_resource_path) { + return `${config?.kafka_resource_path} - ${config?.topics.join(', ')}` + } else if (type === 'nats' && config?.subjects && config?.nats_resource_path) { + return `${config?.nats_resource_path} - ${config?.subjects.join(', ')}` + } else if (type === 'mqtt' && config?.subscribe_topics && config?.mqtt_resource_path) { + const topics = config?.subscribe_topics.map((topic: any) => topic.topic).join(', ') + return `${config?.mqtt_resource_path} - ${topics}` + } else if (type === 'sqs' && config?.queue_url) { + return `${config?.queue_url}` + } else if (type === 'gcp' && config?.gcp_resource_path && config?.topic) { + return `${config?.gcp_resource_path} - ${config?.topic}` + } else if (type === 'websocket' && config?.url) { + return `${config?.url}` + } else if (isDraft && draftConfig?.path) { + return `${draftConfig?.path}` + } else if (isDraft) { + return `New ${type.replace(/s$/, '')} trigger` + } else { + return path ?? '' + } +} + +export function sortTriggers(triggers: Trigger[]): Trigger[] { + const triggerTypeOrder = [ + 'webhook', + 'cli', + 'email', + 'poll', + 'schedule', + 'http', + 'websocket', + 'postgres', + 'kafka', + 'nats', + 'mqtt', + 'sqs', + 'gcp' + ] + + return triggers.sort((a, b) => { + // Draft triggers always come last + if (a.isDraft && !b.isDraft) return 1 + if (!a.isDraft && b.isDraft) return -1 + + // If both are drafts or both are not drafts, sort by type order + if (a.isDraft === b.isDraft) { + const aIndex = triggerTypeOrder.indexOf(a.type) + const bIndex = triggerTypeOrder.indexOf(b.type) + + // If both types are in the order array, sort by their position + if (aIndex >= 0 && bIndex >= 0) { + return aIndex - bIndex + } + + // If only one type is in the order array, it comes first + if (aIndex >= 0) return -1 + if (bIndex >= 0) return 1 + + // If neither type is in the order array, maintain original order + return 0 + } + + return 0 + }) +} + +export type FlowWithDraftAndDraftTriggers = Flow & { + draft?: Flow & { + draft_triggers?: Trigger[] + } +} + +export type NewScriptWithDraftAndDraftTriggers = NewScript & { + draft?: NewScript & { draft_triggers?: Trigger[] } + hash: string +} + +// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger +export function filterDraftTriggers( + savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers, + triggersState: Triggers +): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers { + const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft) + let newSavedValue = savedValue + + const filterMatchingTriggers = (savedTriggers: Trigger[], deployedTriggers: Trigger[]) => { + return savedTriggers.filter( + (savedTrigger) => + !deployedTriggers.some( + (deployedTrigger) => + deployedTrigger.path === savedTrigger.draftConfig?.path && + deployedTrigger.type === savedTrigger.type + ) + ) + } + + const savedDraftTriggersFiltered = filterMatchingTriggers( + newSavedValue?.draft?.draft_triggers ?? [], + deployedTriggers + ) + if (newSavedValue?.draft?.draft_triggers) { + newSavedValue = { + ...newSavedValue, + draft: { + ...newSavedValue.draft, + draft_triggers: + savedDraftTriggersFiltered.length > 0 ? savedDraftTriggersFiltered : undefined + } + } as typeof newSavedValue + } + triggersState.setTriggers([ + ...triggersState.triggers.filter((t) => !t.draftConfig), + ...savedDraftTriggersFiltered + ]) + return newSavedValue +} diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte new file mode 100644 index 0000000000..b6392bf41b --- /dev/null +++ b/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte @@ -0,0 +1,80 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Send a POST request to the URL below to simulate a webhook event. +

+ {:else} +

+ Start capturing to listen to webhook events on this test URL. +

+ {/if} + {/snippet} + + + +
+{/if} diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte index bd8bb7d044..1ee21df607 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte @@ -21,9 +21,6 @@ import { workspaceStore, userStore } from '$lib/stores' import UserSettings from '../../UserSettings.svelte' import { generateRandomString } from '$lib/utils' - import CopyableCodeBlock from '../../details/CopyableCodeBlock.svelte' - import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte' - import CaptureTable from '../CaptureTable.svelte' export let isFlow: boolean = false export let path: string = '' @@ -32,9 +29,6 @@ export let runnableArgs: any export let triggerTokens: TriggerTokens | undefined = undefined export let scopes: string[] = [] - export let showCapture: boolean = false - export let captureTable: CaptureTable | undefined = undefined - export let captureInfo: CaptureInfo | undefined = undefined let webhooks: { async: { @@ -198,17 +192,6 @@ function waitForJobCompletion(UUID) { return `${mainFunction}\n\n${triggerJobFunction}\n\n${waitForJobCompletionFunction}` } - let captureUrl = `${location.origin}/api/w/${$workspaceStore}/capture_u/webhook/${ - isFlow ? 'flow' : 'script' - }/${path}` - - function captureCurlCode() { - return `curl \\ --X POST ${captureUrl} \\ --H 'Content-Type: application/json' \\ --d '${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)}'` - } - function curlCode() { return `TOKEN='${token}' ${requestType !== 'get_path' ? `BODY='${JSON.stringify(cleanedRunnableArgs ?? {})}'` : ''} @@ -263,195 +246,162 @@ done` {scopes} /> -
- {#if showCapture && captureInfo} - - - -
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte index 12142f8343..269d69bb94 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte @@ -1,127 +1,61 @@ - { - loadTriggers() - }} - bind:this={wsTriggerEditor} -/> - -{#if isCloudHosted()} - - WebSocket triggers are disabled in the multi-tenant cloud. - -{:else} -
- - WebSocket triggers allow real-time bidirectional communication between your scripts/flows and - external systems. Each trigger creates a unique WebSocket endpoint. - - - {#if !newItem && wsTriggers && wsTriggers.length > 0} -
-
-
- {#each wsTriggers as wsTriggers (wsTriggers.path)} -
-
{wsTriggers.path}
-
- {wsTriggers.url} -
-
- -
-
- {/each} -
-
-
- {/if} - - { - wsTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="websocket" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + WebSocket triggers are disabled in the multi-tenant cloud. + + {:else} + + WebSocket triggers allow real-time bidirectional communication between your scripts/flows + and external systems. Each trigger creates a unique WebSocket endpoint. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/websocket/utils.ts b/frontend/src/lib/components/triggers/websocket/utils.ts new file mode 100644 index 0000000000..c112cb0f1a --- /dev/null +++ b/frontend/src/lib/components/triggers/websocket/utils.ts @@ -0,0 +1,46 @@ +import { WebsocketTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' + +export async function saveWebsocketTriggerFromCfg( + initialPath: string, + triggerCfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: triggerCfg.path, + script_path: triggerCfg.script_path, + is_flow: triggerCfg.is_flow, + url: triggerCfg.url, + filters: triggerCfg.filters, + initial_messages: triggerCfg.initial_messages, + url_runnable_args: triggerCfg.url_runnable_args, + can_return_message: triggerCfg.can_return_message + } + try { + if (edit) { + await WebsocketTriggerService.updateWebsocketTrigger({ + workspace: workspace, + path: initialPath, + requestBody: requestBody + }) + sendUserToast(`Websocket trigger ${triggerCfg.path} updated`) + } else { + await WebsocketTriggerService.createWebsocketTrigger({ + workspace: workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`Websocket trigger ${triggerCfg.path} created`) + } + if (!get(usedTriggerKinds).includes('ws')) { + usedTriggerKinds.update((t) => [...t, 'ws']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 9a126bf080..f6d2e42685 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -50,6 +50,12 @@ export const tutorialsToDo = writable([]) export const globalEmailInvite = writable('') export const awarenessStore = writable>(undefined) export const enterpriseLicense = writable(undefined) +export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLicense]) => { + if (enterpriseLicense?.endsWith('__whitelabel')) { + return enterpriseLicense.split('__whitelabel')[0] + } + return undefined +}) export const workerTags = writable(undefined) export const usageStore = writable(0) export const workspaceUsageStore = writable(0) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 2609eba53d..6c2bde6431 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1161,6 +1161,7 @@ export type Item = { disabled?: boolean type?: 'action' | 'delete' hide?: boolean | undefined + extra?: Snippet } export function isObjectTooBig(obj: any): boolean { @@ -1245,6 +1246,15 @@ export function formatDateShort(dateString: string | undefined): string { }).format(date) } +export function toJsonStr(result: any) { + try { + // console.log(result) + return JSON.stringify(result ?? null, null, 4) ?? 'null' + } catch (e) { + return 'error stringifying object: ' + e.toString() + } +} + export function getOS() { const userAgent = window.navigator.userAgent const platform = window.navigator.platform @@ -1268,6 +1278,7 @@ export function getOS() { import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' +import type { Snippet } from 'svelte' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index fe46d90921..dc3876ffc5 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -11,7 +11,7 @@ UserService, WorkspaceService } from '$lib/gen' - import { classNames, getModifierKey } from '$lib/utils' + import { capitalize, classNames, getModifierKey } from '$lib/utils' import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' @@ -29,7 +29,8 @@ hubBaseUrlStore, usedTriggerKinds, devopsRole, - setCopilotInfo + setCopilotInfo, + whitelabelNameStore } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' import { afterNavigate, beforeNavigate } from '$app/navigation' @@ -428,7 +429,11 @@
- Windmill + {#if $whitelabelNameStore} + {$whitelabelNameStore} + {:else} + Windmill + {/if}
@@ -481,7 +486,11 @@
{#if !isCollapsed} -
Windmill
+
+ {#if $whitelabelNameStore}{capitalize( + $whitelabelNameStore + )}{:else}Windmill{/if} +
{/if}
@@ -579,7 +588,9 @@ class:w-40={!isCollapsed} > - {#if !isCollapsed}Windmill{/if} + {#if !isCollapsed}{#if $whitelabelNameStore}{capitalize( + $whitelabelNameStore + )}{:else}Windmill{/if}{/if}
diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index e291917235..056424922b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -13,9 +13,9 @@ import { decodeState, emptySchema } from '$lib/utils' import { tick } from 'svelte' import { writable } from 'svelte/store' - import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' + import type { Trigger } from '$lib/components/triggers/utils' let nodraft = $page.url.searchParams.get('nodraft') @@ -43,6 +43,7 @@ initialArgs = $initialArgsStore $initialArgsStore = undefined } + let flowBuilder: FlowBuilder | undefined = undefined export const flowStore = writable({ summary: '', @@ -56,7 +57,8 @@ }) const flowStateStore = writable({}) - let savedPrimarySchedule: ScheduleTrigger | undefined = undefined + let draftTriggersFromUrl: Trigger[] | undefined = undefined + let selectedTriggerIndexFromUrl: number | undefined = undefined async function loadFlow() { loading = true let flow: Flow = { @@ -101,7 +103,10 @@ flow = state.flow pathStoreInit = state.path - savedPrimarySchedule = state.primarySchedule + draftTriggersFromUrl = state.draft_triggers + selectedTriggerIndexFromUrl = state.selected_trigger + flowBuilder?.setDraftTriggers(draftTriggersFromUrl) + flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl) state?.selectedId && (selectedId = state?.selectedId) } else { if (templatePath) { @@ -153,7 +158,6 @@ loadFlow() let getSelectedId: (() => string) | undefined = undefined - let flowBuilder: FlowBuilder | undefined = undefined let getInitialAndModifiedValues: GetInitialAndModifiedValues | undefined = undefined @@ -181,7 +185,8 @@ {flowStateStore} {selectedId} {loading} - {savedPrimarySchedule} + {draftTriggersFromUrl} + {selectedTriggerIndexFromUrl} > diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 51e0e9d6e3..8001ee0281 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -15,6 +15,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import type { Trigger } from '$lib/components/triggers/utils' let version: undefined | number = undefined let nodraft = $page.url.searchParams.get('nodraft') @@ -60,6 +61,9 @@ let savedPrimarySchedule: ScheduleTrigger | undefined = stateLoadedFromUrl?.primarySchedule + let draftTriggersFromUrl: Trigger[] | undefined = undefined + let selectedTriggerIndexFromUrl: number | undefined = undefined + let flowBuilder: FlowBuilder | undefined = undefined async function loadFlow(): Promise { @@ -82,12 +86,19 @@ }) const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow) - const urlScript = cleanValueProperties(stateLoadedFromUrl.flow) + const urlScript = cleanValueProperties({ + ...stateLoadedFromUrl.flow, + draft_triggers: stateLoadedFromUrl.draft_triggers + }) flow = stateLoadedFromUrl.flow - savedPrimarySchedule = stateLoadedFromUrl.primarySchedule + draftTriggersFromUrl = stateLoadedFromUrl.draft_triggers + selectedTriggerIndexFromUrl = stateLoadedFromUrl.selected_trigger + flowBuilder?.setDraftTriggers(draftTriggersFromUrl) + flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl) + const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata' const reloadAction = () => { stateLoadedFromUrl = undefined - goto(`/flows/edit/${statePath}`) + goto(`/flows/edit/${statePath}?selected=${selectedId}`) loadFlow() } if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) { @@ -133,15 +144,18 @@ ? { ...structuredClone(flowWithDraft.draft), path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path - } + } : undefined } as Flow & { - draft?: Flow + draft?: Flow & { + draft_triggers?: Trigger[] + } } if (flowWithDraft.draft != undefined && !nobackenddraft) { flow = flowWithDraft.draft savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule'] flowBuilder?.setPrimarySchedule(savedPrimarySchedule) + flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers']) if (!flowWithDraft.draft_only) { const deployed = cleanValueProperties(flowWithDraft) @@ -178,6 +192,7 @@ } } else { flow = flowWithDraft + flowBuilder?.setDraftTriggers(undefined) } } @@ -200,6 +215,7 @@ return } diffDrawer.closeDrawer() + stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.draft.path}`) loadFlow() } @@ -217,6 +233,7 @@ path: savedFlow.path }) } + stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.path}`) loadFlow() } @@ -251,6 +268,8 @@ bind:savedFlow {diffDrawer} {savedPrimarySchedule} + {draftTriggersFromUrl} + {selectedTriggerIndexFromUrl} bind:version bind:getInitialAndModifiedValues > diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index a20cfd427a..55bf1d5d1c 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -41,35 +41,26 @@ } from 'lucide-svelte' import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' - import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte' - import CliHelpBox from '$lib/components/CliHelpBox.svelte' import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' - import RunPageSchedules from '$lib/components/RunPageSchedules.svelte' import { createAppFromFlow } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' import TimeAgo from '$lib/components/TimeAgo.svelte' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' - import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte' import Star from '$lib/components/Star.svelte' - import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte' import { Highlight } from 'svelte-highlight' import json from 'svelte-highlight/languages/json' import { writable } from 'svelte/store' - import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' - import WebsocketTriggersPanel from '$lib/components/triggers/websocket/WebsocketTriggersPanel.svelte' - import KafkaTriggersPanel from '$lib/components/triggers/kafka/KafkaTriggersPanel.svelte' - import NatsTriggersPanel from '$lib/components/triggers/nats/NatsTriggersPanel.svelte' - import PostgresTriggersPanel from '$lib/components/triggers/postgres/PostgresTriggersPanel.svelte' import Toggle from '$lib/components/Toggle.svelte' - import MqttTriggersPanel from '$lib/components/triggers/mqtt/MqttTriggersPanel.svelte' - import SqsTriggerPanel from '$lib/components/triggers/sqs/SqsTriggerPanel.svelte' - import { onDestroy } from 'svelte' + import { onDestroy, tick } from 'svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import GcpTriggerPanel from '$lib/components/triggers/gcp/GcpTriggerPanel.svelte' + import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' + import type { TriggerContext } from '$lib/components/triggers' + import { setContext } from 'svelte' + import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' + import { Triggers } from '$lib/components/triggers/triggers.svelte' let flow: Flow | undefined let can_write = false @@ -85,9 +76,29 @@ let intervalId: NodeJS.Timeout | undefined = undefined + $: { + const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli') + if (cliTrigger) { + cliTrigger.extra = { + cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'` + } + } + } + const triggersCount = writable(undefined) - $: cliCommand = `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'` + // Add triggers context store + const triggersState = new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'email', path: '', isDraft: false }, + { type: 'cli', path: '', isDraft: false } + ]) + setContext('TriggerContext', { + triggersCount, + simplifiedPoll: writable(false), + showCaptureHint: writable(undefined), + triggersState + }) let previousPath: string | undefined = undefined $: { @@ -96,6 +107,7 @@ previousPath = path loadFlow() loadTriggersCount() + loadTriggers() } } } @@ -124,6 +136,17 @@ }) } + async function loadTriggers(): Promise { + await triggersState.fetchTriggers( + triggersCount, + $workspaceStore, + path, + true, + undefined, + $userStore + ) + } + async function loadFlow(): Promise { flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, @@ -371,7 +394,7 @@ } } let stepDetail: FlowModule | string | undefined = undefined - let token = 'TOKEN_TO_CREATE' + let rightPaneSelected = 'saved_inputs' let savedInputsV2: SavedInputsV2 | undefined = undefined let flowHistory: FlowHistory | undefined = undefined @@ -398,7 +421,6 @@ {#if flow} { + onSelect={async (triggerIndex: number) => { rightPaneSelected = 'triggers' + await tick() + triggersState.selectedTriggerIndex = triggerIndex }} + small={false} /> {#if $workspaceStore} @@ -590,89 +616,23 @@
- -
- -
-
- -
- -
-
- -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- -
- -
-
- -
- - -
-
- {#if stepDetail} {/if} + + + {/if} diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 9fcaa0f202..2fd36a1cc1 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -263,7 +263,7 @@ - + - + - + - + - + - + - + { @@ -122,7 +120,6 @@ searchParams={$page.url.searchParams} {script} {showMeta} - {savedPrimarySchedule} replaceStateFn={(path) => replaceState(path, $page.state)} > diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index fbe85f83c0..803b29bce8 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -12,6 +12,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import type { Trigger } from '$lib/components/triggers/utils' let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined let initialArgs = {} @@ -26,7 +27,8 @@ let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined - let script: NewScript | undefined = undefined + + let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = undefined let initialPath: string = '' @@ -37,7 +39,7 @@ let savedScript: NewScriptWithDraft | undefined = undefined let fullyLoaded = false - let savedPrimarySchedule: ScheduleTrigger | undefined = scriptLoadedFromUrl?.primarySchedule + let savedPrimarySchedule: ScheduleTrigger | undefined = undefined async function loadScript(): Promise { fullyLoaded = false @@ -98,10 +100,12 @@ savedScript = structuredClone(scriptWithDraft) if (scriptWithDraft.draft != undefined) { script = scriptWithDraft.draft + scriptBuilder?.setDraftTriggers(script.draft_triggers) if (script['primary_schedule']) { savedPrimarySchedule = script['primary_schedule'] scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) } + if (!scriptWithDraft.draft_only) { reloadAction = async () => { scriptLoadedFromUrl = undefined @@ -153,6 +157,7 @@ if (script) { initialPath = script.path + scriptBuilder?.setDraftTriggers(script.draft_triggers) scriptBuilder?.setCode(script.content) if (topHash) { script.parent_hash = topHash @@ -176,6 +181,7 @@ } diffDrawer.closeDrawer() goto(`/scripts/edit/${savedScript.draft.path}`) + scriptLoadedFromUrl = undefined loadScript() } @@ -193,6 +199,7 @@ }) } goto(`/scripts/edit/${savedScript.path}`) + scriptLoadedFromUrl = undefined loadScript() } diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 0a7d41a4dc..dd4f7fa15f 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -21,7 +21,7 @@ import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable' - import { onDestroy } from 'svelte' + import { onDestroy, setContext, tick } from 'svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' import { Tabs, @@ -42,10 +42,8 @@ import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte' - import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte' import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte' import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' - import CliHelpBox from '$lib/components/CliHelpBox.svelte' import { Activity, Archive, @@ -68,30 +66,22 @@ import { scriptToHubUrl } from '$lib/hub' import SharedBadge from '$lib/components/SharedBadge.svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' - import RunPageSchedules from '$lib/components/RunPageSchedules.svelte' import { createAppFromScript } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' import TimeAgo from '$lib/components/TimeAgo.svelte' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' import PersistentScriptDrawer from '$lib/components/PersistentScriptDrawer.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' - import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte' import Star from '$lib/components/Star.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte' import { Highlight } from 'svelte-highlight' import json from 'svelte-highlight/languages/json' import { writable } from 'svelte/store' - import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' - import WebsocketTriggersPanel from '$lib/components/triggers/websocket/WebsocketTriggersPanel.svelte' - import KafkaTriggersPanel from '$lib/components/triggers/kafka/KafkaTriggersPanel.svelte' - import NatsTriggersPanel from '$lib/components/triggers/nats/NatsTriggersPanel.svelte' - import PostgresTriggersPanel from '$lib/components/triggers/postgres/PostgresTriggersPanel.svelte' import Toggle from '$lib/components/Toggle.svelte' import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' - import MqttTriggersPanel from '$lib/components/triggers/mqtt/MqttTriggersPanel.svelte' - import SqsTriggerPanel from '$lib/components/triggers/sqs/SqsTriggerPanel.svelte' - import GcpTriggerPanel from '$lib/components/triggers/gcp/GcpTriggerPanel.svelte' + import type { TriggerContext } from '$lib/components/triggers' + import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' + import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' + import { Triggers } from '$lib/components/triggers/triggers.svelte' let script: Script | undefined let topHash: string | undefined @@ -107,7 +97,14 @@ let inputSelected: 'saved' | 'history' | undefined = undefined let jsonView = false - $: cliCommand = `wmill script run ${script?.path} -d '${JSON.stringify(args)}'` + $: { + const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli') + if (cliTrigger) { + cliTrigger.extra = { + cliCommand: `wmill script run ${script?.path} -d '${JSON.stringify(args)}'` + } + } + } $: loading = !script @@ -121,6 +118,19 @@ const triggersCount = writable(undefined) + // Add triggers context store + const triggersState = new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'email', path: '', isDraft: false }, + { type: 'cli', path: '', isDraft: false } + ]) + setContext('TriggerContext', { + triggersCount, + simplifiedPoll: writable(false), + showCaptureHint: writable(undefined), + triggersState + }) + async function deleteScript(hash: string): Promise { try { await ScriptService.deleteScriptByHash({ workspace: $workspaceStore!, hash }) @@ -167,11 +177,15 @@ } let starred: boolean | undefined = undefined - async function loadTriggersCount(path: string) { - $triggersCount = await ScriptService.getTriggersCountOfScript({ - workspace: $workspaceStore!, - path: path - }) + async function loadTriggers(path: string): Promise { + await triggersState.fetchTriggers( + triggersCount, + $workspaceStore, + path, + false, + undefined, + $userStore + ) } async function loadScript(hash: string): Promise { @@ -194,7 +208,7 @@ can_write = script.workspace_id == $workspaceStore && canWrite(script.path, script.extra_perms!, $userStore) - loadTriggersCount(script.path) + loadTriggers(script.path) if (script.path && script.archived) { const script_by_path = await ScriptService.getScriptByPath({ @@ -497,7 +511,6 @@ } } - let token = 'TOKEN_TO_CREATE' let rightPaneSelected = 'saved_inputs' let savedInputsV2: SavedInputsV2 | undefined = undefined @@ -533,11 +546,7 @@ {#key script.hash} - + { + rightPaneSelected = 'triggers' + }} > { - rightPaneSelected = 'triggers' + selected={rightPaneSelected === 'triggers'} + onSelect={async (triggerIndex: number) => { + if (rightPaneSelected !== 'triggers') { + rightPaneSelected = 'triggers' + } + await tick() + triggersState.selectedTriggerIndex = triggerIndex }} /> @@ -740,76 +757,18 @@ /> {/if} - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
+ +
@@ -878,12 +837,6 @@ {/if} - -
- - -
-
{/key} {/if} diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b5a5522652..f117702fba 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -220,7 +220,7 @@ - +
- {#if !$enterpriseLicense || !$enterpriseLicense?.endsWith('_whitelabel')} + {#if !$enterpriseLicense || !$whitelabelNameStore} {/if}
diff --git a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte index ceaf3950c4..03baae3159 100644 --- a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte @@ -219,7 +219,7 @@ - + >({}) const selectedIdStore = writable('settings-metadata') - const primaryScheduleStore = writable(undefined) const triggersCount = writable(undefined) - const selectedTriggerStore = writable< - 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll' - >('webhooks') setContext('TriggerContext', { - primarySchedule: primaryScheduleStore, - selectedTrigger: selectedTriggerStore, triggersCount: triggersCount, simplifiedPoll: writable(false), - defaultValues: writable(undefined), - captureOn: writable(undefined), - showCaptureHint: writable(undefined) + showCaptureHint: writable(undefined), + triggersState: new Triggers() }) setContext('FlowEditorContext', { diff --git a/lsp/Pipfile b/lsp/Pipfile index c2e3293104..43a4bff2fc 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,8 +4,8 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.491.5" -wmill_pg = ">=1.491.5" +wmill = ">=1.492.1" +wmill_pg = ">=1.492.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 329046b615..2fe9558a19 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.491.5 + version: 1.492.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 36e139098d..46f6c80082 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.491.5' + ModuleVersion = '1.492.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index b3f5946311..4ef3ce0c9b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.491.5" +version = "1.492.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill_pg/pyproject.toml b/python-client/wmill_pg/pyproject.toml index d002237168..035c60e4ea 100644 --- a/python-client/wmill_pg/pyproject.toml +++ b/python-client/wmill_pg/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill-pg" -version = "1.491.5" +version = "1.492.1" description = "An extension client for the wmill client library focused on pg" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 04ec369d79..369565be3b 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.491.5", + "version": "1.492.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 8973841c0d..0fe5add40c 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.491.5", + "version": "1.492.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "devDependencies": { diff --git a/version.txt b/version.txt index c09435a9a9..76f3392713 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.491.5 +1.492.1