mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
squashed 2
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:]]*}"}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Vendored
+2
-1
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
72e6260ca886628cf1ba271bc058e6ecfdecdae5
|
||||
<<<<<<< Updated upstream
|
||||
72e6260ca886628cf1ba271bc058e6ecfdecdae5
|
||||
=======
|
||||
0cf6424295774980cd04f10986b826fbe09542a0
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
+22
-3
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+39
-6
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=<token></code></li>
|
||||
<li><code>BASE_INTERNAL_URL=<base url></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-<workspace></code> and <code>dependency-<workspace></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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
<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} -->
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 won’t 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>
|
||||
|
||||
-178
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user