squashed 2

This commit is contained in:
Ruben Fiszel
2025-05-24 13:26:38 +02:00
parent 26222539e6
commit b535c13be5
187 changed files with 8563 additions and 10446 deletions
+78 -51
View File
@@ -5,42 +5,12 @@ on:
types: [submitted]
jobs:
check-membership:
auto-fix-review:
if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]')
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
runs-on: ubicloud-standard-8
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 }}
@@ -49,39 +19,96 @@ jobs:
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
steps:
- name: Acknowledge Request
- 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
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
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)"
- name: Prepare prompt for Aider
id: prepare_prompt
- 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
shell: bash
env:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REVIEW_BODY: ${{ github.event.review.body }}
run: |
REVIEW_BODY_ESCAPED="${REVIEW_BODY//\\/\\\\}"
REVIEW_BODY_ESCAPED="${REVIEW_BODY_ESCAPED//\"/\\\"}"
mkdir -p .github/aider
PROMPT_FILE_PATH=".github/aider/review-prompt.txt"
# 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)
FORMATTED_COMMENTS=$(jq -r '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]' <<< "$ALL_REVIEW_COMMENTS")
/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments \
| jq '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]')
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
COMPLETE_PROMPT="${BASE_PROMPT}"$'\n'"REVIEW:"$'\n'"${REVIEW_BODY_ESCAPED}"$'\n'"REVIEW_COMMENTS:"$'\n'"${FORMATTED_COMMENTS}"
- 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
echo "prompt_content<<EOF" >> $GITHUB_OUTPUT
echo "$COMPLETE_PROMPT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
PROMPT_ESCAPED=$(jq -Rs . <<< "$PROMPT_CONTENT")
run-aider:
needs: [check-membership, check-and-prepare]
+46 -24
View File
@@ -5,21 +5,19 @@ on:
types: [external_issue_fix]
jobs:
check-and-prepare:
runs-on: ubicloud-standard-2
auto-fix:
runs-on: ubicloud-standard-8
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 }}
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 }}
<<<<<<< Updated upstream:.github/workflows/aider-external.yaml
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
@@ -44,29 +42,53 @@ jobs:
"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 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
>>>>>>> Stashed changes:.github/workflows/linear-issue.yaml
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git User
run: |
echo "Determining inputs for Aider..."
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
echo "ISSUE_TITLE<<EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
echo "$ISSUE_TITLE" >> "$GITHUB_OUTPUT"
echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
echo "ISSUE_BODY<<EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
echo "$ISSUE_BODY" >> "$GITHUB_OUTPUT"
echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
- 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
echo "INSTRUCTION<<EOF_AIDER_INSTRUCTION" >> "$GITHUB_OUTPUT"
echo "$INSTRUCTION" >> "$GITHUB_OUTPUT"
echo "EOF_AIDER_INSTRUCTION" >> "$GITHUB_OUTPUT"
echo "Finished determining inputs."
- 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}}')
run-aider:
needs: check-and-prepare
+246 -64
View File
@@ -5,40 +5,12 @@ on:
types: [created]
jobs:
check-membership:
runs-on: ubicloud-standard-2
auto-fix:
runs-on: ubicloud-standard-8
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
@@ -49,23 +21,53 @@ 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: Acknowledge Request
- 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
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
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)"
- name: Determine inputs for Aider
id: determine_inputs
- 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
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -74,21 +76,16 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
run: |
echo "Determining inputs for Aider..."
ISSUE_TITLE_VAL=""
ISSUE_BODY_VAL=""
PROMPT_FILE_PATH=".github/aider/issue-prompt.txt"
mkdir -p .github/aider
# 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="$ISSUE_NUMBER"
PR_NUMBER="${{ github.event.issue.number }}"
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
PR_BODY_VAL=$(jq -r '.body // ""' <<< "$PR_BODY_JSON")
fi
# Get PR description to check for issue references
PR_BODY=$(gh pr view $PR_NUMBER --json body -q .body --repo $GITHUB_REPOSITORY)
if [[ ! -z "$PR_BODY_VAL" ]]; then
REFERENCED_ISSUE=""
@@ -119,29 +116,214 @@ jobs:
ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON")
ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON")
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
else
echo "PR body is empty or could not be fetched."
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"
fi
fi
else
echo "This is a comment on a regular issue"
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"
# 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
else
ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON")
ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON")
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 CLAUDE.md \
--read backend/CLAUDE.md \
--read frontend/CLAUDE.md \
${{ 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
fi
fi
echo "ISSUE_TITLE<<EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
echo "$ISSUE_TITLE_VAL" >> "$GITHUB_OUTPUT"
echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
- 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_BODY<<EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
echo "$ISSUE_BODY_VAL" >> "$GITHUB_OUTPUT"
echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
## Aider Output
\`\`\`
$(cat .github/aider/aider-output.txt || echo "No output available")
\`\`\`
EOL
CLEAN_COMMENT="${COMMENT_BODY/\/aider/}"
CLEAN_COMMENT="${CLEAN_COMMENT#"${CLEAN_COMMENT%%[![:space:]]*}"}"
+4 -2
View File
@@ -49,9 +49,11 @@ jobs:
fi
claude-code-action:
needs: check-membership
if: |
needs.check-membership.outputs.is_member == 'true'
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/aider')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/aider')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/aider')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/aider'))
runs-on: ubicloud-standard-8
permissions:
contents: read
+1 -27
View File
@@ -3,34 +3,8 @@ 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:
needs: check-membership
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }}
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }}
uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main
with:
pr_number: ${{ github.event.issue.number }}
+2 -1
View File
@@ -11,5 +11,6 @@
"remote.autoForwardPorts": true,
"conventionalCommits.scopes": [
"restructring triggers, decoding trigger message on work"
]
],
"rust-analyzer.cargo.features": ["license", "enterprise", "agent_worker_server"]
}
+5 -1
View File
@@ -1 +1,5 @@
72e6260ca886628cf1ba271bc058e6ecfdecdae5
<<<<<<< Updated upstream
72e6260ca886628cf1ba271bc058e6ecfdecdae5
=======
0cf6424295774980cd04f10986b826fbe09542a0
>>>>>>> Stashed changes
+22 -3
View File
@@ -15,6 +15,7 @@ use monitor::{
send_logs_to_object_store, WORKERS_NAMES,
};
use rand::Rng;
use serde_json::{Map, Value};
use sqlx::postgres::PgListener;
use std::{
collections::HashMap,
@@ -48,10 +49,13 @@ use windmill_common::{
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING,
TIMEOUT_WAIT_RESULT_SETTING,
},
jwt::decode_without_verify,
scripts::ScriptLang,
stats_ee::schedule_stats,
triggers::TriggerKind,
utils::{hostname, rd_string, Mode, GIT_VERSION, MODE_AND_ADDONS},
utils::{
rd_string, Mode, AGENT_JWT_PREFIX, AGENT_TOKEN, GIT_VERSION, HOSTNAME, MODE_AND_ADDONS,
},
worker::{
reload_custom_tags_setting, Connection, HUB_CACHE_DIR, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP,
},
@@ -261,7 +265,7 @@ async fn windmill_main() -> anyhow::Result<()> {
tracing::error!("Failed to install rustls crypto provider");
}
let hostname = hostname();
let hostname = HOSTNAME.to_owned();
let mode_and_addons = MODE_AND_ADDONS.clone();
let mode = mode_and_addons.mode;
@@ -675,12 +679,27 @@ Windmill Community Edition {GIT_VERSION}
let base_internal_url = base_internal_rx.await?;
if worker_mode {
let mut workers = vec![];
let suffix_to_append = match mode {
Mode::Agent => {
let decoded_token = decode_without_verify::<Map<String, Value>>(
AGENT_TOKEN.trim_start_matches(AGENT_JWT_PREFIX),
)?;
let suffix_to_append = decoded_token["suffix"].as_str();
suffix_to_append.map(|s| s.to_owned())
}
_ => None,
};
for i in 0..num_workers {
let suffix: String = if i == 0 && first_suffix.as_ref().is_some() {
let mut suffix = if i == 0 && first_suffix.is_some() {
first_suffix.as_ref().unwrap().clone()
} else {
windmill_common::utils::worker_suffix(&hostname, &rd_string(5))
};
if let Some(suffix_to_append) = suffix_to_append.as_ref() {
suffix = format!("{}_{}", suffix, &suffix_to_append);
}
let worker_conn = WorkerConn {
conn: if i == 0 || mode != Mode::Agent {
conn.clone()
+1 -1
View File
@@ -1986,7 +1986,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, worker
None,
error::Error::ExecutionErr(error_message),
true,
same_worker_tx_never_used,
Some(&same_worker_tx_never_used),
"",
worker_name,
send_result_never_used,
+9
View File
@@ -4498,6 +4498,13 @@ paths:
in: query
schema:
type: boolean
- name: languages
in: query
description: |
Filter to only include scripts written in the given languages.
Accepts multiple values as a comma-separated list.
schema:
type: string
responses:
"200":
description: All scripts
@@ -11142,6 +11149,8 @@ paths:
type: array
items:
type: string
suffix:
type: string
exp:
type: integer
required:
+10 -6
View File
@@ -3154,11 +3154,12 @@ async fn check_tag_available_for_workspace(
let tags = get_scope_tags(authed);
if let Some(tags) = tags {
if !tags.contains(&tag.as_str()) {
if !tags.contains(&tag.as_str()) && !authed.is_admin {
return Err(Error::BadRequest(format!(
"Tag {tag} is not available in your scope"
"Tag '{tag}' is not available in your scope. Only admins can use tags outside of the allowed set: {:?}",
tags
)));
}
}
}
let custom_tags_per_w = CUSTOM_TAGS_PER_WORKSPACE.read().await;
@@ -3172,11 +3173,14 @@ async fn check_tag_available_for_workspace(
.contains(&w_id.to_string())
{
Ok(())
} else {
} else if !authed.is_admin {
return Err(error::Error::BadRequest(format!(
"Tag {tag} cannot be used on workspace {w_id}: (CUSTOM_TAGS: {:?})",
custom_tags_per_w
"Tag '{tag}' cannot be used on workspace '{w_id}' by non-admin users. \
Only admins are allowed to use tags that are not included in the allowed CUSTOM_TAGS: {:?}",
custom_tags_per_w.0
)));
} else {
Ok(())
}
} else {
Ok(())
@@ -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, empty_as_none},
utils::{not_found_if_none, paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -46,8 +46,7 @@ pub struct Postgres {
pub dbname: String,
#[serde(default)]
pub sslmode: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub root_certificate_pem: Option<String>,
pub root_certificate_pem: String,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
@@ -84,8 +84,8 @@ pub async fn get_raw_postgres_connection(
}
};
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())
let options = if !db.root_certificate_pem.is_empty() {
options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec())
} else {
options
};
@@ -96,6 +96,7 @@ pub async fn get_raw_postgres_connection(
options
}
};
Ok(PgConnection::connect_with(&options).await?)
}
@@ -19,10 +19,10 @@ use crate::{
use bytes::{BufMut, Bytes, BytesMut};
use chrono::TimeZone;
use futures::{pin_mut, SinkExt, StreamExt};
use native_tls::{Certificate, TlsConnector};
use native_tls::TlsConnector;
use pg_escape::{quote_identifier, quote_literal};
use rand::seq::SliceRandom;
use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, NoTls, SimpleQueryMessage};
use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, SimpleQueryMessage};
use rust_postgres_native_tls::MakeTlsConnector;
use serde::Deserialize;
use serde_json::value::RawValue;
@@ -79,45 +79,6 @@ enum Error {
Tls(#[from] native_tls::Error),
}
fn build_tls_connector(
ssl_mode: SslMode,
root_certificate_pem: Option<&String>,
) -> Result<Option<MakeTlsConnector>, 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 {
@@ -151,27 +112,20 @@ impl PostgresSimpleClient {
config.password(&database.password);
}
let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?;
if !database.root_certificate_pem.is_empty() {
config.ssl_root_cert(database.root_certificate_pem.as_bytes());
}
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
};
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");
});
Ok(PostgresSimpleClient(client))
}
+10 -1
View File
@@ -206,7 +206,6 @@ async fn list_scripts(
Query(lq): Query<ListScriptQuery>,
) -> JsonResult<Vec<ListableScript>> {
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("script as o")
.fields(&[
"hash",
@@ -321,6 +320,16 @@ async fn list_scripts(
.fields(&["dm.deployment_msg"]);
}
if let Some(languages) = lq.languages {
sqlb.and_where_in(
"language",
&languages
.iter()
.map(|language| quote(language.as_str()))
.collect_vec(),
);
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableScript>(&sql)
-13
View File
@@ -5,9 +5,6 @@
* 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};
@@ -38,16 +35,6 @@ 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?;
+1 -3
View File
@@ -11,12 +11,11 @@ use std::time::Duration;
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use crate::{jwt::decode_without_verify, worker::HttpClient};
use crate::{jwt::decode_without_verify, utils::{AGENT_JWT_PREFIX, AGENT_TOKEN}, worker::HttpClient};
lazy_static! {
pub static ref BASE_INTERNAL_URL: String =
std::env::var("BASE_INTERNAL_URL").unwrap_or("http://localhost:8080".to_string());
pub static ref AGENT_TOKEN: String = std::env::var("AGENT_TOKEN").unwrap_or_default();
pub static ref DECODED_AGENT_TOKEN: Option<AgentAuth> = {
if AGENT_TOKEN.is_empty() {
None
@@ -35,7 +34,6 @@ pub struct AgentAuth {
pub exp: Option<usize>,
}
pub const AGENT_JWT_PREFIX: &str = "jwt_agent_";
pub fn build_agent_http_client(worker_suffix: &str) -> HttpClient {
let client = ClientBuilder::new(
reqwest::Client::builder()
-1
View File
@@ -20,7 +20,6 @@ pub async fn encode_with_internal_secret<T: Serialize>(claims: T) -> error::Resu
&jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes()),
)
.map_err(to_anyhow)?;
Ok(token)
}
+60 -1
View File
@@ -9,6 +9,7 @@
use std::{
fmt::{self, Display},
hash::{Hash, Hasher},
str::FromStr,
};
use crate::{
@@ -21,6 +22,7 @@ use crate::worker::HUB_CACHE_DIR;
use anyhow::Context;
use backon::ConstantBuilder;
use backon::{BackoffBuilder, Retryable};
use itertools::Itertools;
use serde::de::Error as _;
use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize};
@@ -84,6 +86,40 @@ impl ScriptLang {
}
}
impl FromStr for ScriptLang {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let language = match s.to_lowercase().as_str() {
"bun" => ScriptLang::Bun,
"bunnative" => ScriptLang::Bunnative,
"nativets" => ScriptLang::Nativets,
"deno" => ScriptLang::Deno,
"python3" => ScriptLang::Python3,
"go" => ScriptLang::Go,
"bash" => ScriptLang::Bash,
"powershell" => ScriptLang::Powershell,
"postgresql" => ScriptLang::Postgresql,
"mysql" => ScriptLang::Mysql,
"bigquery" => ScriptLang::Bigquery,
"snowflake" => ScriptLang::Snowflake,
"mssql" => ScriptLang::Mssql,
"graphql" => ScriptLang::Graphql,
"oracledb" => ScriptLang::OracleDB,
"php" => ScriptLang::Php,
"rust" => ScriptLang::Rust,
"ansible" => ScriptLang::Ansible,
"csharp" => ScriptLang::CSharp,
"nu" => ScriptLang::Nu,
"java" => ScriptLang::Java,
language => {
return Err(anyhow::anyhow!("{} is currently not supported", language).into())
}
};
Ok(language)
}
}
#[derive(Eq, PartialEq, Debug, Hash, Clone, Copy, sqlx::Type)]
#[sqlx(transparent)]
pub struct ScriptHash(pub i64);
@@ -366,7 +402,7 @@ where
deserializer.deserialize_any(StringOrArrayVisitor)
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
pub struct ListScriptQuery {
pub path_start: Option<String>,
pub path_exact: Option<String>,
@@ -383,6 +419,29 @@ pub struct ListScriptQuery {
pub include_without_main: Option<bool>,
pub include_draft_only: Option<bool>,
pub with_deployment_msg: Option<bool>,
#[serde(default, deserialize_with = "from_seq")]
pub languages: Option<Vec<ScriptLang>>,
}
fn from_seq<'de, D>(deserializer: D) -> Result<Option<Vec<ScriptLang>>, D::Error>
where
D: Deserializer<'de>,
{
let s = <String>::deserialize(deserializer)?;
let languages: Vec<ScriptLang> = s
.split(",")
.map(ScriptLang::from_str)
.try_collect()
.map_err(|e| serde::de::Error::custom(e.to_string()))?;
let languages = if languages.is_empty() {
None
} else {
Some(languages)
};
Ok(languages)
}
pub fn to_i64(s: &str) -> crate::error::Result<i64> {
+67 -11
View File
@@ -14,11 +14,13 @@ use crate::error::{to_anyhow, Error, Result};
use crate::global_settings::UNIQUE_ID_SETTING;
use crate::DB;
use anyhow::Context;
use axum::http::{HeaderName, HeaderValue};
use gethostname::gethostname;
use git_version::git_version;
use chrono::Utc;
use croner::Cron;
use hyper::HeaderMap;
use rand::{distr::Alphanumeric, rng, Rng};
use reqwest::Client;
use semver::Version;
@@ -33,6 +35,10 @@ pub const DEFAULT_PER_PAGE: usize = 1000;
pub const GIT_VERSION: &str =
git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
pub const AGENT_JWT_PREFIX: &str = "jwt_agent_";
pub const WORKER_NAME_PREFIX: &str = "wk";
pub const AGENT_WORKER_NAME_PREFIX: &str = "ag";
use crate::CRITICAL_ALERT_MUTE_UI_ENABLED;
use std::panic::{self, AssertUnwindSafe, Location};
use std::sync::atomic::Ordering;
@@ -53,6 +59,12 @@ lazy_static::lazy_static! {
}
).unwrap_or(Version::new(0, 1, 0));
pub static ref HOSTNAME :String = std::env::var("FORCE_HOSTNAME").unwrap_or_else(|_| {
gethostname()
.to_str()
.map(|x| x.to_string())
.unwrap_or_else(|| rd_string(5))
});
pub static ref MODE_AND_ADDONS: ModeAndAddons = {
let mut search_addon = false;
@@ -120,6 +132,33 @@ lazy_static::lazy_static! {
};
}
lazy_static::lazy_static! {
pub static ref AGENT_TOKEN: String = std::env::var("AGENT_TOKEN").unwrap_or_default();
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum AgentWorkerSuffix {
EnableLiveShell,
}
impl TryFrom<&str> for AgentWorkerSuffix {
type Error = Error;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
let suffix = match value {
"enable.live.shell" => AgentWorkerSuffix::EnableLiveShell,
suffix => {
return Err(Error::Anyhow {
error: anyhow::anyhow!("Unknown suffix: {}", suffix),
location: "utils.rs@149".to_string(),
})
}
};
Ok(suffix)
}
}
#[derive(Clone)]
pub struct ModeAndAddons {
pub indexer: bool,
@@ -167,15 +206,6 @@ pub async fn require_admin_or_devops(
Ok(())
}
pub fn hostname() -> String {
std::env::var("FORCE_HOSTNAME").unwrap_or_else(|_| {
gethostname()
.to_str()
.map(|x| x.to_string())
.unwrap_or_else(|| rd_string(5))
})
}
fn instance_name(hostname: &str) -> String {
hostname
.replace(" ", "")
@@ -190,11 +220,22 @@ pub fn worker_suffix(hostname: &str, rd_string: &str) -> String {
format!("{}-{}", instance_name(hostname), rd_string)
}
pub fn check_if_ag_worker_has_specific_suffix(
suffix_to_check: AgentWorkerSuffix,
suffix: &str,
) -> bool {
suffix
.split('_')
.step_by(1)
.filter_map(|sub_suffix| TryInto::<AgentWorkerSuffix>::try_into(sub_suffix).ok())
.any(|parsed_suffix| parsed_suffix == suffix_to_check)
}
pub fn worker_name_with_suffix(is_agent: bool, worker_group: &str, suffix: &str) -> String {
if is_agent {
format!("ag-{}-{}", worker_group, suffix)
format!("{}-{}-{}", AGENT_WORKER_NAME_PREFIX, worker_group, suffix)
} else {
format!("wk-{}-{}", worker_group, suffix)
format!("{}-{}-{}", WORKER_NAME_PREFIX, worker_group, suffix)
}
}
@@ -747,3 +788,18 @@ impl<F: Future> Future for WarnAfterFuture<F> {
}
}
}
pub fn from_iter_to_header_map<'header_name, 'header_value, T>(headers: T) -> Result<HeaderMap>
where
T: IntoIterator<Item = (&'header_name str, &'header_value str)>,
{
let mut map = HeaderMap::new();
for (name_str, value_str) in headers {
let name = HeaderName::from_str(name_str).map_err(to_anyhow)?;
let value = HeaderValue::from_str(value_str).map_err(to_anyhow)?;
map.insert(name, value);
}
Ok(map)
}
+15 -5
View File
@@ -1,4 +1,5 @@
use anyhow::anyhow;
use axum::http::HeaderMap;
use bytes::Bytes;
use const_format::concatcp;
use itertools::Itertools;
@@ -34,7 +35,7 @@ use crate::{
pub const DEFAULT_CLOUD_TIMEOUT: u64 = 900;
pub const DEFAULT_SELFHOSTED_TIMEOUT: u64 = 604800; // 7 days
pub const AGENT_WORKER_SHELL_TAG_HEADER_NAME: &'static str = "ag-wk-shell-tag";
lazy_static::lazy_static! {
pub static ref WORKER_GROUP: String = std::env::var("WORKER_GROUP").unwrap_or_else(|_| {
#[cfg(not(feature = "enterprise"))]
@@ -158,12 +159,20 @@ impl HttpClient {
pub async fn post<T: Serialize, R: DeserializeOwned>(
&self,
url: &str,
headers: Option<HeaderMap>,
body: &T,
) -> anyhow::Result<R> {
let response = self
let response_builder = self
.0
.post(format!("{}{}", *BASE_INTERNAL_URL, url))
.json(body)
.json(body);
let response_builder = match headers {
Some(headers) => response_builder.headers(headers),
None => response_builder,
};
let response = response_builder
.send()
.await
.map_err(|e| anyhow::anyhow!(e))?;
@@ -307,7 +316,7 @@ pub async fn store_suspended_pull_query(wc: &WorkerConfig) {
}
pub fn make_pull_query(tags: &[String]) -> String {
format_pull_query(format!(
let query = format_pull_query(format!(
"SELECT id
FROM v2_job_queue
WHERE running = false AND tag IN ({}) AND scheduled_for <= now()
@@ -315,7 +324,8 @@ pub fn make_pull_query(tags: &[String]) -> String {
FOR UPDATE SKIP LOCKED
LIMIT 1",
tags.iter().map(|x| format!("'{x}'")).join(", ")
))
));
query
}
pub async fn store_pull_query(wc: &WorkerConfig) {
+14 -7
View File
@@ -391,6 +391,7 @@ pub async fn append_logs(
if let Err(e) = client
.post::<_, String>(
&format!("/api/w/{}/agent_workers/push_logs/{}", workspace.as_ref(), job_id),
None,
&logs.as_ref(),
)
.await {
@@ -980,7 +981,6 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
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
@@ -2123,11 +2123,15 @@ pub struct PulledJob {
pub permissioned_as_folders: Option<Vec<serde_json::Value>>,
}
<<<<<<< Updated upstream
// NOTE:
// Precomputed by the server
// Used to offload work from agent workers to server
#[derive(Serialize, Deserialize)]
=======
#[derive(Debug, Serialize, Deserialize)]
>>>>>>> Stashed changes
pub enum PrecomputedAgentInfo {
Bun { local: String, remote: String },
Python {
@@ -2138,7 +2142,7 @@ pub enum PrecomputedAgentInfo {
requirements: Option<String> },
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct JobAndPerms {
pub job: MiniPulledJob,
pub raw_code: Option<String>,
@@ -2290,16 +2294,20 @@ pub async fn pull(
db: &Pool<Postgres>,
suspend_first: bool,
worker_name: &str,
query_o: Option<(String, String)>,
query_o: Option<&(String, String)>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> windmill_common::error::Result<PulledJobResult> {
loop {
if let Some((query_suspended, query_no_suspend)) = query_o.as_ref() {
if let Some((query_suspended, query_no_suspend)) = query_o {
let njob = {
let job = sqlx::query_as::<_, PulledJob>(query_suspended)
let job = if query_suspended.is_empty() {
None
} else {
sqlx::query_as::<_, PulledJob>(query_suspended)
.bind(worker_name)
.fetch_optional(db)
.await?;
.await?
};
if let Some(job) = job {
PulledJobResult { job: Some(job), suspended: true }
} else {
@@ -2549,7 +2557,6 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
return Ok((None, false));
}
let r = if suspend_first {
// tracing::info!("Pulling job with query: {}", query);
sqlx::query_as::<_, PulledJob>(&query)
+10 -2
View File
@@ -1,3 +1,4 @@
use reqwest::header::HeaderMap;
use uuid::Uuid;
use windmill_common::{agent_workers::QueueInitJob, worker::HttpClient};
use windmill_queue::{JobAndPerms, JobCompleted};
@@ -6,14 +7,20 @@ pub async fn queue_init_job(client: &HttpClient, content: &str) -> anyhow::Resul
client
.post(
"/api/agent_workers/queue_init_job",
None,
&QueueInitJob { content: content.to_string() },
)
.await
.and_then(|x: String| Uuid::parse_str(&x).map_err(|e| anyhow::anyhow!(e)))
}
pub async fn pull_job(client: &HttpClient) -> anyhow::Result<Option<JobAndPerms>> {
client.post("/api/agent_workers/pull_job", &()).await
pub async fn pull_job(
client: &HttpClient,
headers: Option<HeaderMap>,
) -> anyhow::Result<Option<JobAndPerms>> {
client
.post("/api/agent_workers/pull_job", headers, &())
.await
}
pub async fn send_result(client: &HttpClient, jc: JobCompleted) -> anyhow::Result<String> {
@@ -23,6 +30,7 @@ pub async fn send_result(client: &HttpClient, jc: JobCompleted) -> anyhow::Resul
"/api/w/{}/agent_workers/send_result/{}",
jc.job.workspace_id, jc.job.id
),
None,
&jc,
)
.await
+1
View File
@@ -494,6 +494,7 @@ pub async fn update_worker_ping_for_failed_init_script(
if let Err(e) = client
.post::<_, ()>(
UPDATE_PING_URL,
None,
&Ping {
last_job_executed: Some(last_job_id),
last_job_workspace_id: None,
@@ -227,6 +227,7 @@ pub async fn handle_child(
if let Err(err) = client
.post::<_, ()>(
&format!("/api/agent_workers/set_job_cancelled/{}", job_id),
None,
&JobCancelled {
canceled_by: "timeout".to_string(),
reason: format!("duration > {}", timeout_duration.as_secs()),
+346 -41
View File
@@ -9,6 +9,7 @@ use std::{
atomic::{AtomicBool, AtomicU16, Ordering},
Arc,
},
time::Duration,
};
use tracing::{field, Instrument};
#[cfg(not(feature = "otel"))]
@@ -20,8 +21,10 @@ use windmill_common::{
add_time,
error::{self, Error},
jobs::JobKind,
utils::WarnAfterExt,
worker::{to_raw_value, Connection, WORKER_GROUP},
utils::{from_iter_to_header_map, WarnAfterExt},
worker::{
make_pull_query, to_raw_value, Connection, AGENT_WORKER_SHELL_TAG_HEADER_NAME, WORKER_GROUP,
},
KillpillSender, DB,
};
@@ -29,23 +32,24 @@ use windmill_common::{
use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
use windmill_queue::{
append_logs, get_queued_job, CanceledBy, JobCompleted, MiniPulledJob,
append_logs, get_queued_job, pull, CanceledBy, JobAndPerms, JobCompleted, MiniPulledJob,
WrappedError,
};
use serde_json::{json, value::RawValue};
use serde_json::{json, value::RawValue, Value};
use tokio::task::JoinHandle;
use tokio::{fs::DirBuilder, task::JoinHandle, time::Instant};
use windmill_queue::{add_completed_job, add_completed_job_error};
use crate::{
bash_executor::ANSI_ESCAPE_RE,
common::{error_to_value, read_result, save_in_cache},
common::{error_to_value, read_result, save_in_cache, OccupancyMetrics},
handle_queued_job,
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,
AuthedClient, JobCompletedReceiver, JobCompletedSender, NextJob, SameWorkerSender, SendResult,
UpdateFlow, INIT_SCRIPT_TAG, KEEP_JOB_DIR, SAME_WORKER_REQUIREMENTS, SLEEP_QUEUE,
};
async fn process_jc(
@@ -54,7 +58,7 @@ async fn process_jc(
base_internal_url: &str,
db: &DB,
worker_dir: &str,
same_worker_tx: &SameWorkerSender,
same_worker_tx: Option<&SameWorkerSender>,
job_completed_sender: &JobCompletedSender,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) {
@@ -105,7 +109,7 @@ async fn process_jc(
&base_internal_url,
&db,
&worker_dir,
&same_worker_tx,
same_worker_tx,
&worker_name,
job_completed_sender.clone(),
#[cfg(feature = "benchmark")]
@@ -119,6 +123,291 @@ async fn process_jc(
}
}
enum JobCompletedRx {
JobCompleted(SendResult),
Killpill,
}
const NAP_TIME_DURATION: u64 = 15;
const RESET: u64 = 2 * 60;
pub fn start_interactive_worker_shell(
conn: Connection,
hostname: String,
worker_name: String,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
base_internal_url: String,
worker_dir: String,
) {
tokio::spawn(async move {
let mut occupancy_metrics = OccupancyMetrics::new(Instant::now());
let mut has_been_killed = false;
let (job_completed_tx, job_completed_rx) = JobCompletedSender::new(&conn, 10);
if let Some(db) = conn.as_sql() {
let db = db.clone();
let job_completed_tx = job_completed_tx.clone();
let worker_name = worker_name.clone();
let base_internal_url = base_internal_url.clone();
let worker_dir = worker_dir.clone();
tokio::spawn(async move {
let job_completed_rx = job_completed_rx.unwrap();
let JobCompletedReceiver { bounded_rx, mut killpill_rx, unbounded_rx } =
job_completed_rx;
while let Some(sr) = {
if has_been_killed {
unbounded_rx
.try_recv()
.ok()
.map(JobCompletedRx::JobCompleted)
.or_else(|| {
bounded_rx.try_recv().ok().map(JobCompletedRx::JobCompleted)
})
} else {
tokio::select! {
biased;
result = unbounded_rx.recv_async() => {
result.ok().map(JobCompletedRx::JobCompleted)
}
result = bounded_rx.recv_async() => {
result.ok().map(JobCompletedRx::JobCompleted)
}
_ = killpill_rx.recv() => {
Some(JobCompletedRx::Killpill)
}
}
}
} {
#[cfg(feature = "benchmark")]
let mut bench = BenchmarkIter::new();
match sr {
JobCompletedRx::JobCompleted(SendResult::JobCompleted(jc)) => {
process_jc(
jc,
&worker_name,
&base_internal_url,
&db,
&worker_dir,
None,
&job_completed_tx,
#[cfg(feature = "benchmark")]
&mut bench,
)
.await;
}
JobCompletedRx::Killpill => {
has_been_killed = true;
}
_ => {}
}
}
});
}
let mut killpill_rx2 = killpill_rx.resubscribe();
let mut last_executed_job: Option<Instant> =
Instant::now().checked_sub(Duration::from_millis(2500));
#[cfg(feature = "benchmark")]
let mut bench = BenchmarkIter::new();
loop {
#[cfg(feature = "enterprise")]
{
if let Ok(_) = killpill_rx.try_recv() {
tracing::info!(worker = %worker_name, hostname = %hostname, "killpill received on worker waiting for valid key");
break;
}
}
if let Ok(_) = killpill_rx.try_recv() {
break;
} else {
let pulled_job = match &conn {
Connection::Sql(db) => {
let query = ("".to_string(), make_pull_query(&[hostname.to_owned()]));
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
let job = pull(
&db,
false,
&worker_name,
Some(&query),
#[cfg(feature = "benchmark")]
&mut bench,
)
.await;
job.map(|x| x.job.map(NextJob::Sql))
}
Connection::Http(client) => {
let header_map = from_iter_to_header_map([(
AGENT_WORKER_SHELL_TAG_HEADER_NAME,
hostname.as_str(),
)])
.ok();
if header_map.is_none() {
tracing::error!(
"An error has occured header map is none, it should not happen"
);
}
crate::agent_workers::pull_job(&client, header_map)
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y)))
}
};
match pulled_job {
Ok(Some(job)) => {
tracing::debug!(worker = %worker_name, hostname = %hostname, "started handling of job {}", job.id);
let job_dir = format!("{worker_dir}/{}", job.id);
DirBuilder::new()
.recursive(true)
.create(&job_dir)
.await
.expect("could not create job dir");
let target = &format!("{job_dir}/shared");
DirBuilder::new()
.recursive(true)
.create(target)
.await
.expect("could not create shared dir");
let JobAndPerms {
job,
raw_code,
raw_lock,
raw_flow,
parent_runnable_path,
token,
precomputed_agent_info: precomputed_bundle,
} = match (job, &conn) {
(NextJob::Sql(job), Connection::Sql(db)) => {
job.get_job_and_perms(db).await
}
(NextJob::Sql(_), Connection::Http(_)) => {
panic!("sql job on http connection")
}
(NextJob::Http(job), _) => job,
};
// let token = create_token(&db, &job, job_perms).await;
let authed_client = AuthedClient {
base_internal_url: base_internal_url.to_string(),
token,
workspace: job.workspace_id.to_string(),
force_client: None,
};
let arc_job = Arc::new(job);
add_time!(bench, "handle_queued_job START");
let span = tracing::span!(tracing::Level::INFO, "job",
job_id = %arc_job.id, workspace_id = %arc_job.workspace_id, worker = %worker_name, hostname = %hostname, tag = %arc_job.tag,
language = "bash", otel.name = "job");
windmill_common::otel_ee::set_span_parent(&span, &arc_job.id);
// span.context().span().add_event_with_timestamp("job created".to_string(), arc_job.created_at.into(), vec![]);
match handle_queued_job(
arc_job.clone(),
raw_code,
raw_lock,
raw_flow,
parent_runnable_path,
&conn,
&authed_client,
&hostname,
&worker_name,
&worker_dir,
&job_dir,
None,
&base_internal_url,
job_completed_tx.clone(),
&mut occupancy_metrics,
&mut killpill_rx2,
precomputed_bundle,
#[cfg(feature = "benchmark")]
&mut bench,
)
.instrument(span)
.await
{
Err(err) => match &conn {
Connection::Sql(db) => {
let _ = handle_non_flow_job_error(
db,
arc_job.as_ref(),
0,
None,
error_to_value(err),
&worker_name,
)
.await;
}
Connection::Http(_) => {
job_completed_tx
.send_job(
JobCompleted {
preprocessed_args: None,
job: arc_job.clone(),
result: Arc::new(
windmill_common::worker::to_raw_value(
&error_to_value(err),
),
),
result_columns: None,
mem_peak: 0,
canceled_by: None,
success: false,
cached_res_path: None,
token: authed_client.token.clone(),
duration: None,
},
false,
)
.await
.expect("send job completed");
}
},
_ => {}
}
if !KEEP_JOB_DIR.load(Ordering::Relaxed) && !arc_job.same_worker {
let _ = tokio::fs::remove_dir_all(job_dir).await;
}
last_executed_job = Some(Instant::now());
}
Ok(None) => {
let now = Instant::now();
match last_executed_job {
Some(last) if now.duration_since(last).as_secs() > RESET => {
tokio::time::sleep(Duration::from_secs(NAP_TIME_DURATION)).await;
}
_ => {
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE)).await;
}
}
}
Err(err) => {
tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err);
}
};
}
}
});
}
pub fn start_background_processor(
job_completed_rx: JobCompletedReceiver,
job_completed_sender: JobCompletedSender,
@@ -140,10 +429,6 @@ pub fn start_background_processor(
#[cfg(feature = "benchmark")]
let mut infos = BenchmarkInfo::new();
enum JobCompletedRx {
JobCompleted(SendResult),
Killpill,
}
//if we have been killed, we want to drain the queue of jobs
while let Some(sr) = {
if has_been_killed && same_worker_queue_size.load(Ordering::SeqCst) == 0 {
@@ -186,7 +471,7 @@ pub fn start_background_processor(
&base_internal_url,
&db,
&worker_dir,
&same_worker_tx,
Some(&same_worker_tx),
&job_completed_sender,
#[cfg(feature = "benchmark")]
&mut bench,
@@ -241,7 +526,7 @@ pub fn start_background_processor(
success,
Arc::new(result),
true,
same_worker_tx.clone(),
&same_worker_tx,
&worker_dir,
stop_early_override,
&worker_name,
@@ -273,16 +558,18 @@ pub fn start_background_processor(
})
}
async fn send_job_completed(
job_completed_tx: JobCompletedSender,
jc: JobCompleted,
) {
job_completed_tx
async fn send_job_completed(job_completed_tx: JobCompletedSender, jc: JobCompleted) {
let result = job_completed_tx
.send_job(jc, true)
.with_context(windmill_common::otel_ee::otel_ctx())
.await
.expect("send job completed")
.await;
match result {
Ok(()) => tracing::debug!("send job completed"),
Err(err) => {
tracing::error!("An error occurend while sending job completed: {:#?}", err)
}
}
}
pub async fn process_result(
@@ -301,7 +588,6 @@ pub async fn process_result(
) -> error::Result<bool> {
match result {
Ok(result) => {
send_job_completed(
job_completed_tx,
JobCompleted {
@@ -391,7 +677,7 @@ pub async fn handle_receive_completed_job(
base_internal_url: &str,
db: &DB,
worker_dir: &str,
same_worker_tx: &SameWorkerSender,
same_worker_tx: Option<&SameWorkerSender>,
worker_name: &str,
job_completed_tx: JobCompletedSender,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
@@ -459,7 +745,7 @@ pub async fn process_completed_job(
client: &AuthedClient,
db: &DB,
worker_dir: &str,
same_worker_tx: SameWorkerSender,
same_worker_tx: Option<&SameWorkerSender>,
worker_name: &str,
job_completed_tx: JobCompletedSender,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
@@ -535,7 +821,7 @@ pub async fn process_completed_job(
true,
result,
false,
same_worker_tx.clone(),
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
&worker_dir,
None,
worker_name,
@@ -575,7 +861,7 @@ pub async fn process_completed_job(
false,
Arc::new(serde_json::value::to_raw_value(&result).unwrap()),
false,
same_worker_tx,
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
&worker_dir,
None,
worker_name,
@@ -592,6 +878,34 @@ pub async fn process_completed_job(
return Ok(None);
}
async fn handle_non_flow_job_error(
db: &DB,
job: &MiniPulledJob,
mem_peak: i32,
canceled_by: Option<CanceledBy>,
err: Value,
worker_name: &str,
) -> Result<WrappedError, Error> {
append_logs(
&job.id,
&job.workspace_id,
format!("Unexpected error during job execution:\n{err:#?}"),
&db.into(),
)
.await;
add_completed_job_error(
db,
job,
mem_peak,
canceled_by,
err,
worker_name,
false,
None,
)
.await
}
#[tracing::instrument(name = "job_error", level = "info", skip_all, fields(job_id = %job.id))]
pub async fn handle_job_error(
db: &DB,
@@ -601,7 +915,7 @@ pub async fn handle_job_error(
canceled_by: Option<CanceledBy>,
err: Error,
unrecoverable: bool,
same_worker_tx: SameWorkerSender,
same_worker_tx: Option<&SameWorkerSender>,
worker_dir: &str,
worker_name: &str,
job_completed_tx: JobCompletedSender,
@@ -610,22 +924,13 @@ pub async fn handle_job_error(
let err = error_to_value(err);
let update_job_future = || async {
append_logs(
&job.id,
&job.workspace_id,
format!("Unexpected error during job execution:\n{err:#?}"),
&db.into(),
)
.await;
add_completed_job_error(
handle_non_flow_job_error(
db,
job,
mem_peak,
canceled_by.clone(),
err.clone(),
worker_name,
false,
None,
)
.await
};
@@ -654,7 +959,7 @@ pub async fn handle_job_error(
false,
Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()),
unrecoverable,
same_worker_tx,
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(),
worker_dir,
None,
worker_name,
+65 -33
View File
@@ -17,7 +17,9 @@ use windmill_common::{
cache::{future::FutureCachedExt, ScriptData, ScriptMetadata},
schema::{should_validate_schema, SchemaValidator},
scripts::PREVIEW_IS_TAR_CODEBASE_HASH,
utils::WarnAfterExt,
utils::{
check_if_ag_worker_has_specific_suffix, AgentWorkerSuffix, WarnAfterExt, WORKER_NAME_PREFIX,
},
worker::{
write_file, Connection, HttpClient, MAX_TIMEOUT, ROOT_CACHE_DIR, ROOT_CACHE_NOMOUNT_DIR,
TMP_DIR,
@@ -112,7 +114,9 @@ use crate::{
job_logger::NO_LOGS_AT_ALL,
js_eval::{eval_fetch_timeout, transpile_ts},
pg_executor::do_postgresql,
result_processor::{process_result, start_background_processor},
result_processor::{
process_result, start_background_processor, start_interactive_worker_shell,
},
schema::schema_validator_from_main_arg_sig,
worker_flow::handle_flow,
worker_lockfiles::{
@@ -244,6 +248,8 @@ lazy_static::lazy_static! {
const DOTNET_DEFAULT_PATH: &str = "C:\\Program Files\\dotnet\\dotnet.exe";
#[cfg(unix)]
const DOTNET_DEFAULT_PATH: &str = "/usr/bin/dotnet";
pub const SAME_WORKER_REQUIREMENTS: &'static str =
"SameWorkerSender is required because this job may be part of a flow";
lazy_static::lazy_static! {
@@ -388,6 +394,31 @@ lazy_static::lazy_static! {
pub static ref WIN_ENVS: Envs = vec![];
}
#[derive(Debug)]
pub enum NextJob {
Sql(PulledJob),
Http(JobAndPerms),
}
impl NextJob {
pub fn job(self) -> MiniPulledJob {
match self {
NextJob::Sql(job) => job.job,
NextJob::Http(job) => job.job,
}
}
}
impl std::ops::Deref for NextJob {
type Target = MiniPulledJob;
fn deref(&self) -> &Self::Target {
match self {
NextJob::Sql(job) => &job.job,
NextJob::Http(job) => &job.job,
}
}
}
//only matter if CLOUD_HOSTED
pub const MAX_RESULT_SIZE: usize = 1024 * 1024 * 2; // 2MB
@@ -1124,6 +1155,29 @@ pub async fn run_worker(
let job_completed_processor_is_done =
Arc::new(AtomicBool::new(matches!(conn, Connection::Http(_))));
let is_agent_worker_allowed_to_enable_live_shell = || {
worker_name
.split("-")
.last()
.map(|suffix| {
check_if_ag_worker_has_specific_suffix(AgentWorkerSuffix::EnableLiveShell, suffix)
})
.unwrap_or(false)
};
if worker_name.starts_with(WORKER_NAME_PREFIX) || is_agent_worker_allowed_to_enable_live_shell() {
tracing::debug!("Worker {} now listening for host tag", &worker_name);
start_interactive_worker_shell(
conn.clone(),
hostname.to_owned(),
worker_name.clone(),
killpill_rx.resubscribe(),
base_internal_url.to_owned(),
worker_dir.clone(),
);
}
let send_result = match (conn, job_completed_rx) {
(Connection::Sql(db), Some(job_completed_receiver)) => Some(start_background_processor(
job_completed_receiver,
@@ -1250,7 +1304,7 @@ pub async fn run_worker(
if !valid_key {
tracing::error!(
worker = %worker_name, hostname = %hostname,
"Invalid license key, workers require a valid license key, sleeping for 30s waiting for valid key to be set"
"Invalid license key, workers require a valid license key, sleeping for 10s waiting for valid key to be set"
);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
@@ -1320,29 +1374,6 @@ pub async fn run_worker(
} else {
tracing::info!("benchmark not finished, still pulling jobs {}", infos.iters);
}
enum NextJob {
Sql(PulledJob),
Http(JobAndPerms),
}
impl NextJob {
pub fn job(self) -> MiniPulledJob {
match self {
NextJob::Sql(job) => job.job,
NextJob::Http(job) => job.job,
}
}
}
impl std::ops::Deref for NextJob {
type Target = MiniPulledJob;
fn deref(&self) -> &Self::Target {
match self {
NextJob::Sql(job) => &job.job,
NextJob::Http(job) => &job.job,
}
}
}
let next_job = {
// println!("2: {:?}", instant.elapsed());
@@ -1383,6 +1414,7 @@ pub async fn run_worker(
"/api/agent_workers/same_worker_job/{}",
same_worker_job.job_id
),
None,
&same_worker_job,
)
.await
@@ -1488,7 +1520,7 @@ pub async fn run_worker(
}
job.map(|x| x.job.map(NextJob::Sql))
}
Connection::Http(client) => crate::agent_workers::pull_job(&client)
Connection::Http(client) => crate::agent_workers::pull_job(&client, None)
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y))),
@@ -1536,6 +1568,7 @@ pub async fn run_worker(
}
}
}
if matches!(job.kind, JobKind::Noop) {
add_time!(bench, "send job completed START");
job_completed_tx
@@ -1733,7 +1766,7 @@ pub async fn run_worker(
&worker_name,
&worker_dir,
&job_dir,
same_worker_tx.clone(),
Some(same_worker_tx.clone()),
base_internal_url,
job_completed_tx.clone(),
&mut occupancy_metrics,
@@ -1756,7 +1789,7 @@ pub async fn run_worker(
None,
err,
false,
same_worker_tx.clone(),
Some(&same_worker_tx),
&worker_dir,
&worker_name,
job_completed_tx.clone(),
@@ -2006,7 +2039,7 @@ pub struct PreviousResult<'a> {
pub previous_result: Option<&'a RawValue>,
}
async fn handle_queued_job(
pub async fn handle_queued_job(
job: Arc<MiniPulledJob>,
raw_code: Option<String>,
raw_lock: Option<String>,
@@ -2018,7 +2051,7 @@ async fn handle_queued_job(
worker_name: &str,
worker_dir: &str,
job_dir: &str,
same_worker_tx: SameWorkerSender,
same_worker_tx: Option<SameWorkerSender>,
base_internal_url: &str,
job_completed_tx: JobCompletedSender,
occupancy_metrics: &mut OccupancyMetrics,
@@ -2167,7 +2200,7 @@ async fn handle_queued_job(
db,
&client,
None,
same_worker_tx,
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS),
worker_dir,
job_completed_tx.clone(),
worker_name,
@@ -2336,7 +2369,6 @@ async fn handle_queued_job(
{
return Ok(false);
}
process_result(
job,
result.map(|x| Arc::new(x)),
+8 -8
View File
@@ -80,7 +80,7 @@ pub async fn update_flow_status_after_job_completion(
success: bool,
result: Arc<Box<RawValue>>,
unrecoverable: bool,
same_worker_tx: SameWorkerSender,
same_worker_tx: &SameWorkerSender,
worker_dir: &str,
stop_early_override: Option<bool>,
worker_name: &str,
@@ -109,7 +109,7 @@ pub async fn update_flow_status_after_job_completion(
rec.success,
rec.result,
unrecoverable,
same_worker_tx.clone(),
same_worker_tx,
worker_dir,
rec.stop_early_override,
rec.skip_error_handler,
@@ -134,7 +134,7 @@ pub async fn update_flow_status_after_job_completion(
error: json!(e.to_string()),
}))),
true,
same_worker_tx.clone(),
same_worker_tx,
worker_dir,
rec.stop_early_override,
rec.skip_error_handler,
@@ -226,7 +226,7 @@ pub async fn update_flow_status_after_job_completion_internal(
mut success: bool,
result: Arc<Box<RawValue>>,
unrecoverable: bool,
same_worker_tx: SameWorkerSender,
same_worker_tx: &SameWorkerSender,
worker_dir: &str,
stop_early_override: Option<bool>,
skip_error_handler: bool,
@@ -1289,7 +1289,7 @@ pub async fn update_flow_status_after_job_completion_internal(
db,
client,
Some(nresult.clone()),
same_worker_tx.clone(),
same_worker_tx,
worker_dir,
job_completed_tx,
worker_name,
@@ -1586,7 +1586,7 @@ pub async fn handle_flow(
db: &sqlx::Pool<sqlx::Postgres>,
client: &AuthedClient,
last_result: Option<Arc<Box<RawValue>>>,
same_worker_tx: SameWorkerSender,
same_worker_tx: &SameWorkerSender,
worker_dir: &str,
job_completed_tx: JobCompletedSender,
worker_name: &str,
@@ -1643,7 +1643,7 @@ pub async fn handle_flow(
db,
client,
last_result.clone(),
same_worker_tx.clone(),
same_worker_tx,
worker_dir,
worker_name,
)
@@ -1736,7 +1736,7 @@ async fn push_next_flow_job(
db: &sqlx::Pool<sqlx::Postgres>,
client: &AuthedClient,
last_job_result: Option<Arc<Box<RawValue>>>,
same_worker_tx: SameWorkerSender,
same_worker_tx: &SameWorkerSender,
worker_dir: &str,
worker_name: &str,
) -> error::Result<PushNextFlowJob> {
@@ -125,6 +125,7 @@ async fn update_worker_ping_full_inner(
client
.post::<_, ()>(
UPDATE_PING_URL,
None,
&Ping {
last_job_executed: None,
last_job_workspace_id: None,
@@ -190,6 +191,7 @@ pub async fn insert_ping(
client
.post::<_, ()>(
UPDATE_PING_URL,
None,
&Ping {
last_job_executed: None,
last_job_workspace_id: None,
@@ -249,6 +251,7 @@ pub async fn update_worker_ping_from_job(
client
.post::<Ping, ()>(
UPDATE_PING_URL,
None,
&Ping {
last_job_executed: Some(job_id.clone()),
last_job_workspace_id: Some(w_id.to_string()),
@@ -287,6 +290,7 @@ pub async fn ping_job_status(
client
.post(
&format!("/api/agent_workers/ping_job_status/{}", job_id),
None,
&PingJobStatus { mem_peak, current_mem },
)
.await
+39 -6
View File
@@ -24,6 +24,7 @@
"@redocly/json-to-json-schema": "^0.0.1",
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
"@windmill-labs/svelte-dnd-action": "^0.9.48",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^0.1.15",
"ag-charts-community": "^9.0.1",
"ag-charts-enterprise": "^9.0.1",
@@ -79,6 +80,8 @@
"windmill-parser-wasm-ts": "^1.486.1",
"windmill-parser-wasm-yaml": "^1.429.0",
"windmill-sql-datatype-parser-wasm": "^1.318.0",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
"y-websocket": "^1.5.4",
"yaml": "^2.3.4",
@@ -3795,6 +3798,22 @@
"svelte": ">=3.23.0 || ^5.0.0-next.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
},
"node_modules/@xyflow/svelte": {
"version": "0.1.39",
"resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.39.tgz",
@@ -3953,7 +3972,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -5485,8 +5503,7 @@
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/encoding-down": {
"version": "6.3.0",
@@ -7179,7 +7196,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -11163,7 +11179,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -11192,7 +11207,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -13029,6 +13043,25 @@
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-readline": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/xterm-readline/-/xterm-readline-1.1.2.tgz",
"integrity": "sha512-1+W2nVuQvCYz9OUYwFBiolrSQUui51aDDyacKXt4PuxeBHqzvabQEJ2kwdBDzsmOjz5BwlDTAjJmYpH2OGqLFA==",
"license": "MIT",
"dependencies": {
"string-width": "^4"
},
"peerDependencies": {
"@xterm/xterm": "^5.5.0"
}
},
"node_modules/y-leveldb": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz",
+4 -1
View File
@@ -81,18 +81,19 @@
"dependencies": {
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~16.1.1",
"@codingame/monaco-vscode-editor-api": "~16.1.1",
"@codingame/monaco-vscode-standalone-css-language-features": "~16.1.1",
"@codingame/monaco-vscode-standalone-html-language-features": "^16.1.1",
"@codingame/monaco-vscode-standalone-json-language-features": "~16.1.1",
"@codingame/monaco-vscode-standalone-languages": "~16.1.1",
"@codingame/monaco-vscode-standalone-typescript-language-features": "~16.1.1",
"@codingame/monaco-vscode-editor-api": "~16.1.1",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@popperjs/core": "^2.11.6",
"@redocly/json-to-json-schema": "^0.0.1",
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
"@windmill-labs/svelte-dnd-action": "^0.9.48",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^0.1.15",
"ag-charts-community": "^9.0.1",
"ag-charts-enterprise": "^9.0.1",
@@ -148,6 +149,8 @@
"windmill-parser-wasm-ts": "^1.486.1",
"windmill-parser-wasm-yaml": "^1.429.0",
"windmill-sql-datatype-parser-wasm": "^1.318.0",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
"y-websocket": "^1.5.4",
"yaml": "^2.3.4",
+1 -1
View File
@@ -25,7 +25,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
<body data-sveltekit-preload-code="viewport" class="outline-none overflow-x-hidden focus:outline-none">
<div style="display: contents">
<div id="svelte-global-loader">
<div id="mainbg" style="position: absolute; bottom: 0; left: 0; width: 100%; height: 100vh">
@@ -0,0 +1,36 @@
<script lang="ts">
import { AgentKnownSuffix, concatAgentSuffix, removeAgentSuffix } from '$lib/utils'
import Section from './Section.svelte'
import Toggle from './Toggle.svelte'
type Props = {
suffix: string | undefined
}
let enable_ssh_repl_like = $state(false)
let { suffix = $bindable() }: Props = $props()
</script>
<Section label="Extra behavior">
<Toggle
textClass="font-normal text-sm"
color="nord"
size="xs"
checked={enable_ssh_repl_like}
on:change={() => {
enable_ssh_repl_like = !enable_ssh_repl_like
if (enable_ssh_repl_like) {
suffix = concatAgentSuffix(suffix, AgentKnownSuffix.ENABLE_LIVE_SHELL)
return
}
suffix = removeAgentSuffix(suffix, AgentKnownSuffix.ENABLE_LIVE_SHELL)
}}
options={{
right: "Enable live shell on worker's host machine",
rightTooltip:
"Allow you to open a live shell and interact with the agent worker's host machine"
}}
class="py-1"
/>
</Section>
@@ -18,7 +18,7 @@
import { Alert } from './common'
import { dbDeleteTableActionWithPreviewScript, dbTableOpsWithPreviewScripts } from './dbOps'
import { makeCreateTableQuery } from './apps/components/display/dbtable/queries/createTable'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { runScriptAndPollResult } from './jobs/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
@@ -208,7 +208,7 @@
previewSql: (values) =>
makeCreateTableQuery(values, resourceType, selectedSchemaKey),
async onConfirm(values) {
await runPreviewJobAndPollResult({
await runScriptAndPollResult({
workspace: $workspaceStore,
requestBody: {
args: { database: '$res:' + resourcePath },
@@ -1,10 +1,8 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
export let link: string | undefined = undefined
</script>
<div class={twMerge('text-xs text-tertiary font-normal opacity-80', $$props.class)}>
<div class="text-xs text-tertiary font-normal">
<slot />
{#if link}
<a href={link} target="_blank">Learn more</a>
+10 -4
View File
@@ -47,11 +47,10 @@
parseTypescriptDeps
} from '$lib/relative_imports'
import Tooltip from './Tooltip.svelte'
import type { TriggerContext } from './triggers'
import type { ScheduleTrigger, 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')
@@ -494,13 +493,20 @@
const testStepStore = writable<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
defaultValues: writable(undefined),
captureOn: writable(undefined),
showCaptureHint: writable(undefined)
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
@@ -35,7 +35,6 @@
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
@@ -270,6 +269,15 @@
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
@@ -26,7 +26,7 @@
}
</script>
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1 items-center')}>
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1')}>
{#if customUi?.disableDownload !== true}
<a
download="{filename ?? 'result'}.json"
+6 -13
View File
@@ -25,8 +25,6 @@
export let fixedHeight = true
export let hidePopup = false
export let open = false
export let customWidth: number | undefined = undefined
export let customMenu = false
const {
elements: { menu, item, trigger },
@@ -108,16 +106,11 @@
</button>
{#if open && !hidePopup}
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
{#if customMenu}
<slot name="menu" />
{:else}
<div
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
style={customWidth ? `width: ${customWidth}px` : ''}
>
<DropdownV2Inner items={computeItems} meltItem={item} />
</div>
{/if}
<div use:melt={$menu} data-menu class="z-[6000]">
<div
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
>
<DropdownV2Inner items={computeItems} meltItem={item} />
</div>
</div>
{/if}
@@ -4,15 +4,10 @@
import { twMerge } from 'tailwind-merge'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import type { Item } from '$lib/utils'
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
export let meltItem: MenubarMenuElements['item']
interface Props {
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
meltItem: MenubarMenuElements['item']
}
let { items = [], meltItem }: Props = $props()
let computedItems: Item[] | undefined = $state(undefined)
let computedItems: Item[] | undefined = undefined
async function computeItems() {
if (typeof items === 'function') {
computedItems = ((await items()) ?? []).filter((item) => !item.hide)
@@ -43,12 +38,9 @@
item={meltItem}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} />
<svelte:component this={item.icon} size={14} color={item.iconColor} />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
</p>
{@render item.extra?.()}
{item.displayName}
</MenuItem>
{/each}
</div>
+161 -164
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {
FlowService,
ScheduleService,
type Flow,
type FlowModule,
DraftService,
@@ -18,12 +19,12 @@
enterpriseLicense,
tutorialsToDo,
userStore,
workspaceStore,
usedTriggerKinds
workspaceStore
} from '$lib/stores'
import {
cleanValueProperties,
encodeState,
formatCron,
generateRandomString,
orderedJsonStringify,
replaceFalseWithUndefined,
@@ -75,14 +76,6 @@
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
export let initialPath: string = ''
export let pathStoreInit: string | undefined = undefined
@@ -92,16 +85,18 @@
export let loading = false
export let flowStore: Writable<OpenFlow>
export let flowStateStore: Writable<FlowState>
export let savedFlow: FlowWithDraftAndDraftTriggers | undefined = undefined
export let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = undefined
export let diffDrawer: DiffDrawer | undefined = undefined
export let customUi: FlowBuilderWhitelabelCustomUi = {}
export let disableAi: boolean = false
export let disabledFlowInputs = false
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined // used to set the primary schedule in the legacy primaryScheduleStore
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
export let version: number | undefined = undefined
export let setSavedraftCb: ((cb: () => void) => void) | undefined = undefined
export let draftTriggersFromUrl: Trigger[] | undefined = undefined
export let selectedTriggerIndexFromUrl: number | undefined = undefined
let initialPathStore = writable(initialPath)
$: initialPathStore.set(initialPath)
@@ -121,26 +116,12 @@
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
// Draft triggers confirmation modal
let draftTriggersModalOpen = false
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
$: setContext('customUi', customUi)
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedFlow,
modifiedValue: {
...$flowStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
modifiedValue: $flowStore
}
}
let onLatest = true
@@ -167,25 +148,42 @@
const dispatch = createEventDispatcher()
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
const triggersCount = writable<TriggersCount | undefined>(undefined)
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const simplifiedPoll = writable(false)
// used to set the primary schedule in the legacy primaryScheduleStore
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
export function setSelectedTriggerIndex(index: number | undefined) {
triggersState.selectedTriggerIndex = index
async function createSchedule(path: string) {
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: true,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
}
} else {
sendUserToast('The primary schedule could not be created: no schedule data', true)
}
}
let loadingSave = false
@@ -197,12 +195,7 @@
}
if (savedFlow) {
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({
...$flowStore,
path: $pathStore,
draft_triggers: currentDraftTriggers
})
const current = cleanValueProperties({ ...$flowStore, path: $pathStore })
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
@@ -266,7 +259,7 @@
value: {
...flow,
path: $pathStore,
draft_triggers: triggersState.getDraftTriggersSnapshot()
primary_schedule: $primaryScheduleStore
}
}
})
@@ -277,14 +270,15 @@
...structuredClone($flowStore),
path: $pathStore,
draft_only: true
}
}
: savedFlow),
draft: {
...structuredClone($flowStore),
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
path: $pathStore
}
} as FlowWithDraftAndDraftTriggers
} as Flow & {
draft?: Flow
}
let savedAtNewPath = false
if (newFlow) {
@@ -357,19 +351,7 @@
deployedBy = flow.edited_by
}
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await saveFlow(deploymentMsg, triggersToDeploy)
}
return
}
}
async function saveFlow(deploymentMsg?: string): Promise<void> {
loadingSave = true
try {
const flow = cleanInputs($flowStore)
@@ -408,15 +390,8 @@
},
runnableKind: 'flow'
})
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
)
if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule($pathStore)
}
} else {
try {
@@ -425,14 +400,51 @@
console.error('error interacting with local storage', e)
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
initialPath
)
const scheduleExists = await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: initialPath
})
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path: initialPath
})
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: { enabled }
})
}
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: $pathStore
})
}
} else if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule(initialPath)
}
await FlowService.updateFlow({
@@ -453,15 +465,10 @@
}
})
}
const { draft_triggers: _, ...newSavedFlow } = $flowStore as OpenFlow & {
draft_triggers: Trigger[]
}
savedFlow = {
...structuredClone(newSavedFlow),
...structuredClone($flowStore),
path: $pathStore
} as Flow
triggersState.setTriggers([])
loadingSave = false
dispatch('deploy', $pathStore)
} catch (err) {
@@ -489,8 +496,7 @@
flow: $flowStore,
path: $pathStore,
selectedId: $selectedIdStore,
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot()
primarySchedule: $primaryScheduleStore
})
)
} catch (err) {
@@ -500,6 +506,16 @@
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectedTriggerStore = writable<
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
>('webhooks')
export function getSelectedId() {
return $selectedIdStore
@@ -525,6 +541,20 @@
selectedIdStore.set(selectedId)
}
function selectTrigger(
selectedTrigger:
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
) {
selectedTriggerStore.set(selectedTrigger)
}
let insertButtonOpen = writable<boolean>(false)
setContext<FlowEditorContext>('FlowEditorContext', {
@@ -547,42 +577,29 @@
flowInputEditorState: flowInputEditorStateStore
})
// Add triggers context store
const triggersState = new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
saveSessionDraft
)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
showCaptureHint,
triggersState
defaultValues: writable(undefined),
captureOn,
showCaptureHint
})
export async function loadTriggers() {
async function loadTriggers() {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path: initialPath
})
// Initialize triggers using utility function
await triggersState.fetchTriggers(
triggersCount,
$workspaceStore,
initialPath,
true,
$primaryScheduleStore,
$userStore
)
if (savedFlow && savedFlow.draft) {
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
}
}
@@ -854,6 +871,16 @@
}
}
if (module.type === 'trigger') {
$primaryScheduleStore = {
summary: 'Scheduled poll of flow',
args: {},
cron: '0 */15 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
}
const flowModule: FlowModule & {
value: RawScript | PathScript
} = {
@@ -863,7 +890,7 @@
? {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
}
: undefined,
value: {
input_transforms: {},
@@ -947,7 +974,7 @@
pastModule?.value.type === 'rawscript' || pastModule?.value.type === 'script'
? (pastModule as FlowModule & {
value: RawScript | PathScript
})
})
: undefined,
isFirstInLoop,
abortController
@@ -1067,8 +1094,8 @@
? isFirstInLoop
? 'flow_input.iter.value'
: pastModule
? 'results.' + pastModule.id
: 'flow_input.' + snakeKey
? 'results.' + pastModule.id
: 'flow_input.' + snakeKey
: 'flow_input.' + snakeKey
}
$shouldUpdatePropertyType[key] = 'javascript'
@@ -1201,7 +1228,7 @@
},
disabled: newFlow
}
]
]
: []),
...(customUi?.topBar?.history != false
? [
@@ -1215,23 +1242,11 @@
icon: FileJson,
action: () => yamlEditorDrawer?.openDrawer()
}
]
]
: [])
]
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved flow to remove the draft trigger that is deployed
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedFlow.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
let flowPreviewButtons: FlowPreviewButtons
</script>
@@ -1248,16 +1263,6 @@
currentValue={$flowStore}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
isFlow={true}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#key renderCount}
{#if !$userStore?.operator}
<FlowCopilotDrawer {getHubCompletions} {genFlow} bind:flowCopilotMode />
@@ -1327,9 +1332,7 @@
</div>
<div class="gap-4 flex-row hidden md:flex w-full max-w-md">
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1338,15 +1341,14 @@
size="xs"
on:click={async () => {
select('triggers')
const selected = primaryScheduleIndex ?? scheduleIndex
if (selected) {
triggersState.selectedTriggerIndex = selected
}
selectTrigger('schedules')
}}
>
{triggersState.triggers[primaryScheduleIndex]?.draftConfig?.schedule ??
triggersState.triggers[primaryScheduleIndex]?.lightConfig?.schedule ??
''}
{$primaryScheduleStore != undefined
? $primaryScheduleStore
? $primaryScheduleStore?.cron
: ''
: $triggersCount?.primary_schedule?.schedule}
</Button>
{/if}
@@ -1407,16 +1409,12 @@
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
draft: savedFlow?.draft,
current: { ...$flowStore, path: $pathStore, draft_triggers: currentDraftTriggers }
draft: savedFlow['draft'],
current: { ...$flowStore, path: $pathStore }
})
}}
disabled={!savedFlow}
@@ -1439,7 +1437,7 @@
<FlowPreviewButtons
on:openTriggers={(e) => {
select('triggers')
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
selectTrigger(e.detail.kind)
captureOn.set(true)
showCaptureHint.set(true)
}}
@@ -1491,7 +1489,6 @@
flowPreviewButtons?.openPreview(true)
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -7,22 +7,25 @@
import TagsToListenTo from './TagsToListenTo.svelte'
import { enterpriseLicense, superadmin } from '$lib/stores'
import CollapseLink from './CollapseLink.svelte'
import AgentWorkerExtraBehavior from './AgentWorkerExtraBehavior.svelte'
export let customTags: string[] | undefined
let selectedTags: string[] = !$enterpriseLicense ? ['agent_test'] : []
type Props = {
customTags: string[] | undefined
}
let { customTags = $bindable() }: Props = $props()
let selectedTags: string[] = $state(!$enterpriseLicense ? ['agent_test'] : [])
let workerGroup: string = $state('agent')
let token: string = $state('')
let workerGroup: string = 'agent'
let suffix: string | undefined = $state(undefined)
let token: string = ''
$: selectedTags && selectedTags.length > 0 && $superadmin && workerGroup && refreshToken()
async function refreshToken() {
async function refreshToken(workerGroup: string, selectedTags: string[], suffix?: string) {
try {
const newToken = await AgentWorkersService.createAgentToken({
requestBody: {
worker_group: workerGroup,
tags: selectedTags,
suffix,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 3 // 3 years
}
})
@@ -32,6 +35,12 @@
sendUserToast('Error creating agent token: ' + error.toString(), true)
}
}
$effect(() => {
if (selectedTags.length > 0 && $superadmin) {
refreshToken(workerGroup, selectedTags, suffix)
}
})
</script>
<div class="flex flex-col gap-y-4">
@@ -55,64 +64,84 @@
{/if}
<TagsToListenTo disabled={!$enterpriseLicense} bind:worker_tags={selectedTags} {customTags} />
</Section>
<Section label="Extra behavior" headless eeOnly>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes, you can
only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<AgentWorkerExtraBehavior bind:suffix />
</Section>
<Section label="Generated JWT token" primary>
<div class="relative max-w-md">
<div class="relative max-w-md group">
<!-- svelte-ignore event_directive_deprecated -->
<input
on:click|preventDefault|stopPropagation|capture={() => {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
on:click|preventDefault|stopPropagation={() => {
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
placeholder="Select tags to generate a jwt token"
placeholder="Select tags to generate a JWT token"
type="text"
disabled
value={token}
class="pr-8 text-sm text-secondary"
class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncate"
/>
<!-- svelte-ignore event_directive_deprecated -->
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-primary"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 group-hover:text-blue-600 hover:scale-105 transition"
aria-label="Copy token to clipboard"
on:click|preventDefault|stopPropagation={() => {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
>
<Copy size={16} />
<Copy size={18} />
</button>
</div>
<div class="text-sm text-secondary mt-2 mb-12 max-w-md">
Pass the env variables:
<ul class="my-1">
<li>MODE=agent</li>
<li>AGENT_TOKEN={'"<token above>"'}</li>
<li>BASE_INTERNAL_URL={'"<base internal url>"'}</li>
<div class="flex flex-col gap-2 text-sm mt-3 leading-relaxed">
Set the following environment variables:
<ul class="list-disc list-inside mt-1">
<li><code>MODE=agent</code></li>
<li><code>AGENT_TOKEN=&lt;token&gt;</code></li>
<li><code>BASE_INTERNAL_URL=&lt;base url&gt;</code></li>
</ul>
to a worker to have it act as an HTTP agent worker. INIT_SCRIPT, if needed, must be passed as an
env variable.
<p class="mt-4">
Remember to have at least one normal worker that listens to the tags `flow` and `dependency`
(or `flow-$workspace` and `dependency-$workspace` if using workspace specific default tags)
to have flow and dependency job being runnable as agent workers can't run dependency jobs
nor can run the flow state machine (but can run the subjobs within them).
<p class="text-sm leading-relaxed">
to a worker to have it act as an HTTP agent worker.
<code>INIT_SCRIPT</code>, if needed, must be passed as an env variable.
</p>
<Alert type="warning" size="sm" title="Agent Worker Limitations">
Ensure at least one normal worker is running and listening to the tags
<code>flow</code> and <code>dependency</code>
(or <code>flow-&lt;workspace&gt;</code> and <code>dependency-&lt;workspace&gt;</code> if
using workspace-specific default tags), because agent workers
<strong>cannot run dependency jobs</strong>
nor execute the
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
</Alert>
<CollapseLink text="Automate JWT token generation" small>
<div class="text-xs mt-2">
Use the following API endpoint with a superadmin bearer token:
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
<pre class=" p-2 rounded-lg text-xs overflow-auto">
<code
>{`
"worker_group": "agent",
"tags": ["tag1", "tag2"],
"exp": 1717334400
`}</code
>
</pre>
The JSON response will contain the generated JWT token.
</div>
</CollapseLink>
</div>
<CollapseLink text="Automate JWT token generation" small>
<div class="text-xs text-secondary">
Use the following api endpoint with a superadmin bearer token:
<code class="text-primary"> POST /api/agent_workers/create_agent_token </code>
with body:
<pre>
<code class="text-primary">
{`{
"worker_group": "agent",
"tags": ["tag1", "tag2"],
"exp": 1717334400 // 3 years from now
}`}
</code>
</pre>
JSON response will be the JWT token.
</div>
</CollapseLink>
</Section>
</div>
+2 -4
View File
@@ -7,15 +7,13 @@
export let disabled = false
export let headless = false
export let required = false
export let headerClass = ''
</script>
<div class={twMerge(disabled ? 'opacity-60 pointer-events-none' : '', $$props.class)}>
<div class="flex flex-row justify-between items-center w-full">
{#if !headless}
<div class={twMerge('flex flex-row items-center gap-2', headerClass)}>
<span
class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6 whitespace-nowrap"
<div class="flex flex-row items-center gap-2">
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6"
>{label}
{#if required}
<Required required={true} />
+17 -29
View File
@@ -35,7 +35,6 @@
import { random_adj } from './random_positive_adjetive'
import { Eye, Folder, Loader2, Plus, SearchCode, User } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
type PathKind =
| 'resource'
@@ -65,7 +64,6 @@
export let dirty = false
export let kind: PathKind
export let hideUser: boolean = false
export let disableEditing = false
let inputP: HTMLInputElement | undefined = undefined
@@ -302,8 +300,7 @@
}
}
async function initPath() {
await tick()
function initPath() {
if (path != undefined && path != '') {
meta = pathToMeta(path, hideUser)
onMetaChange()
@@ -428,12 +425,11 @@
}
}
}}
disabled={disabled || disableEditing}
let:item
>
<ToggleButton
icon={User}
disabled={disabled || disableEditing}
{disabled}
light
size="xs"
value="user"
@@ -444,7 +440,7 @@
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
<ToggleButton
icon={Folder}
disabled={disabled || disableEditing}
{disabled}
light
size="xs"
value="folder"
@@ -466,20 +462,14 @@
type="text"
bind:value={meta.owner}
placeholder={$userStore?.username ?? ''}
disabled={disabled ||
!($superadmin || ($userStore?.is_admin ?? false)) ||
disableEditing}
disabled={disabled || !($superadmin || ($userStore?.is_admin ?? false))}
on:keydown={setDirty}
/>
</label>
{:else if meta.ownerKind === 'folder'}
<label class="block grow w-48">
<div class="flex flex-row items-center gap-1 w-full">
<select
class="grow w-full"
disabled={disabled || disableEditing}
bind:value={meta.owner}
>
<select class="grow w-full" {disabled} bind:value={meta.owner}>
{#if folders?.length == 0}
<option disabled>No folders</option>
{/if}
@@ -498,19 +488,17 @@
iconOnly
startIcon={{ icon: Eye }}
/>
{#if !disableEditing}
<Button
title="New folder"
btnClasses="!p-1.5"
variant="border"
color="light"
size="xs"
{disabled}
on:click={newFolder.openDrawer}
iconOnly
startIcon={{ icon: Plus }}
/>
{/if}
<Button
title="New folder"
btnClasses="!p-1.5"
variant="border"
color="light"
size="xs"
{disabled}
on:click={newFolder.openDrawer}
iconOnly
startIcon={{ icon: Plus }}
/>
</div>
</label>
{/if}
@@ -519,7 +507,7 @@
<label class="block grow w-full max-w-md">
<!-- svelte-ignore a11y-autofocus -->
<input
disabled={disabled || disableEditing}
{disabled}
type="text"
id="path"
{autofocus}
@@ -0,0 +1,86 @@
<script lang="ts">
import { Badge, Button } from './common'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import Toggle from './Toggle.svelte'
import { ListOrdered, PenBox } from 'lucide-svelte'
import JobArgs from './JobArgs.svelte'
import Tooltip from './Tooltip.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import { type Schedule } from '$lib/gen'
export let schedule: any
export let can_write: boolean
export let path: string
export let isFlow: boolean
export let scheduleEditor: ScheduleEditor
export let setScheduleEnabled: (path: string, enabled: boolean) => void
$: schedule = typeof schedule === 'boolean' ? undefined : (schedule as Schedule | undefined)
</script>
<div class="flex flex-col gap-2 grow w-full">
<div class="grid grid-cols-3 w-full">
<div class="flex justify-start">
<Badge color="indigo" small>
Primary&nbsp;
<Tooltip light>
Share the same path as the script or flow it is attached to and its path get renamed
whenever the source path is renamed
</Tooltip>
</Badge>
</div>
<div class="flex justify-center">
<div class="flex flex-row gap-2 items-center">
<input
size="9"
class="!text-xs !h-6 !text-primary"
type="text"
id="cron-schedule"
name="cron-schedule"
placeholder="*/30 * * * *"
value={schedule?.schedule ?? ''}
disabled={true}
/>
<Toggle
checked={schedule?.enabled ?? false}
on:change={(e) => {
if (can_write) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
options={{
right: 'On'
}}
size="xs"
textClass="text-primary font-normal text-xs"
/>
</div>
</div>
<div class="flex justify-end">
<div class="flex flex-row gap-2 items-center">
<Button size={'xs'} variant="border" color="light" href={`${base}/runs/${path}`}>
<span>Runs</span>
<ListOrdered size={14} />
</Button>
<Button
size="xs"
color="dark"
on:click={() => scheduleEditor?.openEdit(path ?? '', isFlow)}
>
<PenBox size={14} />
</Button>
</div>
</div>
</div>
{#if Object.keys(schedule?.args ?? {}).length > 0}
<div class="">
<JobArgs args={schedule?.args ?? {}} />
</div>
{:else}
<div class="text-xs text-tertiary"> No arguments </div>
{/if}
</div>
@@ -0,0 +1,245 @@
<script lang="ts">
import { Button } from './common'
import { workspaceStore } from '$lib/stores'
import { ScheduleService, type Schedule } from '$lib/gen'
import { Calendar, Trash, Save } from 'lucide-svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Label from '$lib/components/Label.svelte'
import { getContext } from 'svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import CronInput from './CronInput.svelte'
import SchemaForm from './SchemaForm.svelte'
import { emptyString, sendUserToast } from '$lib/utils'
import Toggle from './Toggle.svelte'
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
import { type Writable, writable } from 'svelte/store'
import Description from '$lib/components/Description.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
export let schema: any
export let isFlow: boolean
export let path: string
export let can_write: boolean
export let newItem: boolean = false
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
let scheduleEditor: ScheduleEditor
let schedules: Writable<Schedule[] | undefined> = writable(undefined)
let initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined> = writable(undefined)
async function updateSchedules(forceRefresh: boolean) {
const loadPrimarySchedule = true
loadSchedules(
forceRefresh,
path,
isFlow,
schedules,
primarySchedule,
initialPrimarySchedule,
$workspaceStore ?? '',
triggersCount,
loadPrimarySchedule
)
}
$: updateSchedules(false) || path
async function save() {
await saveSchedule(path, newItem, $workspaceStore ?? '', primarySchedule, isFlow)
updateSchedules(true)
}
</script>
<div class="flex flex-col gap-4 w-full">
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
Run scripts and flows automatically on a recurring basis using cron expressions. Each script or
flow can have multiple schedules, with one designated as primary.
</Description>
<ScheduleEditor
on:update={() => {
updateSchedules(true)
}}
bind:this={scheduleEditor}
/>
{#if $primarySchedule == undefined}
<Skeleton layout={[[12]]} />
{:else if $primarySchedule}
<div class="w-full flex flex-col mb-4">
{#if can_write}
<div class="w-full flex-row-reverse flex mb-2">
<div class="flex flex-row gap-4">
<Button
on:click={() => {
$primarySchedule = false
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 1) - 1,
primary_schedule: undefined
}
}}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Trash }}
/>
{#if initialPrimarySchedule && !newItem}
<Toggle
disabled={emptyString($primarySchedule.cron)}
bind:checked={$primarySchedule.enabled}
options={{
right: 'Enabled'
}}
on:change={async (e) => {
if (!newItem && $initialPrimarySchedule != false) {
await ScheduleService.setScheduleEnabled({
path: path,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${path}`)
}
}}
/>
{/if}
{#if !newItem}
<Button
on:click={save}
color="dark"
size="sm"
startIcon={{ icon: Save }}
disabled={JSON.stringify({ ...$primarySchedule, enabled: true }) ==
JSON.stringify({ ...initialPrimarySchedule, enabled: true })}
>Apply changes now</Button
>
{:else}
<div class="text-sm text-secondary mt-1 text-center"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
</div>
{/if}
<!-- svelte-ignore a11y-autofocus -->
<div class="mt-5">
<Label label="Summary" class="font-semibold" primary>
<input
autofocus
type="text"
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
bind:value={$primarySchedule.summary}
/>
</Label>
</div>
</div>
<CronInput bind:schedule={$primarySchedule.cron} bind:timezone={$primarySchedule.timezone} />
<SchemaForm onlyMaskPassword {schema} bind:args={$primarySchedule.args} />
{#if emptyString($primarySchedule.cron)}
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
{#if $initialPrimarySchedule != false && !newItem}
<div class="flex">
<Button size="xs" color="light" on:click={() => scheduleEditor?.openEdit(path, isFlow)}
>Advanced</Button
>
</div>
{/if}
{:else}
<div class="flex flex-row gap-4 mt-2">
<div class="flex items-center">
<Button
on:click={() => {
$primarySchedule = {
summary: '',
args: {},
cron: '0 0 */1 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: $primarySchedule.cron }
}
}}
variant="contained"
color="dark"
size="sm"
startIcon={{ icon: Calendar }}
>
Set primary schedule
</Button>
</div>
{#if $initialPrimarySchedule != undefined && $initialPrimarySchedule != false && !newItem}
<Button on:click={save} color="dark" size="md" startIcon={{ icon: Save }}>
Apply changes now
</Button>
{:else}
<div class="text-sm text-center text-secondary mt-2"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
<Label label="Summary" class="font-semibold" primary>
<input
type="text"
disabled
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
/>
</Label>
<CronInput schedule={''} disabled timezone={Intl.DateTimeFormat().resolvedOptions().timeZone} />
<SchemaForm disabled {schema} />
{/if}
{#if !newItem}
<div class="mt-10"></div>
{#if $primarySchedule}
<Button
on:click={() => scheduleEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New schedule
</Button>
{/if}
<Label label="Other schedules">
{#if $schedules}
{#if $schedules.length == 0 || $schedules == undefined}
<div class="text-xs text-tertiary"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y">
{#each $schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-xs items-center py-2">
<div class="col-span-3 truncate">{schedule.path}</div>
<div class="col-span-2 flex flex-row gap-4 flex-nowrap">
<div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
</div>
<div class="flex justify-end">
<button
on:click={() => scheduleEditor?.openEdit(schedule.path, isFlow)}
class="px-2">Edit</button
>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Label>
{/if}
</div>
@@ -1,53 +0,0 @@
<script lang="ts">
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { onMount } from 'svelte'
let scheduleEditor = $state<ScheduleEditorInner | null>(null)
let {
selectedTrigger,
isFlow,
path,
defaultValues = undefined,
schema,
customLabel = undefined,
...restProps
} = $props()
function openScheduleEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
scheduleEditor?.openNew(isFlow, path, defaultValues)
} else {
scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
selectedTrigger?.type === 'schedule' &&
scheduleEditor &&
openScheduleEditor(isFlow, selectedTrigger.isDraft ?? false)
})
</script>
<ScheduleEditorInner
useDrawer={false}
bind:this={scheduleEditor}
hideTarget
allowDraft
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
primary={selectedTrigger.isPrimary}
draftSchema={schema}
{customLabel}
{...restProps}
>
{#snippet docDescription()}
<div class="flex flex-col gap-2 pb-4">
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
Run scripts and flows automatically on a recurring basis using cron expressions.
</Description>
</div>
{/snippet}
</ScheduleEditorInner>
<!-- hideTarget
hidePath
{header} -->
+121 -180
View File
@@ -4,6 +4,7 @@
type NewScript,
ScriptService,
type NewScriptWithDraft,
ScheduleService,
type Script,
type TriggersCount,
PostgresTriggerService,
@@ -11,18 +12,13 @@
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
import {
defaultScripts,
enterpriseLicense,
usedTriggerKinds,
userStore,
workspaceStore
} from '$lib/stores'
import { defaultScripts, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import {
cleanValueProperties,
emptySchema,
emptyString,
encodeState,
formatCron,
generateRandomString,
orderedJsonStringify,
replaceFalseWithUndefined,
@@ -72,7 +68,7 @@
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import type { ScheduleTrigger, TriggerContext, TriggerKind } from './triggers'
import {
TS_PREPROCESSOR_MODULE_CODE,
TS_PREPROCESSOR_SCRIPT_INTRO,
@@ -83,17 +79,8 @@
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import DeployButton from './DeployButton.svelte'
import {
type NewScriptWithDraftAndDraftTriggers,
type Trigger,
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
export let script: NewScript & { draft_triggers?: Trigger[] }
export let script: NewScript
export let fullyLoaded: boolean = true
export let initialPath: string = ''
export let template: 'docker' | 'bunnative' | 'script' = 'script'
@@ -102,7 +89,7 @@
export let showMeta: boolean = false
export let neverShowMeta: boolean = false
export let diffDrawer: DiffDrawer | undefined = undefined
export let savedScript: NewScriptWithDraftAndDraftTriggers | undefined = undefined
export let savedScript: NewScriptWithDraft | undefined = undefined
export let searchParams: URLSearchParams = new URLSearchParams()
export let disableHistoryChange = false
export let replaceStateFn: (url: string) => void = (url) =>
@@ -115,10 +102,7 @@
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedScript,
modifiedValue: {
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
modifiedValue: script
}
}
@@ -150,38 +134,20 @@
let scriptEditor: ScriptEditor | undefined = undefined
let captureTable: CaptureTable | undefined = undefined
// Draft triggers confirmation modal
let draftTriggersModalOpen = false
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // keep for legacy
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const simplifiedPoll = writable(false)
const selectedTriggerStore = writable<TriggerKind>('webhooks')
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
const dispatch = createEventDispatcher()
$: initialPath != '' && loadTriggers()
@@ -210,42 +176,29 @@
workspace: $workspaceStore!,
path: initialPath
})
await triggersState.fetchTriggers(
triggersCount,
$workspaceStore,
initialPath,
false,
$primaryScheduleStore,
$userStore
)
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
savedScript = filterDraftTriggers(
savedScript,
triggersState
) as NewScriptWithDraftAndDraftTriggers
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
}
}
// Add triggers context store
const triggersState = new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
],
undefined,
saveSessionDraft
)
const triggerDefaultValuesStore = writable<Record<string, any> | undefined>(undefined)
const captureOn = writable<boolean | undefined>(undefined)
const showCaptureHint = writable<boolean | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
showCaptureHint: showCaptureHint,
triggersState
defaultValues: triggerDefaultValuesStore,
captureOn: captureOn,
showCaptureHint: showCaptureHint
})
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql', 'oracledb']
@@ -321,26 +274,8 @@
})
}
$: !disableHistoryChange && encodeScriptState(script)
function encodeScriptState(script: NewScript) {
replaceStateFn(
'#' +
encodeState({
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
})
)
}
let timeout: NodeJS.Timeout | undefined = undefined
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
encodeScriptState(script)
}, 500)
}
$: !disableHistoryChange &&
replaceStateFn('#' + encodeState({ ...script, primarySchedule: $primaryScheduleStore }))
if (script.content == '') {
initContent(script.language, script.kind, template)
}
@@ -382,6 +317,31 @@
}
}
async function createSchedule(path: string) {
if (!$primaryScheduleStore) {
return
}
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: false,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
}
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
@@ -459,21 +419,8 @@
async function editScript(
stay: boolean,
parentHash: string,
deploymentMsg?: string,
triggersToDeploy?: Trigger[]
deploymentMsg?: string
): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await editScript(stay, parentHash, deploymentMsg, triggersToDeploy)
}
return
}
}
loadingSave = true
try {
try {
@@ -543,21 +490,55 @@
})
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
script.path,
true
)
const scheduleExists =
initialPath != '' &&
(await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: script.path
}))
if ($primaryScheduleStore) {
const { enabled, timezone, args, cron, summary } = $primaryScheduleStore
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: { enabled }
})
}
} else if (enabled) {
await createSchedule(script.path)
}
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
}
const { draft_triggers: _, ...newScript } = structuredClone(script)
savedScript = structuredClone(newScript) as NewScriptWithDraft
triggersState.setTriggers([])
savedScript = structuredClone(script) as NewScriptWithDraft
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
@@ -581,8 +562,7 @@
if (savedScript) {
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
const current = cleanValueProperties(script)
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
@@ -673,16 +653,12 @@
}
})
}
const draftTriggers = triggersState.getDraftTriggersSnapshot()
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
typ: 'script',
value: {
...script,
draft_triggers: draftTriggers
}
value: { ...script, primary_schedule: $primaryScheduleStore }
}
})
@@ -690,11 +666,8 @@
...(initialPath == '' || savedScript?.draft_only
? { ...structuredClone(script), draft_only: true }
: savedScript),
draft: {
...structuredClone(script),
draft_triggers: draftTriggers
}
} as NewScriptWithDraftAndDraftTriggers
draft: structuredClone(script)
} as NewScriptWithDraft
let savedAtNewPath = false
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
@@ -717,7 +690,7 @@
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
savedScript: NewScriptWithDraft | undefined,
diffDrawer: DiffDrawer | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
@@ -745,19 +718,12 @@
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedScript,
draft: savedScript['draft'],
current: {
...script,
draft_triggers: currentDraftTriggers
}
current: script
})
}
}
@@ -833,7 +799,8 @@
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
selectedTriggerStore.set(ev.detail.kind)
triggerDefaultValuesStore.set(ev.detail.config)
captureOn.set(true)
}
@@ -857,18 +824,6 @@
}
selectedInputTab = 'preprocessor'
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved script to remove the draft trigger that is deployed
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedScript.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -882,21 +837,11 @@
bind:deployedValue
currentValue={script}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#if !$userStore?.operator}
<Drawer
placement="right"
bind:open={metadataOpen}
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
size={selectedTab === 'ui' ? '1200px' : '800px'}
>
<DrawerContent noPadding title="Settings" on:close={() => (metadataOpen = false)}>
<!-- svelte-ignore a11y-autofocus -->
@@ -1514,30 +1459,27 @@
customUi={customUi?.settingsPanel?.metadata?.editableSchemaForm}
/>
</TabContent>
<TabContent value="triggers" class="h-full">
<TabContent value="triggers">
<TriggersEditor
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
on:exitTriggers={() => {
captureTable?.loadCaptures(true)
}}
currentPath={script.path}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
{initialPath}
{fakeInitialPath}
schema={script.schema}
noEditor={true}
newItem={initialPath == ''}
isFlow={false}
{hasPreprocessor}
currentPath={script.path}
hash={script.parent_hash}
newItem={initialPath == ''}
canHavePreprocessor={script.language === 'bun' ||
script.language === 'deno' ||
script.language === 'python3'}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && !savedScript?.draft_only}
schema={script.schema}
hash={script.parent_hash}
onDeployTrigger={handleDeployTrigger}
{hasPreprocessor}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
</div>
@@ -1568,10 +1510,7 @@
</div>
<div class="gap-4 flex">
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const schedule = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1581,12 +1520,14 @@
on:click={async () => {
metadataOpen = true
selectedTab = 'triggers'
triggersState.selectedTriggerIndex = primarySchedule ?? schedule
$selectedTriggerStore = 'schedules'
}}
>
{triggersState.triggers[primarySchedule]?.draftConfig?.schedule ??
triggersState.triggers[primarySchedule]?.lightConfig?.schedule ??
''}
{$primaryScheduleStore != undefined
? $primaryScheduleStore
? $primaryScheduleStore?.cron
: ''
: $triggersCount?.primary_schedule?.schedule}
</Button>
{/if}
{#if customUi?.topBar?.path != false}
@@ -94,7 +94,7 @@
{/if}
{#if disabled}
<input type="text" value={scriptPath ?? initialPath ?? ''} disabled />
<input type="text" value={scriptPath ?? ''} disabled />
{:else}
<Select
value={items?.find((x) => x.value == initialPath)}
+1 -3
View File
@@ -11,7 +11,6 @@
export let eeOnly = false
export let small: boolean = false
export let wrapperClass: string = ''
export let headerClass: string = ''
export let collapsable: boolean = false
export let collapsed: boolean = true
@@ -27,8 +26,7 @@
class={twMerge(
'font-semibold flex flex-row items-center gap-1',
breakAll ? 'break-all' : '',
small ? 'text-sm' : 'text-base',
headerClass
small ? 'text-sm' : 'text-base'
)}
>
{#if collapsable}
+2 -2
View File
@@ -41,7 +41,7 @@
import { CornerDownLeft } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
import Editor from './Editor.svelte'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { runScriptAndPollResult } from './jobs/utils'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
@@ -91,7 +91,7 @@
.join(';')
}
let { job, result } = (await runPreviewJobAndPollResult(
let { job, result } = (await runScriptAndPollResult(
{
workspace: $workspaceStore,
requestBody: {
@@ -67,7 +67,7 @@
$: {
if (format == 'email') {
pattern = '^[\\w-+.]+@([\\w-]+\\.)+[\\w-]{2,63}$'
pattern = '^[\\w-+.]+@([\\w-]+\\.)+[\\w-]{2,4}$'
}
}
@@ -1,47 +1,55 @@
<script lang="ts">
import { X } from 'lucide-svelte'
import { X, Plus, Trash } from 'lucide-svelte'
import { Button } from './common'
import { Plus } from 'lucide-svelte'
import { superadmin } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import AutoComplete from 'simple-svelte-autocomplete'
import { defaultTags, nativeTags } from './worker_group'
const dispatch = createEventDispatcher()
export let worker_tags: string[] = []
export let customTags: string[] = []
export let disabled = false
let newTag = ''
let createdTags: string[] = []
type Props = {
worker_tags: string[]
customTags: string[] | undefined
disabled?: boolean
}
let {
worker_tags = $bindable([]),
customTags = $bindable([]),
disabled = $bindable(false)
}: Props = $props()
let newTag = $state('')
let createdTags: string[] = $state([])
</script>
<div class="flex gap-3 gap-y-2 flex-wrap pb-2">
{#if worker_tags?.length == 0}
<div class="flex gap-2 gap-y-2 flex-wrap pb-3">
{#if worker_tags?.length === 0}
<div class="text-xs text-secondary">No tags selected</div>
{/if}
{#each worker_tags as tag}
<div class="flex gap-0.5 items-center"
><div class="text-2xs p-1 rounded border text-primary">{tag}</div>
<div
class="flex items-center gap-1 px-2 py-1 rounded-full border border-primary text-2xs text-primary bg-surface-primary"
>
<span>{tag}</span>
{#if $superadmin && !disabled}
<button
class={'z-10 rounded-full p-1 duration-200 hover:bg-gray-200'}
aria-label="Remove item"
on:click|preventDefault|stopPropagation={() => {
worker_tags = worker_tags?.filter((t) => t != tag) ?? []
<Button
class="p-1 rounded-full hover:bg-surface-hover transition"
aria-label="Remove tag"
on:click={() => {
worker_tags = worker_tags?.filter((t) => t !== tag) ?? []
dispatch('dirty')
dispatch('deletePriorityTag', tag)
}}
>
<X size={12} />
</button>
{/if}</div
>
</Button>
{/if}
</div>
{/each}
</div>
{#if $superadmin}
<div class="max-w-md">
<div class="max-w-md space-y-2">
<AutoComplete
noInputStyles
items={[...(customTags ?? []), ...createdTags, ...defaultTags, ...nativeTags].filter(
@@ -50,26 +58,23 @@
{disabled}
bind:selectedItem={newTag}
hideArrow={true}
inputClassName={'flex !font-gray-600 !font-primary !bg-surface-primary"'}
dropdownClassName="!text-sm !py-2 !rounded-sm !border-gray-200 !border !shadow-md"
className="w-full !font-gray-600 !font-primary !bg-surface-primary"
onFocus={() => {
dispatch('focus')
}}
inputClassName="w-full text-sm bg-surface-primary border border-gray-300 rounded-md px-3 py-2 text-primary placeholder-secondary focus:outline-none"
dropdownClassName="!text-sm !py-2 !rounded-md !border-gray-200 !border !shadow-md bg-white"
className="w-full font-primary text-primary"
onFocus={() => dispatch('focus')}
create
onCreate={(c) => {
onCreate={(c: string) => {
createdTags.push(c)
createdTags = [...createdTags]
return c
}}
createText="Press enter to use this tag"
createText="Press Enter to use this tag"
/>
<div class="mt-1"></div>
<div class="flex">
<div class="flex gap-2">
<Button
variant="contained"
color="blue"
color="light"
size="xs"
startIcon={{ icon: Plus }}
disabled={disabled || newTag == '' || worker_tags?.includes(newTag)}
@@ -81,6 +86,22 @@
>
Add tag
</Button>
<Button
variant="contained"
color="red"
size="xs"
startIcon={{ icon: Trash }}
disabled={disabled || worker_tags.length === 0}
on:click={() => {
worker_tags = worker_tags.filter((tag) => {
dispatch('deletePriorityTag', tag)
return false
})
dispatch('dirty')
}}
>
Remove all selected tags
</Button>
</div>
</div>
{/if}
+8 -10
View File
@@ -21,7 +21,7 @@
export let lightMode: boolean = false
export let eeOnly: boolean = false
export let size: 'sm' | 'xs' | '2xs' | '2sm' = 'sm'
export let size: 'sm' | 'xs' | '2xs' = 'sm'
const dispatch = createEventDispatcher<{ change: boolean }>()
const bothOptions = Boolean(options.left) && Boolean(options.right)
@@ -40,7 +40,7 @@
class={twMerge(
'mr-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
size === 'xs' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
textClass
)}
style={textStyle}
@@ -73,15 +73,13 @@
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
size === 'sm'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
: size === '2sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
)}
></div>
</div>
@@ -90,7 +88,7 @@
class={twMerge(
'ml-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
size === 'xs' ? 'text-xs' : 'text-sm',
textClass
)}
style={textStyle}
@@ -1,29 +0,0 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import { ExternalLink } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
export let documentationLink: string | undefined = undefined
export let markdownTooltip: string | undefined = undefined
const plugins = [gfmPlugin()]
</script>
<div
class="shadow max-w-sm break-words py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left"
>
{#if markdownTooltip}
<div class="prose-sm">
<Markdown md={markdownTooltip} {plugins} />
</div>
{:else}
<slot />
{/if}
{#if documentationLink}
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
See documentation
<ExternalLink size="16" />
</div>
</a>
{/if}
</div>
@@ -0,0 +1,295 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import { runScriptAndPollResult } from './jobs/utils'
import { workspaceStore } from '$lib/stores'
import { Badge, Button, Drawer, DrawerContent, Skeleton } from './common'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Library, Play } from 'lucide-svelte'
import Editor from './Editor.svelte'
import WorkspaceScriptPicker from './flows/pickers/WorkspaceScriptPicker.svelte'
import ToggleHubWorkspace from './ToggleHubWorkspace.svelte'
import type { SupportedLanguage } from '$lib/common'
import HighlightCode from './HighlightCode.svelte'
import PickHubScript from './flows/pickers/PickHubScript.svelte'
import { getScriptByPath } from '$lib/scripts'
import { FitAddon } from '@xterm/addon-fit'
import { Readline } from 'xterm-readline'
import Tooltip from './Tooltip.svelte'
let bashEditorDrawer: Drawer | undefined = undefined
let container: HTMLDivElement
let term: Terminal
let input = ''
type Props = {
tag: string
width?: number
}
let scriptPicker: Drawer | undefined = $state()
let editor = $state<Editor | null>(null)
let darkMode = $state(false)
let pick_existing: 'workspace' | 'hub' = $state('workspace')
let codeViewer: Drawer | undefined = $state()
let filter = $state('')
let { tag }: Props = $props()
let code: string = $state('')
let working_directory = $state('~')
let homeDirectory: string = '~'
let prompt = $derived(
`$-${working_directory === '/' ? '/' : working_directory.split('/').at(-1)} `
)
let codeObj: { language: SupportedLanguage; content: string } | undefined = $state(undefined)
function resolvePath(currentDir: string, newPath: string): string {
if (newPath.startsWith('/') || newPath.startsWith('~')) {
return newPath
}
let parts = currentDir.split('/').filter(Boolean)
const segments = newPath.split('/').filter(Boolean)
for (const segment of segments) {
if (segment === '..') {
parts.pop()
} else if (segment !== '.') {
parts.push(segment)
}
}
return (currentDir.startsWith('~') ? '' : '/') + parts.join('/')
}
function ensureTrailingLineBreak(input: any): boolean {
if (typeof input !== 'string') {
return false
}
return input.endsWith('\r\n') || input.endsWith('\n')
}
function isSimpleCdCommand(command: string): boolean {
const trimmed = command.trim()
// Matches:
// - Starts with "cd"
// - Followed by any number of valid args (quoted or not)
// - No use of &, |, ; outside quotes
// - No other commands
const cdRegex = /^cd(\s+("[^"]*"|'[^']*'|[^\s"'&|;]+))*\s*$/
return cdRegex.test(trimmed)
}
async function handleCommand(command: string) {
try {
const trimmedCommand = command.trim()
if (trimmedCommand.length === 0) return
const isOnlyCdCommand = isSimpleCdCommand(trimmedCommand)
let wDirectory = working_directory
if (isOnlyCdCommand) {
const parts = trimmedCommand.split(' ')
if (parts.length > 1) {
const path = parts.slice(1).join(' ')
const newPath = resolvePath(working_directory, path)
wDirectory = newPath
} else {
wDirectory = homeDirectory
}
}
let result: any = await runScriptAndPollResult({
workspace: $workspaceStore!,
requestBody: {
language: 'bash',
content: `(cd ${wDirectory} && ${isOnlyCdCommand ? 'pwd' : `${trimmedCommand}`}) > result.out`,
tag,
args: {}
}
})
if (isOnlyCdCommand) {
working_directory = (result as string).replace(/(\r\n|\n|\r)/g, '')
result = ''
} else if (!ensureTrailingLineBreak(result)) {
result += '\r\n'
}
rl.write(result)
} catch (e) {
term.writeln(`Error: ${e}`)
}
}
const rl = new Readline()
onMount(async () => {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
theme: {
background: '#1e1e1e',
foreground: '#ffffff'
},
fontFamily: 'monospace',
convertEol: true,
rightClickSelectsWord: true
})
term.loadAddon(rl)
function readLine() {
rl.read(prompt).then(processLine)
}
async function processLine(text: string) {
await handleCommand(text)
setTimeout(readLine)
}
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(container)
term.focus()
fitAddon.fit()
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(container)
readLine()
})
function clearPrompt() {
const buffer = term.buffer.active
const lastLineIndex = buffer.baseY + buffer.cursorY
for (let i = lastLineIndex; i >= 0; i--) {
const line = buffer.getLine(i)
if (!line) break
const text = line.translateToString()
const postion = text.indexOf(prompt)
if (postion !== -1) {
const x = postion + prompt.length + 1
term.write(`\x1b[${x}G`)
const numSpaces = text.length - x
term.write(' '.repeat(numSpaces))
term.write(`\x1b[${x}G`)
break
} else {
term.write('\x1b[2K\r')
}
term.write(`\x1b[1A`)
}
}
async function onScriptPick(e: { detail: { path: string } }) {
codeObj = undefined
codeViewer?.openDrawer?.()
codeObj = await getScriptByPath(e.detail.path ?? '')
}
async function replacePromptWithCommand(command: string) {
clearPrompt()
if (!ensureTrailingLineBreak(command)) {
command += '\r\n'
}
input = command
rl.appendHistory(command)
term.write(command)
await handleCommand(input)
term.write(prompt)
}
</script>
<DarkModeObserver bind:darkMode />
<Drawer bind:this={codeViewer} size="600px">
<DrawerContent title="Code" on:close={codeViewer.closeDrawer}>
{#if codeObj}
<HighlightCode language={codeObj?.language} code={codeObj?.content} />
{:else}
<Skeleton layout={[[40]]} />
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={scriptPicker} size="900px">
<DrawerContent title="Code" on:close={scriptPicker.closeDrawer}>
{#if pick_existing == 'hub'}
<PickHubScript bind:filter kind={'script'} on:pick={onScriptPick}>
<ToggleHubWorkspace bind:selected={pick_existing} />
</PickHubScript>
{:else}
<WorkspaceScriptPicker bind:filter kind={'script'} on:pick={onScriptPick}>
<ToggleHubWorkspace bind:selected={pick_existing} />
</WorkspaceScriptPicker>
{/if}
</DrawerContent>
</Drawer>
<div class="h-screen flex flex-col">
<div class="m-1">
<div class="flex flex-col">
<div class="flex justify-start w-full mb-2">
<div class="flex flex-row">
<Badge
color="gray"
class="relative center-center !bg-gray-300 !text-tertiary dark:!bg-gray-700 dark:!text-gray-300 !h-[40px] rounded-r-none rounded-l-none"
>
Full path
<Tooltip
markdownTooltip="Commands run in the default directory. Run a standalone `cd` to change it. Chained or invalid `cd` commands wont apply."
class="absolute top-0.5"
/>
</Badge>
</div>
<input type="text" disabled bind:value={working_directory} />
</div>
</div>
<div bind:this={container}></div>
</div>
<div class="flex flex-col h-full gap-1 mt-2">
<div class="flex flex-row w-full justify-between">
<div class="flex flex-row">
<Button
btnClasses="!font-medium text-tertiary "
size="xs"
spacingSize="md"
color="light"
startIcon={{ icon: Play }}
title="Run bash script"
on:click={async () => {
await replacePromptWithCommand(code)
}}
>
Run
</Button>
<Button
btnClasses="!font-medium text-tertiary "
size="xs"
spacingSize="md"
color="light"
on:click={scriptPicker.openDrawer}
startIcon={{ icon: Library }}
title="Explore other scripts"
>
Library
</Button>
</div>
</div>
<Editor bind:this={editor} bind:code lang="bash" scriptLang="bash" class="w-full h-full" />
</div>
</div>
<style>
:global(.xterm-screen) {
padding-left: 10px;
}
</style>
@@ -8,7 +8,7 @@ import {
} from 'graphql'
import { tryEvery } from '$lib/utils'
import { stringifySchema } from '$lib/components/copilot/lib'
import { runPreviewJobAndPollResult } from '$lib/components/jobs/utils'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
export enum ColumnIdentity {
ByDefault = 'By Default',
@@ -114,7 +114,7 @@ export async function loadAllTablesMetaData(
}
try {
let result = (await runPreviewJobAndPollResult({
let result = (await runScriptAndPollResult({
workspace: workspace,
requestBody: {
language: getLanguageByResourceType(resourceType),
@@ -1,22 +0,0 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
interface Props {
url?: string
disabled?: boolean
label?: string
}
let { url = '', disabled = false, label = '' }: Props = $props()
</script>
<div class="flex justify-start w-full">
<Badge color="gray" class="rounded-r-none h-[27px]">{label}</Badge>
<ClipboardPanel
content={url}
class="rounded-l-none bg-surface border-none outline outline-2 outline-surface-secondary outline-offset-[-2px]"
{disabled}
/>
</div>
@@ -198,7 +198,6 @@
this={startIcon.icon}
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
@@ -247,7 +246,6 @@
this={startIcon.icon}
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
@@ -290,13 +288,12 @@
<div
class={twMerge(
buttonClass,
'rounded-md m-0 p-0 center-center h-full',
'rounded-md m-0 p-0 !w-10 center-center h-full',
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
'rounded-r-md !rounded-l-none',
size === 'xs2' ? '!w-8' : '!w-10'
'rounded-r-md !rounded-l-none'
)}
>
<ChevronDown size={lucideIconSize} />
<ChevronDown class="w-5 h-5" />
</div>
</svelte:fragment>
</Dropdown>
@@ -10,7 +10,6 @@ export namespace ButtonType {
icon?: any | undefined
classes?: string
faIcon?: any | undefined
props?: any
}
export const FontSizeClasses: Record<ButtonType.Size, string> = {
@@ -4,7 +4,6 @@
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { AlertTriangle, CornerDownLeft, Loader2, RefreshCcw } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
type Props = {
title: string
@@ -13,7 +12,6 @@
loading?: boolean
open?: boolean
type?: 'danger' | 'reload'
showIcon?: boolean
}
const {
@@ -22,8 +20,7 @@
keyListen = true,
loading = false,
open = false,
type: _type,
showIcon = true
type: _type
}: Props = $props()
const type = $derived(_type ?? 'danger')
@@ -95,14 +92,12 @@
)}
>
<div class="flex">
{#if showIcon}
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<Icon class={theme[type].classes.icon} />
</div>
{/if}
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<Icon class={theme[type].classes.icon} />
</div>
<div class="ml-4 text-left flex-1">
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
@@ -1,178 +0,0 @@
<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import { createEventDispatcher } from 'svelte'
import type { Trigger } from '$lib/components/triggers/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
interface Props {
open?: boolean
draftTriggers?: Trigger[]
isFlow?: boolean
}
let { open = $bindable(false), draftTriggers = [], isFlow = false }: Props = $props()
let selectedTriggers: Trigger[] = $state(draftTriggers)
const dispatch = createEventDispatcher<{
canceled: void
confirmed: { selectedTriggers: Trigger[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
}
function checkSavePermissions(trigger: Trigger) {
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
$effect(() => {
open &&
(selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy'))
})
</script>
<ConfirmationModal
{open}
title="Draft triggers detected !"
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
on:canceled={() => dispatch('canceled')}
on:confirmed={() => dispatch('confirmed', { selectedTriggers })}
>
<div class="flex flex-col w-full gap-8 pb-4">
<div class="text-secondary text-sm">
{`${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.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Triggers to deploy</th>
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftTriggers as trigger}
{@const SvelteComponent = triggerIconMap[trigger.type]}
{@const permission = checkSavePermissions(trigger)}
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
<tr
class={twMerge(
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<td class={twMerge('text-center py-1 px-4')}>
<div class="flex flex-row items-center gap-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={14}
class={isSelectedTrigger
? 'text-blue-500'
: 'text-gray-500 dark:text-gray-400'}
/>
{#if trigger.isPrimary}
<Star size={8} class="absolute -mt-3 ml-3 text-blue-400" />
{/if}
</div>
<div class="flex grow min-w-0 items-center text-left">
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
</div>
</div>
</td>
<td class="text-left py-1">
{#if permission === 'deploy'}
<div class="flex justify-start">
<ToggleButtonGroup
let:item
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 w-[54px] justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-marine-400 data-[state=on]:text-white data-[state=on]:dark:bg-marine-50 data-[state=on]:dark:text-primary-inverse w-[54px] justify-center"
/>
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Only admins can deploy http triggers"
>
Admin only
</div>
{:else if permission === 'invalid-config'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Invalid config"
>
Invalid config
</div>
{/if}
</td>
</tr>
{/each}
{#if draftTriggers.length === 0}
<tr>
<td colspan="3" class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
No draft triggers found
</td>
</tr>
{/if}
</tbody>
</DataTable>
</div>
</div>
</ConfirmationModal>
@@ -17,7 +17,6 @@ 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'
@@ -76,7 +76,7 @@
</script>
{#if menuOpen}
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
@@ -88,7 +88,7 @@
</script>
{#if menuOpen}
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
{/if}
<Row
@@ -35,7 +35,6 @@
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)}
@@ -48,6 +48,6 @@
{id}
>
<div class={twMerge('flex bg-surface-secondary rounded-md p-0.5 gap-1 h-full ', tabListClass)}>
<slot {item} {disabled} />
<slot {item} />
</div>
</div>
+7 -7
View File
@@ -4,7 +4,7 @@ import {
type DbType
} from './apps/components/display/dbtable/utils'
import { makeSelectQuery } from './apps/components/display/dbtable/queries/select'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { runScriptAndPollResult } from './jobs/utils'
import { makeCountQuery } from './apps/components/display/dbtable/queries/count'
import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update'
import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete'
@@ -55,7 +55,7 @@ export function dbTableOpsWithPreviewScripts({
colDefs,
getCount: async ({ quicksearch }) => {
const countQuery = makeCountQuery(resourceType, tableKey, undefined, colDefs)
const result = await runPreviewJobAndPollResult({
const result = await runScriptAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, quicksearch },
@@ -68,7 +68,7 @@ export function dbTableOpsWithPreviewScripts({
},
getRows: async (params) => {
const query = makeSelectQuery(tableKey, colDefs, undefined, resourceType as DbType)
let items = (await runPreviewJobAndPollResult({
let items = (await runScriptAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...params },
@@ -85,7 +85,7 @@ export function dbTableOpsWithPreviewScripts({
onUpdate: async ({ values }, colDef, newValue) => {
const updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, resourceType)
await runPreviewJobAndPollResult({
await runScriptAndPollResult({
workspace,
requestBody: {
args: {
@@ -101,7 +101,7 @@ export function dbTableOpsWithPreviewScripts({
onDelete: async ({ values }) => {
const deleteQuery = makeDeleteQuery(tableKey, colDefs, resourceType)
await runPreviewJobAndPollResult({
await runScriptAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...values },
@@ -112,7 +112,7 @@ export function dbTableOpsWithPreviewScripts({
},
onInsert: async ({ values }) => {
const insertQuery = makeInsertQuery(tableKey, colDefs, resourceType)
runPreviewJobAndPollResult({
runScriptAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...values },
@@ -155,7 +155,7 @@ export function dbDeleteTableActionWithPreviewScript({
successText: `Table '${tableKey}' deleted successfully`,
action: async () => {
const deleteQuery = makeDeleteTableQuery(tableKey, resourceType)
await runPreviewJobAndPollResult({
await runScriptAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath },
@@ -16,10 +16,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={twMerge(
'grow min-w-0 w-full px-2 py-1 border flex items-center bg-surface-secondary text-primary justify-between rounded-md',
$$props.class
)}
class="grow min-w-0 w-full px-2 py-1 border flex items-center bg-surface-secondary text-primary justify-between rounded-md"
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
on:click={(e) => {
@@ -12,7 +12,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-col flex-1 border rounded-md relative"
class="flex flex-row flex-1 border p-2 rounded-md overflow-auto relative"
class:cursor-not-allowed={disabled}
on:click={(e) => {
if (disabled) {
@@ -22,10 +22,6 @@
copyToClipboard(code)
}}
>
<div class="absolute top-2 right-1 z-10 pointer-events-none">
<Clipboard size={14} class="w-8 cursor-pointer pointer-events-auto" />
</div>
<div class="p-2 overflow-auto w-full">
<Highlight {language} {code} class="pointer-events-none" />
</div>
<Highlight {language} {code} class="pointer-events-none" />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
</div>
@@ -3,8 +3,24 @@
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
@@ -36,8 +52,21 @@
<TabContent value="script" class="h-full">
<slot name="script" />
</TabContent>
<TabContent value="triggers" class="h-full">
<slot name="triggers" />
<TabContent value="triggers" class="h-full pt-2">
<DetailPageTriggerPanel {simplfiedPoll} bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
{#if flow_json}
<TabContent value="raw" class="flex flex-col flex-1 h-full overflow-auto p-2">
@@ -5,7 +5,7 @@
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext, tick } from 'svelte'
import { createEventDispatcher, getContext } from 'svelte'
import type { TriggerContext } from '../triggers'
import { Calendar } from 'lucide-svelte'
@@ -23,7 +23,7 @@
color?: 'red'
}
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, selectedTrigger } = getContext<TriggerContext>('TriggerContext')
export let mainButtons: MainButton[] = []
export let menuItems: MenuItemButton[] = []
@@ -52,20 +52,16 @@
<Badge>tag: {tag}</Badge>
{/if}
<slot />
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
{@const primarySchedule = triggersState.triggers.findIndex(
(t) => t.isPrimary && !t.isDraft
)}
{#if $triggersCount?.primary_schedule}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
dispatch('seeTriggers')
await tick()
triggersState.selectedTriggerIndex = primarySchedule
on:click={() => {
$selectedTrigger = 'schedules'
dispatch('triggerDetail')
}}
>
{$triggersCount?.primary_schedule?.schedule ?? ''}
@@ -3,14 +3,48 @@
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import DetailPageDetailPanel from './DetailPageDetailPanel.svelte'
import type { ScheduleTrigger, TriggerContext } from '../triggers'
import { setContext } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import type { TriggersCount } from '$lib/gen'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
export let isOperator: boolean = false
export let flow_json: any | undefined = undefined
export let selected: string
export let triggersCount: Writable<TriggersCount | undefined>
let mobileTab: 'form' | 'detail' = 'form'
let clientWidth = window.innerWidth
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const selectedTriggerStore = writable<
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
| 'kafka'
| 'nats'
| 'mqtt'
| 'sqs'
| 'gcp'
>('webhooks')
const simplifiedPoll = writable(false)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
defaultValues: writable(undefined),
captureOn: writable(undefined),
showCaptureHint: writable(undefined)
})
</script>
<main class="h-screen w-full" bind:clientWidth>
@@ -23,11 +57,28 @@
<slot name="form" />
</Pane>
<Pane size={35} minSize={15}>
<DetailPageDetailPanel bind:selected {isOperator} {flow_json}>
<DetailPageDetailPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
bind:selected
{isOperator}
{flow_json}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
<slot slot="script" name="script" />
<slot slot="save_inputs" name="save_inputs" />
<slot slot="flow_step" name="flow_step" />
<slot slot="triggers" name="triggers" />
</DetailPageDetailPanel>
</Pane>
</Splitpanes>
@@ -57,8 +108,25 @@
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full mt-[-2px]">
<slot name="triggers" />
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="script" name="script" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
</TabContent>
<TabContent value="script" class="flex flex-col flex-1 h-full">
<slot name="script" />
@@ -1,70 +0,0 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import { workspaceStore } from '$lib/stores'
import { base32 } from 'rfc4648'
import ClipboardPanel from './ClipboardPanel.svelte'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import { fade } from 'svelte/transition'
interface Props {
isFlow?: boolean
path: string
emailDomain?: string | null
captureInfo?: CaptureInfo | undefined
hasPreprocessor?: boolean
captureLoading?: boolean
}
let {
isFlow = false,
path,
emailDomain = null,
captureInfo = undefined,
hasPreprocessor = false,
captureLoading = false
}: Props = $props()
function getCaptureEmail() {
const cleanedPath = path.replaceAll('/', '.')
const plainPrefix = `capture+${$workspaceStore}+${(isFlow ? 'flow.' : '') + cleanedPath}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${encodedPrefix}@${emailDomain}`
}
let captureEmail = $derived(getCaptureEmail())
</script>
{#if captureInfo}
<CaptureSection
captureType="email"
disabled={false}
{captureInfo}
{captureLoading}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Send an email to the test address below to simulate an email trigger.
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to email events on this test address.
</p>
{/if}
{/snippet}
<Label label="Test email address" disabled={!captureInfo.active}>
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
@@ -10,6 +10,8 @@
import ClipboardPanel from './ClipboardPanel.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { base32 } from 'rfc4648'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import CaptureTable from '../triggers/CaptureTable.svelte'
export let token: string = ''
export let isFlow: boolean = false
@@ -17,9 +19,25 @@
export let path: string
export let userSettings: any
export let emailDomain: string | null = null
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
let requestType: 'hash' | 'path' = 'path'
function getCaptureEmail() {
const cleanedPath = path.replaceAll('/', '.')
const plainPrefix = `capture+${$workspaceStore}+${(isFlow ? 'flow.' : '') + cleanedPath}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${encodedPrefix}@${emailDomain}`
}
$: captureEmail = getCaptureEmail()
function emailAddress(
requestType: 'hash' | 'path',
path: string,
@@ -45,6 +63,23 @@
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
bind:captureTable
captureType="email"
disabled={false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
>
<Label label="Email address">
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
<div class="flex flex-col gap-4">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
@@ -7,20 +7,20 @@
import { SettingService } from '$lib/gen'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import TriggerTokens from '../triggers/TriggerTokens.svelte'
import TriggersEditorSection from '../triggers/TriggersEditorSection.svelte'
import Description from '../Description.svelte'
import Section from '../Section.svelte'
import { createEventDispatcher } from 'svelte'
import EmailTriggerConfigSection from './EmailTriggerConfigSection.svelte'
let userSettings: UserSettings
const dispatch = createEventDispatcher()
export let token: string
export let scopes: string[] = []
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean = false
let emailDomain: string | null = null
let triggerTokens: TriggerTokens | undefined = undefined
@@ -35,8 +35,6 @@
}
getEmailDomain()
$: emailDomain && dispatch('email-domain', emailDomain)
</script>
<HighlightTheme />
@@ -52,7 +50,7 @@
{scopes}
/>
<Section label="Email trigger" class="flex flex-col gap-4">
<div class="flex flex-col w-full gap-4">
<Description link="https://www.windmill.dev/docs/advanced/email_triggers">
Email triggers execute scripts and flows when emails are sent to specific addresses. Each
trigger has its own unique email address that can be used to invoke the script or flow.
@@ -61,7 +59,23 @@
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
<EmailTriggerConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
<TriggersEditorSection
cloudDisabled={false}
triggerType="email"
{isFlow}
noSave
data={{ emailDomain, userSettings, token, hash, path }}
{isEditor}
{path}
{canHavePreprocessor}
{hasPreprocessor}
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
alwaysOpened={true}
/>
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
@@ -81,4 +95,4 @@
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="email" />
{/if}
</Section>
</div>
@@ -13,7 +13,6 @@
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import type { Flow } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
export let loading: boolean
@@ -29,8 +28,6 @@
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
let size = 50
const { currentStepStore: copilotCurrentStepStore } =
@@ -87,7 +84,6 @@
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
/>
{/if}
</Pane>
@@ -1,16 +1,14 @@
<script lang="ts">
import type { FlowModuleValue } from '$lib/gen'
import FlowCardHeader from './FlowCardHeader.svelte'
export let title: string | undefined = undefined
export let summary: string | undefined = undefined
export let noEditor: boolean
export let noHeader = false
export let flowModuleValue: FlowModuleValue | undefined = undefined
</script>
<div class="flex flex-col h-full">
{#if !noEditor && !noHeader}
{#if !noEditor}
<div>
<FlowCardHeader on:setHash on:reload {title} bind:summary {flowModuleValue}>
<slot name="header" />
@@ -6,14 +6,13 @@
import FlowInput from './FlowInput.svelte'
import FlowFailureModule from './FlowFailureModule.svelte'
import FlowConstants from './FlowConstants.svelte'
import TriggersEditor from '../../triggers/TriggersEditor.svelte'
import type { FlowModule, Flow } from '$lib/gen'
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import FlowPreprocessorModule from './FlowPreprocessorModule.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { insertNewPreprocessorModule } from '../flowStateUtils'
import TriggersEditor from '../../triggers/TriggersEditor.svelte'
import { handleSelectTriggerFromKind, type Trigger } from '$lib/components/triggers/utils'
export let noEditor = false
export let enableAi = false
@@ -24,7 +23,7 @@
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
const {
selectedId,
flowStore,
@@ -37,7 +36,7 @@
flowInputEditorState
} = getContext<FlowEditorContext>('FlowEditorContext')
const { showCaptureHint, triggersState, triggersCount } =
const { selectedTrigger, defaultValues, captureOn, showCaptureHint } =
getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
let seenModules: string[] = []
@@ -83,7 +82,9 @@
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
$selectedId = 'triggers'
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
selectedTrigger.set(ev.detail.kind)
defaultValues.set(ev.detail.config)
captureOn.set(true)
showCaptureHint.set(true)
}}
on:applyArgs
@@ -117,18 +118,16 @@
}
}}
on:testWithArgs
args={$previewArgs}
currentPath={$pathStore}
initialPath={$initialPathStore}
{fakeInitialPath}
schema={$flowStore.schema}
{noEditor}
newItem={newFlow}
isFlow={true}
hasPreprocessor={!!$flowStore.value.preprocessor_module}
canHavePreprocessor={true}
args={$previewArgs}
isDeployed={savedFlow && !savedFlow?.draft_only}
schema={$flowStore.schema}
{onDeployTrigger}
/>
{:else if $selectedId.startsWith('subflow:')}
<div class="p-4"
@@ -21,7 +21,6 @@
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import type { TriggerContext } from '$lib/components/triggers'
import { formatCron } from '$lib/utils'
export let flowModule: FlowModule
export let noEditor: boolean = false
@@ -31,7 +30,7 @@
const { selectedId, flowStateStore, flowInputsStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { primarySchedule } = getContext<TriggerContext>('TriggerContext')
let scriptKind: 'script' | 'trigger' | 'approval' = 'script'
let scriptTemplate: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' = 'script'
@@ -43,33 +42,19 @@
export let previousModule: FlowModule | undefined = undefined
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
const primaryIndex = triggersState.triggers.findIndex((t) => t.isPrimary)
if (primaryIndex === -1) {
const primaryCfg = {
if (!$primarySchedule) {
$primarySchedule = {
summary: 'Scheduled poll of flow',
args: {},
schedule: formatCron('0 */15 * * *'),
cron: '0 */15 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true,
is_flow: true
}
triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, primaryCfg)
} else if (triggersState.triggers[primaryIndex].draftConfig) {
//If there is a primary schedule draft update it
const newCfg = { ...triggersState.triggers[primaryIndex].draftConfig }
let updated = false
if (!newCfg.schedule) {
newCfg.schedule = formatCron('0 */15 * * *')
updated = true
}
if (!newCfg.enabled) {
newCfg.enabled = true
updated = true
}
if (updated) {
triggersState.triggers[primaryIndex].draftConfig = newCfg
enabled: true
}
}
if (!$primarySchedule.cron) {
$primarySchedule.cron = '0 */15 * * *'
}
$primarySchedule.enabled = true
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
@@ -1,8 +1,7 @@
<script lang="ts">
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import type { ScheduleTrigger, TriggerContext } from '$lib/components/triggers'
import { FlowService, type Flow, type TriggersCount } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { setContext } from 'svelte'
@@ -13,12 +12,19 @@
let flow: Flow | undefined = undefined
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
primarySchedule: primaryScheduleStore,
selectedTrigger: selectedTriggerStore,
triggersCount: triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
defaultValues: writable(undefined),
captureOn: writable(undefined),
showCaptureHint: writable(undefined)
})
async function loadFlow(path: string) {
@@ -60,7 +60,7 @@
flowInputsStore,
pathStore
} = getContext<FlowEditorContext>('FlowEditorContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
async function insertNewModuleAtIndex(
@@ -433,7 +433,7 @@
undefined
)
setExpr(detail.modules[index + 1], `results.${id}`)
setScheduledPollSchedule(triggersState, triggersCount)
setScheduledPollSchedule(primarySchedule, triggersCount)
}
if (`flow` in detail) {
@@ -1,10 +1,14 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { Cross } from 'lucide-svelte'
import InsertModuleInner from './InsertModuleInner.svelte'
import StepGenQuick from '$lib/components/copilot/StepGenQuick.svelte'
import FlowInputsQuick from '../content/FlowInputsQuick.svelte'
import type { FlowModule } from '$lib/gen'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import { twMerge } from 'tailwind-merge'
import type { ComputeConfig } from 'svelte-floating-ui'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
import PopupV2 from '$lib/components/common/popup/PopupV2.svelte'
import { flip, offset } from 'svelte-floating-ui/dom'
import { SchedulePollIcon } from '$lib/components/icons'
@@ -12,9 +16,13 @@
// import type { Writable } from 'svelte/store'
const dispatch = createEventDispatcher()
export let stop = false
export let index: number = 0
export let funcDesc = ''
export let modules: FlowModule[] = []
export let disableAi = false
export let kind: 'script' | 'trigger' | 'preprocessor' | 'failure' = 'script'
export let allowTrigger = true
export let iconSize = 12
type Alignment = 'start' | 'end' | 'center'
@@ -31,8 +39,19 @@
autoUpdate: true
}
$: !open && (funcDesc = '')
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' = kind
let preFilter: 'all' | 'workspace' | 'hub' = 'all'
let loading = false
let small = false
let open = false
let width = 0
let height = 0
$: displayPath = width > 650 || height > 400
$: small = kind === 'preprocessor' || kind === 'failure'
</script>
<!-- <Menu transitionDuration={0} pointerDown bind:show={open} noMinW {placement} let:close> -->
@@ -74,5 +93,129 @@ shouldUsePortal={true} -->
{/if}
</button>
</svelte:fragment>
<InsertModuleInner on:close={() => close(null)} on:insert on:new on:pickFlow on:pickScript />
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small
? 'w-[450px]'
: 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5 resize overflow-auto {small
? 'min-w-[450px]'
: 'min-w-[650px]'} min-h-[400px]"
on:wheel={(e) => {
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
<StepGenQuick
on:escape={() => close(null)}
{disableAi}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
</div>
<div class="flex flex-row grow min-h-0">
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary">
<TopLevelNode
label="Action"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode
label="Trigger"
selected={selectedKind === 'trigger'}
on:select={() => {
selectedKind = 'trigger'
}}
/>
{/if}
<TopLevelNode
label="Approval/Prompt"
selected={selectedKind === 'approval'}
on:select={() => {
selectedKind = 'approval'
}}
/>
{#if customUi?.flowNode != false}
<TopLevelNode
label="Flow"
selected={selectedKind === 'flow'}
on:select={() => {
selectedKind = 'flow'
}}
/>
{/if}
{#if stop}
<TopLevelNode
label="End flow"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{/if}
<TopLevelNode
label="For loop"
on:select={() => {
close(null)
dispatch('new', { kind: 'forloop' })
}}
/>
<TopLevelNode
label="While loop"
on:select={() => {
close(null)
dispatch('new', { kind: 'whileloop' })
}}
/>
<TopLevelNode
label="Branch to one"
on:select={() => {
close(null)
dispatch('new', { kind: 'branchone' })
}}
/>
<TopLevelNode
label="Branch to all"
on:select={() => {
close(null)
dispatch('new', { kind: 'branchall' })
}}
/>
</div>
{/if}
<FlowInputsQuick
{selectedKind}
bind:loading
filter={funcDesc}
{modules}
{index}
{disableAi}
{funcDesc}
{kind}
on:close={() => {
close(null)
}}
on:new
on:pickScript
on:pickFlow
{preFilter}
{small}
{displayPath}
/>
</div>
</div>
</PopupV2>
@@ -1,167 +0,0 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import StepGenQuick from '$lib/components/copilot/StepGenQuick.svelte'
import FlowInputsQuick from '../content/FlowInputsQuick.svelte'
import type { FlowModule } from '$lib/gen'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
// import type { Writable } from 'svelte/store'
const dispatch = createEventDispatcher()
export let stop = false
export let index: number = 0
export let funcDesc = ''
export let modules: FlowModule[] = []
export let disableAi = false
export let kind: 'script' | 'trigger' | 'preprocessor' | 'failure' = 'script'
export let allowTrigger = true
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' = kind
let preFilter: 'all' | 'workspace' | 'hub' = 'all'
let loading = false
let small = false
let width = 0
let height = 0
$: displayPath = width > 650 || height > 400
$: small = kind === 'preprocessor' || kind === 'failure'
</script>
<!-- <Menu transitionDuration={0} pointerDown bind:show={open} noMinW {placement} let:close> -->
<!-- {floatingConfig}
floatingClasses="mt-2"
containerClasses="border rounded-lg shadow-lg bg-surface"
noTransition
shouldUsePortal={true} -->
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small
? 'w-[450px]'
: 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5 resize overflow-auto {small
? 'min-w-[450px]'
: 'min-w-[650px]'} min-h-[400px]"
on:wheel={(e) => {
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
<StepGenQuick
on:escape={() => dispatch('close')}
{disableAi}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
</div>
<div class="flex flex-row grow min-h-0">
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary">
<TopLevelNode
label="Action"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode
label="Trigger"
selected={selectedKind === 'trigger'}
on:select={() => {
selectedKind = 'trigger'
}}
/>
{/if}
<TopLevelNode
label="Approval/Prompt"
selected={selectedKind === 'approval'}
on:select={() => {
selectedKind = 'approval'
}}
/>
{#if customUi?.flowNode != false}
<TopLevelNode
label="Flow"
selected={selectedKind === 'flow'}
on:select={() => {
selectedKind = 'flow'
}}
/>
{/if}
{#if stop}
<TopLevelNode
label="End flow"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{/if}
<TopLevelNode
label="For loop"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'forloop' })
}}
/>
<TopLevelNode
label="While loop"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'whileloop' })
}}
/>
<TopLevelNode
label="Branch to one"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'branchone' })
}}
/>
<TopLevelNode
label="Branch to all"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'branchall' })
}}
/>
</div>
{/if}
<FlowInputsQuick
{selectedKind}
bind:loading
filter={funcDesc}
{modules}
{index}
{disableAi}
{funcDesc}
{kind}
on:close={() => {
dispatch('close')
}}
on:new
on:pickScript
on:pickFlow
{preFilter}
{small}
{displayPath}
/>
</div>
</div>
@@ -70,7 +70,6 @@
return undefined
}
}
$: infiniteList && !noHistory && initLoadInputs()
function handleSelect(e: CustomEvent) {
@@ -1,7 +1,6 @@
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'
@@ -39,8 +38,7 @@ export async function loadSchedules(
initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined>,
workspace: string,
triggersCount: Writable<TriggersCount | undefined>,
loadPrimarySchedule: boolean = false,
isDeployed: Writable<boolean | undefined> = writable(undefined)
loadPrimarySchedule: boolean = false
) {
if (!path || path == '') {
schedules.set([])
@@ -55,9 +53,6 @@ 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)
@@ -69,7 +64,7 @@ export async function loadSchedules(
cron: primary.schedule,
timezone: primary.timezone,
enabled: primary.enabled
}
}
: false
}
primarySchedule.update((ps) => (ps === undefined || forceRefresh ? remotePrimarySchedule : ps))
@@ -142,58 +137,3 @@ export async function saveSchedule(
}
}
}
export async function saveScheduleFromCfg(
scheduleCfg: Record<string, any>,
edit: boolean,
workspace: string
): Promise<boolean> {
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
}
}
@@ -2,15 +2,13 @@
import NodeWrapper from './NodeWrapper.svelte'
import TriggersWrapper from '../triggers/TriggersWrapper.svelte'
import { type GraphEventHandlers, type SimplifiableFlow } from '../../graphBuilder'
import type { FlowModule, TriggersCount } from '$lib/gen'
import type { FlowModule } 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
@@ -28,24 +26,11 @@
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('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
}
}
const { primarySchedule, triggersCount, selectedTrigger } =
getContext<TriggerContext>('TriggerContext')
</script>
<NodeWrapper wrapperClass="shadow-md rounded-sm" let:darkMode>
<NodeWrapper wrapperClass="shadow-md" let:darkMode>
{#if data.simplifiableFlow?.simplifiedFlow != true}
<TriggersWrapper
disableAi={data.disableAi}
@@ -53,7 +38,6 @@
path={data.path}
bgColor={getStateColor(undefined, darkMode)}
bgHoverColor={getStateHoverColor(undefined, darkMode)}
showDraft={data.isEditor ?? false}
on:new={(e) => {
data?.eventHandlers.insert({
modules: data.modules,
@@ -73,24 +57,14 @@
data?.eventHandlers?.simplifyFlow(true)
}}
on:openScheduledPoll={(e) => {
const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)
triggersState.selectedTriggerIndex = primarySchedule
$selectedTrigger = 'scheduledPoll'
}}
on:select={() => data?.eventHandlers?.select('triggers')}
onSelect={async (triggerIndex: number) => {
on:select={(e) => {
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}
@@ -106,23 +80,22 @@
data?.eventHandlers?.select(e.detail)
}}
>
{#if triggersState.triggers.some((t) => t.isPrimary) || $triggersCount?.primary_schedule}
{@const { enabled, schedule } = getScheduleCfg(
triggersState.triggers.find((t) => t.isPrimary),
$triggersCount
)}
{#if $primarySchedule || ($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)}
<div class="text-2xs text-primary p-2 flex gap-2 items-center">
<Calendar size={12} />
<div>
Schedule every {schedule}
{enabled ? '' : ' (disabled)'}
Schedule every {$primarySchedule?.cron ?? $triggersCount?.primary_schedule?.schedule}
{$primarySchedule?.enabled ||
($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)
? ''
: ' (disabled)'}
</div>
</div>
{:else}
<button
class="px-2 py-1 hover:bg-surface-inverse w-full hover:text-primary-inverse"
on:click={() => {
setScheduledPollSchedule(triggersState, triggersCount)
setScheduledPollSchedule(primarySchedule, triggersCount)
}}
>
Set primary schedule
@@ -1,313 +1,110 @@
<script lang="ts">
import { Calendar, Mail, Webhook, Unplug, Database, Terminal } from 'lucide-svelte'
import { Loader2 } from 'lucide-svelte'
import { type ComponentType } from 'svelte'
import { Calendar, Mail, Webhook, Unplug, Database, PlugZap } from 'lucide-svelte'
import TriggerButton from './TriggerButton.svelte'
import Popover from '$lib/components/Popover.svelte'
import TriggerCount from './TriggerCount.svelte'
import { createEventDispatcher, onMount, type ComponentType } from 'svelte'
import { Route } from 'lucide-svelte'
import { getContext } from 'svelte'
import { type TriggerContext } from '$lib/components/triggers'
import { enterpriseLicense } from '$lib/stores'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon, GoogleCloudIcon } from '$lib/components/icons'
import { type Trigger, type TriggerType } from '$lib/components/triggers/utils'
import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { twMerge } from 'tailwind-merge'
import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { FlowService, ScriptService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon } from '$lib/components/icons'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { selectedTrigger, triggersCount } = getContext<TriggerContext>('TriggerContext')
interface Props {
path: string
newItem: boolean
isFlow: boolean
selected: boolean
showOnlyWithCount: boolean
numberOfTriggers?: number
small?: boolean
vertical?: boolean
limit?: number
showDraft?: boolean
onSelect?: (triggerIndex: number) => void
export let path: string
export let newItem: boolean
export let isFlow: boolean
export let selected: boolean
export let showOnlyWithCount: boolean
export let triggersToDisplay: (
| 'webhooks'
| 'schedules'
| 'routes'
| 'websockets'
| 'kafka'
| 'nats'
| 'mqtt'
| 'emails'
| 'eventStreams'
| 'postgres'
| 'sqs'
| 'gcp'
)[] = showOnlyWithCount
? ['webhooks', 'schedules', 'routes', 'websockets', 'kafka', 'nats', 'emails']
: ['webhooks', 'schedules', 'routes', 'websockets', 'eventStreams', 'emails']
const dispatch = createEventDispatcher()
onMount(() => {
if (!newItem) {
loadCount()
}
})
async function loadCount() {
if (isFlow) {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path
})
} else {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path
})
}
}
let {
selected,
showOnlyWithCount,
// @ts-ignore - This is an output-only prop used with bind:
numberOfTriggers = $bindable(0),
small = true,
vertical = false,
limit,
showDraft = true,
onSelect
}: Props = $props()
let menuOpen = $state(false)
const triggerTypeConfig: {
[key in TriggerType]: { icon: ComponentType; countKey?: string; disabled?: boolean }
[key: string]: { icon: ComponentType; countKey?: string }
} = {
webhook: { icon: Webhook, countKey: 'webhook_count' },
schedule: { icon: Calendar, countKey: 'schedule_count' },
http: { icon: Route, countKey: 'http_routes_count' },
websocket: { icon: Unplug, countKey: 'websocket_count' },
webhooks: { icon: Webhook, countKey: 'webhook_count' },
schedules: { icon: Calendar, countKey: 'schedule_count' },
routes: { icon: Route, countKey: 'http_routes_count' },
websockets: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
email: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense },
poll: { icon: SchedulePollIcon },
cli: { icon: Terminal }
kafka: { icon: KafkaIcon, countKey: 'kafka_count' },
emails: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count' },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count' },
sqs: { icon: AwsIcon, countKey: 'sqs_count' },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count' },
eventStreams: { icon: PlugZap }
}
const allTypes = [
'webhook',
'schedule',
'http',
'websocket',
'postgres',
'kafka',
'email',
'nats',
'mqtt',
'sqs',
'gcp',
'poll',
'cli'
]
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1')
return result.charAt(0).toUpperCase() + result.slice(1).toLowerCase()
}
const itemClass = twMerge(
'text-secondary text-left font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary flex flex-row items-center flex-nowrap'
)
// Group triggers by their mapped type
let triggersGrouped = $derived.by(() => {
const triggersWithIndex = triggersState.triggers.map((trigger, index) => ({
...trigger,
index
}))
const triggersFiltered = showDraft
? triggersWithIndex
: triggersWithIndex.filter((trigger) => !trigger.isDraft)
return triggersFiltered.reduce(
(acc, trigger) => {
const configType = trigger.type
if (!acc[configType]) {
acc[configType] = []
}
acc[configType].push(trigger)
return acc
},
{} as Record<TriggerType, Trigger[]>
)
})
const noTriggers = $derived(triggersState.triggers.length === 0)
// Extract unique trigger types for display, only keep the first
let allTriggerTypes = $derived.by(() => {
const types = !noTriggers ? (Object.keys(triggersGrouped) as TriggerType[]) : allTypes
//filter out types if showOnlyTriggersWithCount is true and there are no triggers for that type
return types.filter(
(type) =>
(!showOnlyTriggersWithCount ||
((triggerTypeConfig[type].countKey &&
($triggersCount?.[triggerTypeConfig[type].countKey] ?? 0)) ||
0) > 0) &&
!triggerTypeConfig[type].disabled
)
})
let triggersToDisplay = $derived(limit ? allTriggerTypes.slice(0, limit) : allTriggerTypes)
let extraTriggers = $derived(
limit && allTriggerTypes.length > limit
? allTriggerTypes.slice(limit).flatMap((type) => triggersGrouped[type])
: []
)
// Fallback for when there are no triggers in the store but we have the types
let extraTriggersType = $derived(
limit && allTriggerTypes.length > limit ? allTriggerTypes.slice(limit) : []
)
let showOnlyTriggersWithCount = $derived(showOnlyWithCount || triggersState.triggers.length === 0)
$effect(() => {
if (allTriggerTypes) {
numberOfTriggers = allTriggerTypes?.length
}
})
</script>
<Menubar
class={twMerge(
'items-center justify-center',
vertical ? 'flex flex-col gap-2' : 'flex flex-row gap-1'
)}
>
{#snippet children({ createMenu })}
{#each triggersToDisplay as type}
{@const isSelected =
selected && triggersState.selectedTrigger && triggersState.selectedTrigger?.type === type}
{@const singleItem =
type === 'webhook' ||
type === 'email' ||
type === 'cli' ||
(triggersGrouped[type] && triggersGrouped[type].length === 1)}
<Tooltip
disablePopup={menuOpen}
placement={vertical ? 'right' : 'bottom'}
on:click={(e) => e.stopPropagation()}
{#each triggersToDisplay as type}
{@const { icon, countKey } = triggerTypeConfig[type]}
{#if (!showOnlyWithCount || ((countKey && $triggersCount?.[countKey]) || 0) > 0) && !(type === 'gcp' && !$enterpriseLicense) && !(type === 'sqs' && !$enterpriseLicense) && !(type === 'kafka' && !$enterpriseLicense) && !(type === 'nats' && !$enterpriseLicense) && !(type === 'mqtt')}
<Popover>
<svelte:fragment slot="text">{camelCaseToWords(type)}</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = type === 'eventStreams' ? 'kafka' : type
dispatch('select')
}}
selected={selected &&
($selectedTrigger === type ||
(type === 'eventStreams' &&
($selectedTrigger === 'kafka' ||
$selectedTrigger === 'nats' ||
$selectedTrigger === 'sqs' ||
$selectedTrigger === 'mqtt' ||
$selectedTrigger === 'gcp')))}
>
{#snippet text()}
{camelCaseToWords(type)}
{/snippet}
{#if singleItem}
{@render triggerButton({ type, isSelected, singleItem })}
{:else}
<Menu
{createMenu}
usePointerDownOutside
placement={vertical ? 'right-start' : 'bottom'}
menuClass={'max-w-56'}
class="h-fit"
bind:open={menuOpen}
disabled={!triggersGrouped[type]}
>
{#snippet trigger({ trigger })}
{@render triggerButton({
type,
isSelected,
meltElement: trigger
})}
{/snippet}
{#snippet children({ item })}
{#if triggersGrouped[type] && triggersGrouped[type].length > 0}
{#each triggersGrouped[type] as trigger}
{@render triggerItem({ triggerIndex: trigger.index, item })}
{/each}
{:else}
<div class="text-xs text-gray-400 p-2">No {camelCaseToWords(type)} triggers</div>
{/if}
{/snippet}
</Menu>
{#if countKey}
<TriggerCount count={$triggersCount?.[countKey]} />
{/if}
</Tooltip>
{/each}
{#if extraTriggers.length > 0}
<Menu
{createMenu}
usePointerDownOutside
placement="bottom"
menuClass={'w-56'}
class="h-fit center-center mr-1"
bind:open={menuOpen}
>
{#snippet trigger({ trigger })}
<MeltButton
class="w-[23px] h-[23px] rounded-md center-center text-[12px] hover:bg-slate-300 transition-all duration-100 font-normal text-secondary hover:text-primary"
meltElement={trigger}
>
+{extraTriggers.length}
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#if extraTriggers.length > 0}
{#each extraTriggers as trigger}
{@render triggerItem({ triggerIndex: trigger.index, item })}
{/each}
{:else if extraTriggersType.length > 0}
{#each extraTriggersType as type}
{@render simpleTriggerItem({ item, type })}
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
{#snippet triggerButton({ type, isSelected, meltElement = undefined, singleItem = false })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
<MeltButton
class={twMerge(
'hover:bg-surface-hover rounded-md shadow-sm text-xs relative center-center cursor-pointer bg-slate-100 dark:bg-slate-700',
'dark:outline dark:outline-1 outline-tertiary/20 group',
isSelected ? 'outline-tertiary outline' : '',
small ? 'w-[23px] h-[23px] outline-[1.5px]' : 'p-2 outline-[2px]'
)}
on:click={(e) => {
e.stopPropagation()
e.preventDefault()
if (singleItem) {
onSelect?.(triggersGrouped[type][0].index)
}
}}
{meltElement}
>
{#if countKey}
{@const count = $triggersCount?.[countKey] ?? 0}
{#if count > 0}
<div
class={twMerge(
// Base styles that apply in all cases
'absolute z-10 rounded-full overflow-hidden',
'flex center-center text-primary-inverse font-mono',
'bg-tertiary/50 group-hover:bg-primary transition-all duration-[100ms]',
noTriggers ? 'bg-primary' : '',
// Size variants based on small prop
small
? '-right-[3px] -top-[3px] h-3 w-3 text-[8px]'
: '-right-1.5 -top-1.5 h-4 w-4 text-xs',
// Special case for no triggers
noTriggers && small ? 'h-3 w-3 text-[8px] -right-0.5 -top-0.5' : '',
noTriggers && !small ? 'h-4 w-4 text-xs -right-1 -top-1' : ''
)}
>
{#if count === undefined}
<Loader2 class="animate-spin text-2xs" />
{:else}
<p>{count}</p>
{/if}
</div>
{/if}
{/if}
<SvelteComponent size={small ? 12 : 14} />
</MeltButton>
{/snippet}
{#snippet triggerItem({ triggerIndex, item })}
<MenuItem
{item}
class={itemClass}
on:click={() => {
onSelect?.(triggerIndex)
}}
>
<TriggerLabel trigger={triggersState.triggers[triggerIndex]} />
</MenuItem>
{/snippet}
{#snippet simpleTriggerItem({ item, type })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
<MenuItem {item} class={itemClass}>
<div class="flex flex-row items-center gap-2">
<SvelteComponent size={14} />
{camelCaseToWords(type)}
{#if countKey}
<div class="text-xs text-gray-400">
{$triggersCount?.[countKey] ?? 0}
</div>
{/if}
</div>
</MenuItem>
{/snippet}
<svelte:component this={icon} size={12} />
</TriggerButton>
</Popover>
{/if}
{/each}
@@ -1,124 +1,59 @@
<script lang="ts">
import { NODE } from '../../util'
import { createEventDispatcher } from 'svelte'
import type { TriggerType } from '$lib/components/triggers/utils'
import TriggersBadge from './TriggersBadge.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import type { FlowModule } from '$lib/gen'
import { Plus } from 'lucide-svelte'
import InsertModuleInner from '$lib/components/flows/map/InsertModuleInner.svelte'
import AddTriggersButton from '$lib/components/triggers/AddTriggersButton.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
path: string
newItem: boolean
selected: boolean
isEditor?: boolean
disableAi?: boolean
modules?: FlowModule[]
bgColor: string
bgHoverColor?: string
showDraft?: boolean
onSelect?: (triggerIndex: number) => void
onAddDraftTrigger?: (type: TriggerType) => void
}
let {
path,
newItem,
selected,
isEditor = false,
disableAi = false,
modules = [],
bgColor,
bgHoverColor = '',
showDraft,
onSelect,
onAddDraftTrigger
}: Props = $props()
let showTriggerScriptPicker = $state(false)
let numberOfTriggers = $state(0)
export let path: string
export let newItem: boolean
export let selected: boolean
export let isEditor: boolean = false
export let disableAi: boolean = false
export let modules: FlowModule[] = []
export let bgColor: string
export let bgHoverColor: string = ''
const dispatch = createEventDispatcher()
let hover = $state(false)
let addTriggersButton = $state<AddTriggersButton | undefined>(undefined)
let hover = false
</script>
<div style={`width: ${NODE.width}px;`}>
<button
style="background-color: {hover && bgHoverColor ? bgHoverColor : bgColor};"
class="relative flex w-full flex-row gap-1.5 px-2 p-1 items-center justify-center rounded-sm {selected
? 'outline outline-2 outline-gray-600 dark:bg-white/5 dark:outline-gray-400'
class="flex w-full flex-row gap-1 px-2 p-1 items-center {selected
? 'outline outline-2 outline-gray-600 rounded-sm dark:bg-white/5 dark:outline-gray-400'
: ''}"
onclick={() => {
on:pointerdown={() => {
dispatch('select')
}}
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
>
<div
class={twMerge(
'flex flex-row items-center text-2xs font-normal',
numberOfTriggers > 6 ? 'absolute left-0 -top-[20px]' : ''
)}
>
Triggers
<div class="flex flex-col mr-1 ml-1">
<div class="flex flex-row items-center text-2xs font-normal"> Triggers </div>
</div>
<TriggersBadge
showOnlyWithCount={false}
{showDraft}
{path}
{newItem}
isFlow
{selected}
bind:numberOfTriggers
limit={isEditor ? 7 : 8}
{onSelect}
/>
<TriggersBadge showOnlyWithCount={false} {path} {newItem} isFlow {selected} on:select />
{#if isEditor}
<AddTriggersButton
bind:this={addTriggersButton}
onAddScheduledPoll={() => {
showTriggerScriptPicker = true
<InsertModuleButton
{disableAi}
on:new
on:pickScript
on:select
on:open={() => {
dispatch('openScheduledPoll')
}}
class="w-fit h-fit"
triggerScriptPicker={showTriggerScriptPicker ? triggerScriptPicker : undefined}
onClose={() => {
showTriggerScriptPicker = false
}}
isEditor
{onAddDraftTrigger}
>
<button
class="hover:bg-slate-300 dark:hover:bg-slate-600 rounded-md outline-1 outline-dashed outline-secondary outline-offset-[-1px] text-xs w-[23px] h-[23px] relative center-center cursor-pointer text-secondary"
>
<Plus size={12} />
</button>
</AddTriggersButton>
kind="trigger"
index={0}
{modules}
class={twMerge(
'hover:bg-surface-hover rounded-md border text-xs w-[23px] h-[23px] relative center-center cursor-pointer bg-surface outline-0'
)}
/>
{/if}
</button>
</div>
{#snippet triggerScriptPicker()}
<div class="border rounded-lg shadow-lg bg-surface z5000">
<InsertModuleInner
{disableAi}
on:new
on:pickScript
on:select
on:open={() => {
dispatch('openScheduledPoll')
}}
on:close={() => {
addTriggersButton?.close()
}}
kind="trigger"
index={0}
{modules}
/>
</div>
{/snippet}
@@ -1,10 +1,11 @@
<script lang="ts">
export let size: number = 16
export let height = '20px'
export let width = '20px'
</script>
<svg
width={size}
height={size}
{width}
{height}
viewBox="0 -25 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
+9 -5
View File
@@ -1,11 +1,15 @@
import { JobService, type RunScriptPreviewData } from '$lib/gen'
import { JobService, type RunScriptByPathData, type RunScriptPreviewData } from '$lib/gen'
export async function runPreviewJobAndPollResult(
data: RunScriptPreviewData,
function isRunScriptByPathData(arg:RunScriptPreviewData | RunScriptByPathData): arg is RunScriptByPathData {
return (arg as RunScriptByPathData).path !== undefined;
}
export async function runScriptAndPollResult(
data: RunScriptPreviewData | RunScriptByPathData,
{ maxRetries = 7, withJobData }: { maxRetries?: number; withJobData?: boolean } = {}
): Promise<unknown> {
const uuid = await JobService.runScriptPreview(data)
const uuid = (isRunScriptByPathData(data) ? await JobService.runScriptByPath(data) : await JobService.runScriptPreview(data)) as string
let attempts = 0
while (attempts < maxRetries) {
try {
@@ -1,21 +1,12 @@
<script lang="ts">
import { type AnyMeltElement } from '@melt-ui/svelte'
import { conditionalMelt } from '$lib/utils'
export let meltElement: AnyMeltElement | undefined = undefined
export let meltElement: AnyMeltElement
export let type: 'button' | 'submit' | 'reset' | null | undefined = undefined
export let title: string = ''
export let id: string | undefined = undefined
</script>
<button
use:conditionalMelt={meltElement}
class={$$props.class}
{type}
{title}
{id}
{...$meltElement}
on:click
>
<button use:meltElement class={$$props.class} {type} {title} {id} {...$meltElement}>
<slot />
</button>
@@ -15,8 +15,6 @@
export let createMenu: MenubarBuilders['createMenu']
export let invisible: boolean = false
export let usePointerDownOutside: boolean = false
export let menuClass: string = ''
export let open = false
// Use the passed createMenu function
const menu = createMenu({
@@ -34,6 +32,8 @@
states
} = menu
let open = false
const sync = createSync(states)
$: sync.open(open, (v) => (open = Boolean(v)))
@@ -76,13 +76,12 @@
class={twMerge(
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto',
lightMode ? 'bg-surface-inverse' : 'bg-surface',
invisible ? 'opacity-0' : '',
menuClass
invisible ? 'opacity-0' : ''
)}
on:click
>
<div class="py-1" style="max-height: {maxHeight}px; ">
<slot {item} {open} />
<slot {item} />
</div>
</div>
{/if}
@@ -1,11 +1,12 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import type { Placement } from '@floating-ui/core'
import { InfoIcon } from 'lucide-svelte'
import { ExternalLink, InfoIcon } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { zIndexes } from '$lib/zIndexes'
import { createTooltip, melt } from '@melt-ui/svelte'
import { fade } from 'svelte/transition'
import TooltipInner from '../TooltipInner.svelte'
export let light = false
export let placement: Placement | undefined = 'bottom'
@@ -17,6 +18,8 @@
export let closeDelay: number = 0
export let portal: string | undefined | null = 'body'
const plugins = [gfmPlugin()]
const {
elements: { trigger, content },
states: { open }
@@ -46,9 +49,27 @@
{/if}
{#if $open && !disablePopup}
<div use:melt={$content} transition:fade={{ duration: 100 }} style="z-index: {zIndexes.tooltip}">
<TooltipInner {documentationLink} {markdownTooltip}>
<div
use:melt={$content}
transition:fade={{ duration: 100 }}
class="shadow max-w-sm break-words py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left"
style="z-index: {zIndexes.tooltip}"
>
{#if markdownTooltip}
<div class="prose-sm">
<Markdown md={markdownTooltip} {plugins} />
</div>
{:else}
<slot name="text" />
</TooltipInner>
{/if}
{#if documentationLink}
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
See documentation
<ExternalLink size="16" />
</div>
</a>
{/if}
</div>
{/if}
@@ -50,7 +50,7 @@
</script>
<Portal name="run-row">
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
</Portal>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -6,7 +6,6 @@
let name: string = ''
export let customName: string | undefined = undefined
export let disabled: boolean = false
const dispatch = createEventDispatcher()
@@ -16,7 +15,7 @@
}
</script>
<Popover closeButton={false} class="w-full" {disabled}>
<Popover closeButton={false} class="w-full">
<svelte:fragment slot="trigger">
<slot name="trigger" />
</svelte:fragment>
@@ -31,7 +30,6 @@
close()
}
}}
{disabled}
/>
<Button
variant="contained"
@@ -42,7 +40,7 @@
addField()
close()
}}
disabled={!name || disabled}
disabled={!name}
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
>
Add {customName ? customName.toLowerCase() : 'field'}
@@ -76,11 +76,7 @@
</script>
<div
class={twMerge(
'h-full',
rounded ? 'rounded-md overflow-hidden' : '',
noBorder ? 'border-0' : 'border'
)}
class={twMerge('h-full', rounded ? 'rounded-md' : '', noBorder ? 'border-0' : 'border')}
bind:clientHeight={tableHeight}
>
<List justify="between" gap="none" hFull={true}>
+21 -21
View File
@@ -1,7 +1,5 @@
import type { CaptureTriggerKind, TriggersCount } from '$lib/gen'
import { type Writable } from 'svelte/store'
import { formatCron } from '$lib/utils'
import { Triggers } from './triggers/triggers.svelte'
import type { Writable } from 'svelte/store'
export type ScheduleTrigger = {
summary: string | undefined
@@ -13,32 +11,34 @@ export type ScheduleTrigger = {
}
export type TriggerContext = {
selectedTrigger: Writable<TriggerKind>
primarySchedule: Writable<ScheduleTrigger | undefined | false>
triggersCount: Writable<TriggersCount | undefined>
simplifiedPoll: Writable<boolean | undefined>
defaultValues: Writable<Record<string, any> | undefined>
captureOn: Writable<boolean | undefined>
showCaptureHint: Writable<boolean | undefined>
triggersState: Triggers
}
export function setScheduledPollSchedule(
triggersState: Triggers,
primarySchedule: Writable<ScheduleTrigger | undefined | false>,
triggersCount: Writable<TriggersCount | undefined>
) {
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
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 }
}
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:
@@ -1,133 +0,0 @@
<script lang="ts">
import { triggerIconMap, type TriggerType } from './utils'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { SchedulePollIcon } from '$lib/components/icons'
import type { Placement } from '@floating-ui/core'
import { isCloudHosted } from '$lib/cloud'
import { CloudOff } from 'lucide-svelte'
interface Props {
setDropdownWidthToButtonWidth?: boolean
children?: import('svelte').Snippet
triggerScriptPicker?: import('svelte').Snippet | undefined
class?: string
placement?: Placement
isEditor?: boolean
onAddDraftTrigger?: (type: TriggerType) => void
onAddScheduledPoll?: () => void
onClose?: () => void
}
let {
setDropdownWidthToButtonWidth = false,
children,
class: className,
triggerScriptPicker,
placement = 'bottom',
isEditor = false,
onAddDraftTrigger,
onAddScheduledPoll,
onClose
}: Props = $props()
let dropdown: DropdownV2 | undefined
const cloudHosted = isCloudHosted()
// Dropdown items for adding new triggers
const addTriggerItems: Item[] = [
{
displayName: 'Schedule',
action: () => onAddDraftTrigger?.('schedule'),
icon: triggerIconMap.schedule
},
{ displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http },
{
displayName: 'WebSocket',
action: () => onAddDraftTrigger?.('websocket'),
icon: triggerIconMap.websocket,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Postgres',
action: () => onAddDraftTrigger?.('postgres'),
icon: triggerIconMap.postgres,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Kafka',
action: () => onAddDraftTrigger?.('kafka'),
icon: triggerIconMap.kafka,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'NATS',
action: () => onAddDraftTrigger?.('nats'),
icon: triggerIconMap.nats,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'MQTT',
action: () => onAddDraftTrigger?.('mqtt'),
icon: triggerIconMap.mqtt,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'SQS',
action: () => onAddDraftTrigger?.('sqs'),
icon: triggerIconMap.sqs,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'GCP Pub/Sub',
action: () => onAddDraftTrigger?.('gcp'),
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Scheduled Poll',
action: (e) => {
e.preventDefault()
onAddDraftTrigger?.('poll')
onAddScheduledPoll?.()
},
icon: SchedulePollIcon,
hidden: !isEditor
}
].filter((item) => !item.hidden)
let triggersButtonWidth = $state(0)
export function close() {
dropdown?.close()
}
</script>
{#snippet extra()}
<p
class="text-xs text-yellow-700 dark:text-yellow-100/90 bg-yellow-50 dark:bg-yellow-900/40 rounded-md p-1 px-2 -my-1"
title="Disabled in multi-tenant cloud"
>
<CloudOff size={14} />
</p>
{/snippet}
<DropdownV2
bind:this={dropdown}
items={addTriggerItems}
{placement}
class={className}
customWidth={setDropdownWidthToButtonWidth ? triggersButtonWidth : undefined}
usePointerDownOutside
customMenu={!!triggerScriptPicker}
on:close={() => onClose?.()}
>
{#snippet buttonReplacement()}
<div class={className} bind:clientWidth={triggersButtonWidth}>
{@render children?.()}
</div>
{/snippet}
{#snippet menu()}
{@render triggerScriptPicker?.()}
{/snippet}
</DropdownV2>
@@ -1,4 +1,4 @@
<script module lang="ts">
<script context="module" lang="ts">
export type CaptureInfo = {
active: boolean
hasPreprocessor: boolean
@@ -6,70 +6,33 @@
isFlow: boolean
path: string
connectionInfo: ConnectionInfo | undefined
loading?: boolean
}
</script>
<script lang="ts">
import { fade } from 'svelte/transition'
import { slide } from 'svelte/transition'
import AnimatedButton from '../common/button/AnimatedButton.svelte'
import PulseButton from '../common/button/PulseButton.svelte'
import Button from '../common/button/Button.svelte'
import { CircleStop, History, Play, Loader2 } from 'lucide-svelte'
import { CircleStop } from 'lucide-svelte'
import ConnectionIndicator, {
type ConnectionInfo
} from '../common/alert/ConnectionIndicator.svelte'
import CaptureTable from './CaptureTable.svelte'
import { createEventDispatcher, onDestroy, getContext, onMount } from 'svelte'
import type { CaptureTriggerKind, Capture } from '$lib/gen'
import { createEventDispatcher, onDestroy, getContext } from 'svelte'
import type { CaptureTriggerKind } from '$lib/gen'
import CaptureIcon from './CaptureIcon.svelte'
import Tooltip from '../Tooltip.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { Popover } from '$lib/components/meltComponents'
import { CaptureService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { isObject, sendUserToast } from '$lib/utils'
import { triggerIconMap } from './utils'
import { formatDateShort } from '$lib/utils'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte'
import { base } from '$lib/base'
import Description from '$lib/components/Description.svelte'
import { twMerge } from 'tailwind-merge'
import { FlaskConical } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
interface Props {
disabled?: boolean | undefined
captureType: CaptureTriggerKind
captureInfo: CaptureInfo
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
description?: import('svelte').Snippet
children?: import('svelte').Snippet
displayAlert?: boolean
}
let {
disabled = undefined,
captureType,
captureInfo,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
description,
children,
displayAlert = false
}: Props = $props()
const testKind: 'preprocessor' | 'main' = $derived(hasPreprocessor ? 'preprocessor' : 'main')
export let disabled: boolean
export let captureType: CaptureTriggerKind
export let captureInfo: CaptureInfo
export let captureTable: CaptureTable | undefined
const dispatch = createEventDispatcher<{
captureToggle: { disableOnly?: boolean }
updateSchema: { payloadData: Record<string, any>; redirect: boolean; args?: boolean }
addPreprocessor: null
testWithArgs: Record<string, any>
applyArgs: { kind: 'main' | 'preprocessor'; args: Record<string, any> }
updateSchema: { payloadData: Record<string, any>; redirect: boolean }
}>()
const { showCaptureHint } = getContext<TriggerContext>('TriggerContext')
@@ -81,16 +44,16 @@
disableOnly: true
})
}
stopCaptureListening()
})
/* function handleUpdateSchema(e: any) {
function handleUpdateSchema(e: any) {
dispatch('updateSchema', {
payloadData: e.detail.payloadData,
redirect: e.detail.redirect
})
} */
}
let openingDuration = 400
let pulseButton: PulseButton | undefined
function updateShowCaptureHint(show: boolean | undefined) {
if (show) {
@@ -100,400 +63,77 @@
}, 300)
}
}
$effect(() => {
updateShowCaptureHint($showCaptureHint)
})
let selectedCapture: Capture | undefined = $state(undefined)
function handleSelectCapture(e: any) {
if (e.detail) {
selectCapture(e.detail)
} else if (lastCapture) {
selectCapture(lastCapture)
}
}
// New code for capture fetching and management
let lastCapture: Capture | undefined = $state(undefined)
let newCaptureReceived = $state(false)
let isLoadingBigPayload = $state(false)
let capturePollingInterval: ReturnType<typeof setInterval> | undefined = undefined
let lastCaptureId: number | undefined = undefined
let displayResult: DisplayResult | undefined = $state(undefined)
let toolbarLocation: 'internal' | 'external' | undefined = $state(undefined)
function selectCapture(capture: Capture) {
selectedCapture = capture
if (
capture.main_args === 'WINDMILL_TOO_BIG' ||
capture.preprocessor_args === 'WINDMILL_TOO_BIG'
) {
loadBigPayload(capture)
}
}
// Function to fetch the last capture when component mounts
async function fetchLastCapture() {
try {
if (!captureInfo.path) return
const captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: captureInfo.isFlow ? 'flow' : 'script',
path: captureInfo.path,
triggerKind: captureType,
page: 1,
perPage: 1
})
if (captures.length > 0) {
lastCapture = captures[0]
lastCaptureId = lastCapture.id
selectCapture(lastCapture)
}
} catch (error) {
console.error('Failed to fetch last capture:', error)
}
}
// Function to listen for new captures (polls every 100ms)
function listenForCaptures() {
if (capturePollingInterval) return
capturePollingInterval = setInterval(async () => {
if (!captureInfo.active) return
try {
const captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: captureInfo.isFlow ? 'flow' : 'script',
path: captureInfo.path,
triggerKind: captureType,
page: 1,
perPage: 1
})
if (captures.length > 0 && lastCaptureId !== captures[0].id) {
lastCapture = captures[0]
lastCaptureId = lastCapture.id
// Trigger animation for new capture
showNewCaptureAnimation()
selectCapture(lastCapture)
}
} catch (error) {
console.error('Error polling for new captures:', error)
}
}, 100)
}
// Stop listening for captures
function stopCaptureListening() {
if (capturePollingInterval) {
clearInterval(capturePollingInterval)
capturePollingInterval = undefined
}
}
// Show animation when new capture arrives
function showNewCaptureAnimation() {
newCaptureReceived = true
setTimeout(() => {
newCaptureReceived = false
}, 2000) // Animation duration
}
// Load big payload when requested
async function loadBigPayload(capture: Capture | undefined) {
if (!capture) return
try {
isLoadingBigPayload = true
const fullCapture = await CaptureService.getCapture({
workspace: $workspaceStore!,
id: capture.id
})
capture.main_args = fullCapture.main_args
capture.preprocessor_args = fullCapture.preprocessor_args
isLoadingBigPayload = false
} catch (error) {
sendUserToast('Failed to load large payload', true)
isLoadingBigPayload = false
}
}
function getCapturePayload(capture: Capture) {
let payloadData: any = {}
const preprocessor_args = isObject(capture.preprocessor_args) ? capture.preprocessor_args : {}
if ('wm_trigger' in preprocessor_args) {
// v1
payloadData =
testKind === 'preprocessor'
? {
...(typeof capture.main_args === 'object' ? capture.main_args : {}),
...preprocessor_args
}
: capture.main_args
} else {
// v2
payloadData = testKind === 'preprocessor' ? capture.preprocessor_args : capture.main_args
}
return payloadData
}
// Start or stop capture listening based on active state
$effect(() => {
if (captureInfo.active) {
listenForCaptures()
} else {
stopCaptureListening()
}
})
// Fetch last capture when component mounts
onMount(() => {
fetchLastCapture()
})
$: updateShowCaptureHint($showCaptureHint)
</script>
<Splitpanes>
<Pane size={50} minSize={30}>
<div
class="flex flex-col gap-1 px-4 py-2 h-full w-full overflow-auto"
style="scrollbar-gutter: stable"
>
<div class="text-sm text-secondary flex items-center gap-1">
<FlaskConical size={16} />
Test trigger
</div>
<div class="flex flex-col gap-1 mb-4 w-full">
<div class="flex justify-center w-full">
<div class="relative h-fit">
<AnimatedButton
animate={captureInfo.active}
wrapperClasses={captureInfo.active ? 'm-[-2px]' : ''}
baseRadius="7px"
<div transition:slide={{ duration: openingDuration }} class="pb-12 overflow-hidden">
<div class="border p-4 rounded-lg">
<div class="flex flex-col gap-1 mb-4">
<div class="flex flex-row items-center justify-start gap-1">
<PulseButton bind:this={pulseButton} numberOfPulses={1} pulseDuration={1}>
<AnimatedButton
animate={captureInfo.active || captureInfo.loading}
baseRadius="6px"
wrapperClasses="ml-[-2px]"
>
<Button
size="xs2"
on:click={() => dispatch('captureToggle', {})}
variant="border"
{disabled}
color="light"
btnClasses={captureInfo.active ? 'text-blue-500 hover:text-blue-500' : ''}
>
<Button
size="xs"
on:click={() => dispatch('captureToggle', {})}
{disabled}
color={captureInfo.active ? 'light' : 'dark'}
btnClasses={captureInfo.active ? 'text-blue-500' : ''}
startIcon={captureInfo.active
? { icon: CircleStop }
: { icon: CaptureIcon, props: { variant: 'redDot' } }}
loading={captureLoading}
>
<div class="flex flex-row items-center gap-1 w-28 justify-center">
{#if captureInfo.active}
<p class="w-24" transition:fade={{ duration: 300 }}>Stop capturing</p>
<CircleStop size={14} />
{:else}
<p class="w-24" transition:fade={{ duration: 300 }}>Start capturing</p>
<CaptureIcon variant="redDot" size={14} />
{/if}
</Button>
</AnimatedButton>
{captureInfo.active ? 'Stop' : 'Start capturing'}
</div>
</Button>
</AnimatedButton>
</PulseButton>
<div class="absolute top-1/2 -translate-y-1/2 -right-5">
{#if captureInfo.active}
<ConnectionIndicator connectionInfo={captureInfo.connectionInfo} />
{/if}
</div>
</div>
</div>
<div class="mt-4 mb-2">
{#if displayAlert}
<Alert type="warning" title="Trigger deployed" size="xs" class="mb-4">
Capturing will suscribe to the trigger endpoint. Treat carefully.
</Alert>
{/if}
<Description>
<div class="relative min-h-8">
{#key (captureInfo.active, disabled)}
<div
class={twMerge(
'absolute top-0 left-0 w-full text-center',
disabled === true ? 'text-red-600 dark:text-red-400' : ''
)}
in:fade={{ duration: 100, delay: 50 }}
out:fade={{ duration: 50 }}
>
{#if disabled === true}
Enter a valid configuration to start capturing.
{:else}
{@render description?.()}
{/if}
</div>
{/key}
</div>
</Description>
</div>
{#if children}
<div class="grow min-h-0 flex flex-col gap-4">
{@render children?.()}
</div>
{/if}
</div>
</div>
</Pane>
<Pane minSize={30} class="flex flex-col">
<div class="flex flex-row gap-1 justify-between min-h-[33.5px] pl-1 pr-4">
<div class="flex flex-row gap-1 items-center">
{#if lastCapture}
<Popover
placement="left"
contentClasses="w-48 min-h-48 max-h-64 overflow-auto"
floatingConfig={{
placement: 'left-start',
offset: { mainAxis: 8, crossAxis: -4.5 },
gutter: 0 // hack to make offset effective, see https://github.com/melt-ui/melt-ui/issues/528
}}
usePointerDownOutside
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
iconOnly
startIcon={{ icon: History }}
nonCaptureEvent
btnClasses="h-[27px]"
></Button>
{/snippet}
{#snippet content()}
<CaptureTable
{captureType}
isFlow={captureInfo.isFlow}
path={captureInfo.path}
on:selectCapture={handleSelectCapture}
fullHeight
headless
addButton={false}
noBorder
/>
{/snippet}
</Popover>
{/if}
{#if selectedCapture}
{@const SvelteComponent = triggerIconMap[captureType]}
<div
class={'min-w-16 text-secondary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2 h-[27px]'}
>
<SvelteComponent size={12} />
<span class="text-xs text-secondary truncate">
Capture {formatDateShort(selectedCapture?.created_at)}
</span>
</div>
{/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'}
<Button
size="xs2"
color="dark"
btnClasses="h-[27px]"
dropdownItems={[
{
label: 'Use as input schema',
onClick: async () => {
if (!lastCapture) return
const payloadData = selectedCapture?.main_args
dispatch('updateSchema', {
payloadData: payloadData ?? {},
redirect: true,
args: true
})
},
disabled: !selectedCapture,
hidden: !isFlow || testKind !== 'main'
}
].filter((item) => !item.hidden)}
on:click={async () => {
if (!selectedCapture) return
const payloadData = selectedCapture?.main_args ?? {}
if (isFlow && testKind === 'main') {
dispatch('testWithArgs', payloadData)
} else {
const trigger_extra = isObject(selectedCapture.preprocessor_args)
? selectedCapture.preprocessor_args
: {}
dispatch('applyArgs', {
kind: testKind,
args: { ...structuredClone(payloadData), ...trigger_extra }
})
}
}}
disabled={testKind === 'preprocessor' && !hasPreprocessor}
{title}
startIcon={isFlow && testKind === 'main' ? { icon: Play } : {}}
>
{label}
</Button>
{#if captureInfo.active}
<ConnectionIndicator connectionInfo={captureInfo.connectionInfo} />
{:else}
<Tooltip>
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.
</Tooltip>
{/if}
</div>
{#if displayResult && toolbarLocation === 'external'}
<DisplayResultControlBar
{base}
result={selectedCapture?.main_args}
disableTooltips={false}
on:open-drawer={() => {
if (displayResult && typeof displayResult.openDrawer === 'function') {
displayResult.openDrawer()
}
}}
/>
{/if}
</div>
<div class="grow min-h-0 rounded-md w-full pl-2 py-1 pb-2 overflow-auto">
{#if isLoadingBigPayload}
<Loader2 class="animate-spin" />
{:else if selectedCapture?.main_args}
<div class="bg-surface rounded-md text-sm" class:animate-highlight={newCaptureReceived}>
<DisplayResult
bind:this={displayResult}
workspaceId={undefined}
jobId={undefined}
result={getCapturePayload(selectedCapture)}
externalToolbarAvailable
on:toolbar-location-changed={({ detail }) => {
toolbarLocation = detail
}}
/>
{#if disabled}
<div class="text-sm font-normal text-red-600 dark:text-red-400" transition:slide>
Enter a valid configuration to start capturing.
</div>
{:else}
<div class="text-center text-tertiary p-4 bg-surface rounded-md"
>No captures to show yet.</div
>
{/if}
</div>
</Pane>
</Splitpanes>
<style>
@keyframes highlight {
0% {
color: rgba(59, 130, 246, 1);
background-color: rgba(59, 130, 246, 0.1);
}
100% {
color: inherit;
}
}
{#if $$slots.default}
<div class:opacity-50={disabled || !captureInfo.active} class="flex flex-col gap-4 mb-4">
<slot />
</div>
{/if}
.animate-highlight {
animation: highlight 2s ease-out forwards;
}
</style>
<CaptureTable
bind:this={captureTable}
{captureType}
hasPreprocessor={captureInfo.hasPreprocessor}
canHavePreprocessor={captureInfo.canHavePreprocessor}
isFlow={captureInfo.isFlow}
path={captureInfo.path}
canEdit={true}
on:applyArgs
on:updateSchema={handleUpdateSchema}
on:addPreprocessor
on:testWithArgs
fullHeight={false}
captureActiveIndicator={captureInfo.active}
/>
</div>
</div>

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