Compare commits

...

56 Commits

Author SHA1 Message Date
François Delbrayelle
a5b004e1e1 fix(tests): on HttpClientTest 2025-09-12 19:16:58 +02:00
François Delbrayelle
b74d09accb Reapply "feat(retry): use the retry policy on HttpClient (#10922)" (#11263)
This reverts commit 01e8e46b77.
2025-09-12 19:16:58 +02:00
Roman Acevedo
5be401d23c ci: add a kestra-devtools cli, and comment PR with failed tests
this is a POC, I think it can already be useful. Next step will be to move kestra-devtools to a separate repo and publish it to npm
2025-09-12 18:48:12 +02:00
Roman Acevedo
bb9f4be8c2 Revert "chore(sanitycheck): refactor PurgeCurrentExecutionFiles (#11115)"
This reverts commit fc690bf7cd.
Python task cannot be used here, it is not available. This commit was
wrongly merged with a red CI
2025-09-12 17:49:02 +02:00
François Delbrayelle
01e8e46b77 Revert "feat(retry): use the retry policy on HttpClient (#10922)" (#11263)
This reverts commit a236688be6.
2025-09-12 17:46:28 +02:00
Miloš Paunović
d00f4b0768 chore(core): ensure editor suggestion widget renders above other elements (#11258)
Closes https://github.com/kestra-io/kestra/issues/10702.
Closes https://github.com/kestra-io/kestra/issues/11033.
2025-09-12 14:48:56 +02:00
Barthélémy Ledoux
279f59c874 fix(core): only display close all tabs when there is more than one tab (#11257) 2025-09-12 14:20:54 +02:00
Barthélémy Ledoux
d897509726 fix(flows): clear tasks list when last task is deleted (#11255) 2025-09-12 14:20:42 +02:00
Pradumna Saraf
0d592342af chore(sanitycheck): add for OutputValues (#11105) 2025-09-12 16:53:13 +05:30
Pradumna Saraf
fc690bf7cd chore(sanitycheck): refactor PurgeCurrentExecutionFiles (#11115) 2025-09-12 16:52:37 +05:30
Antoine Gauthier
0a1b919863 chore(logs): display copy button only on row hover (#11254)
Closes https://github.com/kestra-io/kestra/issues/11220.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-12 12:00:08 +02:00
Piyush Bhaskar
2f4e981a29 fix(core): add gradient at footer to avoid hard cut (#11252) 2025-09-12 14:35:47 +05:30
brian-mulier-p
5e7739432e fix(core): add ability to remap sort keys (#11233)
part of kestra-io/kestra-ee#5075
2025-09-12 09:43:39 +02:00
Miloš Paunović
8aba863b8c feat(core): introduce close all panels functionality (#11225)
Closes https://github.com/kestra-io/kestra/issues/10785.
2025-09-12 09:01:24 +02:00
dependabot[bot]
7eaa43c50f build(deps): bump axios (#11243)
Bumps the npm_and_yarn group with 1 update in the /ui directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.11.0 to 1.12.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-12 08:36:02 +02:00
Piyush Bhaskar
267ff78bfe fix(admin): change the header and add description on hover (#11241)
Co-authored-by: GitHub Action <actions@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-12 12:00:41 +05:30
François Delbrayelle
7272cfe01f feat(ai_copilot): gray italic placeholder + rename AiAgent to AiCopilot (#11235) 2025-09-11 20:24:04 +02:00
brian.mulier
91e2fdb2cc fix(ai): increase maxOutputToken default 2025-09-11 18:11:52 +02:00
François Delbrayelle
a236688be6 feat(retry): use the retry policy on HttpClient (#10922) 2025-09-11 15:00:25 +02:00
Antoine Gauthier
81763d40ae fix(docs): center main container in DocsLayout (#11222)
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-09-11 16:18:12 +05:30
Miloš Paunović
677efb6739 fix(namespaces): open details page at top (#11221)
Closes https://github.com/kestra-io/kestra/issues/10536.
2025-09-11 10:52:47 +02:00
Nicolas K.
b35924fef1 fix(tests): add server type mock in the kestra context (#11176)
Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-09-11 09:45:51 +02:00
Jaem Dessources
9dd93294b6 fix(core): align copy logs button to each row’s right edge (#11216)
Closes https://github.com/kestra-io/kestra/issues/10898.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-11 08:55:01 +02:00
Piyush Bhaskar
fac6dfe9a0 fix(core): update router usage in loadAutocomplete. (#11219) 2025-09-11 12:13:05 +05:30
Bisesh
3bf9764505 fix(core): make sidebar tab color consistent when unfocused (#11217)
Closes https://github.com/kestra-io/kestra/issues/11156.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-11 08:33:57 +02:00
Piyush Bhaskar
c35cea5d19 fix(core): override the ns module. (#11218) 2025-09-11 11:53:00 +05:30
Barthélémy Ledoux
4d8e9479f1 refactor: finally get rid of vuex (#11211) 2025-09-10 22:44:21 +02:00
Florian Hussonnois
3f24e8e838 fix(core): make CRC32 for plugin JARs lazy
Make CRC32 calculation for lazy plugin JAR files
to avoid excessive startup time and performance impact.

Avoid byte buffer reallocation while computing CRC32.
2025-09-10 17:42:02 +02:00
Miloš Paunović
7175fcb666 fix(executions): refactor link creation to ensure the id is rendered as a clickable link (#11209)
Related to https://github.com/kestra-io/kestra/issues/10906.
2025-09-10 15:01:29 +02:00
Barthélémy Ledoux
2ddfa13b1b refactor: make-axios-composable (#11177) 2025-09-10 14:54:00 +02:00
Barthélémy Ledoux
ba2a5dfec8 chore: revert monaco update (#11207) 2025-09-10 13:34:33 +02:00
Loïc Mathieu
f84441dac7 fix(ci): disable publishing docker image on fork
I should have not trusted an AI for this but copy/paste what I know work: the Quarkus CI!
2025-09-10 12:17:25 +02:00
Barthélémy Ledoux
433b788e4a chore: a bunch of performance fixes detected by oxlint (eslint-unicorn) (#10050) 2025-09-10 11:35:07 +02:00
dependabot[bot]
65c5fd6331 build(deps): bump org.projectlombok:lombok from 1.18.38 to 1.18.40
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.38 to 1.18.40.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.38...v1.18.40)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  dependency-version: 1.18.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 11:12:24 +02:00
dependabot[bot]
421ab40276 build(deps): bump io.micrometer:micrometer-core from 1.15.3 to 1.15.4
Bumps [io.micrometer:micrometer-core](https://github.com/micrometer-metrics/micrometer) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/micrometer-metrics/micrometer/releases)
- [Commits](https://github.com/micrometer-metrics/micrometer/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: io.micrometer:micrometer-core
  dependency-version: 1.15.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 11:11:48 +02:00
dependabot[bot]
efb2779693 build(deps): bump flyingSaucerVersion from 9.13.3 to 10.0.0
Bumps `flyingSaucerVersion` from 9.13.3 to 10.0.0.

Updates `org.xhtmlrenderer:flying-saucer-core` from 9.13.3 to 10.0.0
- [Release notes](https://github.com/flyingsaucerproject/flyingsaucer/releases)
- [Changelog](https://github.com/flyingsaucerproject/flyingsaucer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/flyingsaucerproject/flyingsaucer/compare/v9.13.3...v10.0.0)

Updates `org.xhtmlrenderer:flying-saucer-pdf` from 9.13.3 to 10.0.0
- [Release notes](https://github.com/flyingsaucerproject/flyingsaucer/releases)
- [Changelog](https://github.com/flyingsaucerproject/flyingsaucer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/flyingsaucerproject/flyingsaucer/compare/v9.13.3...v10.0.0)

---
updated-dependencies:
- dependency-name: org.xhtmlrenderer:flying-saucer-core
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: org.xhtmlrenderer:flying-saucer-pdf
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 11:10:59 +02:00
dependabot[bot]
74d371c0ca build(deps): bump com.azure:azure-sdk-bom from 1.2.37 to 1.2.38
Bumps [com.azure:azure-sdk-bom](https://github.com/azure/azure-sdk-for-java) from 1.2.37 to 1.2.38.
- [Release notes](https://github.com/azure/azure-sdk-for-java/releases)
- [Commits](https://github.com/azure/azure-sdk-for-java/compare/azure-sdk-bom_1.2.37...azure-sdk-bom_1.2.38)

---
updated-dependencies:
- dependency-name: com.azure:azure-sdk-bom
  dependency-version: 1.2.38
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 11:10:10 +02:00
Loïc Mathieu
90a7869020 fixsystem): always load netty from the app classloader
As Netty is used in core and a lot of plugins, and we already load project reactor from the app classloader that depends in Netty.

Fixes https://github.com/kestra-io/kestra-ee/issues/5038
2025-09-10 10:50:22 +02:00
dependabot[bot]
d9ccb50b0f build(deps): bump actions/github-script from 7 to 8
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:47:40 +02:00
dependabot[bot]
aea0b87ef8 build(deps): bump aquasecurity/trivy-action from 0.33.0 to 0.33.1
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.33.0 to 0.33.1.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.33.0...0.33.1)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.33.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:47:17 +02:00
Loïc Mathieu
9a144fc3fe fix(system): we don't need to advance the parser anymore to the first token 2025-09-10 10:46:44 +02:00
Loïc Mathieu
ddd9cebc63 chore(deps): upgrade to Jackson 2.20.0
Jackson annotation now uses a version scheme without micro version so it has been updated to 2.20.

Closes #11069
2025-09-10 10:46:44 +02:00
dependabot[bot]
1bebbb9b73 build(deps): bump com.gorylenko.gradle-git-properties
Bumps com.gorylenko.gradle-git-properties from 2.5.2 to 2.5.3.

---
updated-dependencies:
- dependency-name: com.gorylenko.gradle-git-properties
  dependency-version: 2.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:46:26 +02:00
dependabot[bot]
8de4dc867e build(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:46:08 +02:00
dependabot[bot]
fc49694e76 build(deps): bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:45:47 +02:00
dependabot[bot]
152300abae build(deps): bump io.micronaut.openapi:micronaut-openapi-bom
Bumps [io.micronaut.openapi:micronaut-openapi-bom](https://github.com/micronaut-projects/micronaut-openapi) from 6.17.3 to 6.18.0.
- [Release notes](https://github.com/micronaut-projects/micronaut-openapi/releases)
- [Commits](https://github.com/micronaut-projects/micronaut-openapi/compare/v6.17.3...v6.18.0)

---
updated-dependencies:
- dependency-name: io.micronaut.openapi:micronaut-openapi-bom
  dependency-version: 6.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:45:13 +02:00
dependabot[bot]
1ff5dda4e1 build(deps): bump software.amazon.awssdk:bom from 2.33.2 to 2.33.5
Bumps software.amazon.awssdk:bom from 2.33.2 to 2.33.5.

---
updated-dependencies:
- dependency-name: software.amazon.awssdk:bom
  dependency-version: 2.33.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 10:44:50 +02:00
Miloš Paunović
84f9b8876d chore(deps): regular dependency update (#11200)
Performing a weekly round of dependency updates in the NPM ecosystem to keep everything up to date.
2025-09-10 10:18:33 +02:00
brian-mulier-p
575955567f fix(flows): avoid failing flow dependencies with dynamic defaults (#11166)
closes #11117
2025-09-10 09:59:51 +02:00
brian-mulier-p
d6d2580b45 fix(namespaces): avoid adding 'company.team' as default ns (#11174)
closes #11168
2025-09-09 17:13:48 +02:00
Miloš Paunović
070e54b902 chore(flows): display correct flow dependency count (#11169)
Closes https://github.com/kestra-io/kestra/issues/11127.
2025-09-09 13:56:17 +02:00
Roman Acevedo
829ca4380f fix(flows): topology would not load when having many flows and cyclic relations
- this will probably fix https://github.com/kestra-io/kestra-ee/issues/4980

the issue was recursiveFlowTopology was returning a lot of duplicates, it was aggravated when having many Flows and multiple Flow triggers
2025-09-09 13:06:20 +02:00
Karthik D
381c7a75ad chore(core): use simple search input on blueprints listing (#11034)
Closes https://github.com/kestra-io/kestra/issues/11002.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-09 12:54:58 +02:00
louispy
1688c489a9 chore(flows): improve visibility of horizontal scroll bar on listing (#11163)
Closes https://github.com/kestra-io/kestra/issues/11158.

Co-authored-by: louispy <louisleslie98@gmail.com>
Co-authored-by: MilosPaunovic <paun992@hotmail.com>
2025-09-09 12:40:28 +02:00
AKSHAT GUPTA
93ccbf5f9b chore(core): separate data loading from graph node rendering on dependency view (#11155)
Relates to https://github.com/kestra-io/kestra/issues/11125.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-09-09 12:25:58 +02:00
Barthélémy Ledoux
ac1cb235e5 refactor: avoid importing all of lodash when we only need groupBy (#10870) 2025-09-09 11:34:13 +02:00
171 changed files with 10738 additions and 2258 deletions

View File

@@ -26,7 +26,7 @@ jobs:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.x"
@@ -39,7 +39,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "20.x"

View File

@@ -0,0 +1,32 @@
name: kestra-devtools test
on:
pull_request:
branches:
- develop
paths:
- 'dev-tools/kestra-devtools/**'
env:
# to save corepack from itself
COREPACK_INTEGRITY_KEYS: 0
jobs:
test:
name: kestra-devtools tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Npm - install
working-directory: 'dev-tools/kestra-devtools'
run: npm ci
- name: Run tests
working-directory: 'dev-tools/kestra-devtools'
run: npm run test
- name: Npm - Run build
working-directory: 'dev-tools/kestra-devtools'
run: npm run build

View File

@@ -87,7 +87,7 @@ jobs:
# Run Trivy image scan for Docker vulnerabilities, see https://github.com/aquasecurity/trivy-action
- name: Docker Vulnerabilities Check
uses: aquasecurity/trivy-action@0.33.0
uses: aquasecurity/trivy-action@0.33.1
with:
image-ref: kestra/kestra:develop
format: 'template'
@@ -132,7 +132,7 @@ jobs:
# Run Trivy image scan for Docker vulnerabilities, see https://github.com/aquasecurity/trivy-action
- name: Docker Vulnerabilities Check
uses: aquasecurity/trivy-action@0.33.0
uses: aquasecurity/trivy-action@0.33.1
with:
image-ref: kestra/kestra:latest
format: table

View File

@@ -20,6 +20,7 @@ permissions:
contents: write
checks: write
actions: read
pull-requests: write
jobs:
test:
@@ -59,6 +60,14 @@ jobs:
export GOOGLE_APPLICATION_CREDENTIALS=$HOME/.gcp-service-account.json
./gradlew check javadoc --parallel
- name: comment PR with test report
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_AUTH_TOKEN }}
run: |
export KESTRA_PWD=$(pwd) && sh -c 'cd dev-tools/kestra-devtools && npm ci && npm run build && node dist/kestra-devtools-cli.cjs generateTestReportSummary --only-errors --ci $KESTRA_PWD' > report.md
cat report.md
# report test
- name: Test - Publish Test Results
uses: dorny/test-reporter@v2

View File

@@ -7,7 +7,7 @@ on:
jobs:
publish:
name: Pull Request - Delete Docker
if: github.repository == github.event.pull_request.head.repo.full_name # prevent running on forks
if: github.repository == 'kestra-io/kestra' # prevent running on forks
runs-on: ubuntu-latest
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1

View File

@@ -8,12 +8,12 @@ on:
jobs:
build-artifacts:
name: Build Artifacts
if: github.repository == github.event.pull_request.head.repo.full_name # prevent running on forks
if: github.repository == 'kestra-io/kestra' # prevent running on forks
uses: ./.github/workflows/workflow-build-artifacts.yml
publish:
name: Publish Docker
if: github.repository == github.event.pull_request.head.repo.full_name # prevent running on forks
if: github.repository == 'kestra-io/kestra' # prevent running on forks
runs-on: ubuntu-latest
needs: build-artifacts
env:
@@ -62,7 +62,7 @@ jobs:
# Add comment on pull request
- name: Add comment to PR
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -32,7 +32,7 @@ plugins {
// release
id 'net.researchgate.release' version '3.1.0'
id "com.gorylenko.gradle-git-properties" version "2.5.2"
id "com.gorylenko.gradle-git-properties" version "2.5.3"
id 'signing'
id "com.vanniktech.maven.publish" version "0.34.0"
@@ -207,6 +207,13 @@ subprojects {
test {
useJUnitPlatform()
reports {
junitXml.required = true
junitXml.outputPerTestCase = true
junitXml.mergeReruns = true
junitXml.includeSystemErrLog = true;
junitXml.outputLocation = layout.buildDirectory.dir("test-results/junit")
}
// set Xmx for test workers
maxHeapSize = '4g'

View File

@@ -39,8 +39,6 @@ public class NamespaceFilesUpdateCommand extends AbstractApiCommand {
@Inject
private TenantIdSelectorService tenantService;
private static final String KESTRA_IGNORE_FILE = ".kestraignore";
@Override
public Integer call() throws Exception {
super.call();

View File

@@ -4,11 +4,15 @@ import com.fasterxml.jackson.core.type.TypeReference;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
import io.kestra.core.http.HttpRequest;
import io.kestra.core.http.HttpResponse;
import io.kestra.core.http.client.apache.*;
import io.kestra.core.http.client.apache.FailedResponseInterceptor;
import io.kestra.core.http.client.apache.LoggingRequestInterceptor;
import io.kestra.core.http.client.apache.LoggingResponseInterceptor;
import io.kestra.core.http.client.apache.RunContextResponseInterceptor;
import io.kestra.core.http.client.configurations.HttpConfiguration;
import io.kestra.core.runners.DefaultRunContext;
import io.kestra.core.runners.RunContext;
import io.kestra.core.serializers.JacksonMapper;
import io.kestra.core.utils.RetryUtils;
import io.micrometer.common.KeyValues;
import io.micrometer.core.instrument.binder.httpcomponents.hc5.ApacheHttpClientContext;
import io.micrometer.core.instrument.binder.httpcomponents.hc5.DefaultApacheHttpClientObservationConvention;
@@ -21,7 +25,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.ContextBuilder;
import org.apache.hc.client5.http.auth.*;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.impl.ChainElement;
import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy;
@@ -39,6 +44,8 @@ import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.ssl.SSLContexts;
import org.apache.hc.core5.util.Timeout;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
@@ -46,11 +53,8 @@ import java.net.*;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.List;
import java.util.function.Consumer;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
@Slf4j
public class HttpClient implements Closeable {
@@ -75,7 +79,7 @@ public class HttpClient implements Closeable {
throw new IllegalStateException("Client has already been created");
}
org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom()
var builder = HttpClients.custom()
.disableDefaultUserAgent()
.setUserAgent("Kestra");
@@ -89,49 +93,37 @@ public class HttpClient implements Closeable {
// logger
if (this.configuration.getLogs() != null && this.configuration.getLogs().length > 0) {
if (ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.REQUEST_HEADERS) ||
ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.REQUEST_BODY)
) {
ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.REQUEST_BODY)) {
builder.addRequestInterceptorLast(new LoggingRequestInterceptor(runContext.logger(), this.configuration.getLogs()));
}
if (ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.RESPONSE_HEADERS) ||
ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.RESPONSE_BODY)
) {
ArrayUtils.contains(this.configuration.getLogs(), HttpConfiguration.LoggingType.RESPONSE_BODY)) {
builder.addResponseInterceptorLast(new LoggingResponseInterceptor(runContext.logger(), this.configuration.getLogs()));
}
}
// Object dependencies
PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create();
ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom();
BasicCredentialsProvider credentialsStore = new BasicCredentialsProvider();
var connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create();
var connectionConfig = ConnectionConfig.custom();
var credentialsStore = new BasicCredentialsProvider();
// Timeout
if (this.configuration.getTimeout() != null) {
var connectTimeout = runContext.render(this.configuration.getTimeout().getConnectTimeout()).as(Duration.class);
var connectTimeout = runContext.render(this.configuration.getTimeout().getConnectTimeout()).as(java.time.Duration.class);
connectTimeout.ifPresent(duration -> connectionConfig.setConnectTimeout(Timeout.of(duration)));
var readIdleTimeout = runContext.render(this.configuration.getTimeout().getReadIdleTimeout()).as(Duration.class);
var readIdleTimeout = runContext.render(this.configuration.getTimeout().getReadIdleTimeout()).as(java.time.Duration.class);
readIdleTimeout.ifPresent(duration -> connectionConfig.setSocketTimeout(Timeout.of(duration)));
}
// proxy
if (this.configuration.getProxy() != null && configuration.getProxy().getAddress() != null) {
String proxyAddress = runContext.render(configuration.getProxy().getAddress()).as(String.class).orElse(null);
var proxyAddress = runContext.render(configuration.getProxy().getAddress()).as(String.class).orElse(null);
if (StringUtils.isNotEmpty(proxyAddress)) {
int port = runContext.render(configuration.getProxy().getPort()).as(Integer.class).orElseThrow();
SocketAddress proxyAddr = new InetSocketAddress(
proxyAddress,
port
);
Proxy proxy = new Proxy(runContext.render(configuration.getProxy().getType()).as(Proxy.Type.class).orElse(null), proxyAddr);
var port = runContext.render(configuration.getProxy().getPort()).as(Integer.class).orElseThrow();
var proxyAddr = new InetSocketAddress(proxyAddress, port);
var proxy = new Proxy(runContext.render(configuration.getProxy().getType()).as(Proxy.Type.class).orElse(null), proxyAddr);
builder.setProxySelector(new ProxySelector() {
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException e) {
/* ignore */
}
@Override
@@ -142,7 +134,6 @@ public class HttpClient implements Closeable {
if (this.configuration.getProxy().getUsername() != null && this.configuration.getProxy().getPassword() != null) {
builder.setProxyAuthenticationStrategy(new DefaultAuthenticationStrategy());
credentialsStore.setCredentials(
new AuthScope(proxyAddress, port),
new UsernamePasswordCredentials(
@@ -154,19 +145,16 @@ public class HttpClient implements Closeable {
}
}
// ssl
if (this.configuration.getSsl() != null) {
if (this.configuration.getSsl().getInsecureTrustAllCertificates() != null) {
connectionManagerBuilder.setSSLSocketFactory(this.selfSignedConnectionSocketFactory());
}
}
// auth
if (this.configuration.getAuth() != null) {
this.configuration.getAuth().configure(builder, runContext);
}
// root options
if (!runContext.render(this.configuration.getFollowRedirects()).as(Boolean.class).orElseThrow()) {
builder.disableRedirectHandling();
}
@@ -176,8 +164,7 @@ public class HttpClient implements Closeable {
}
if (this.configuration.getAllowedResponseCodes() != null) {
List<Integer> list = runContext.render(this.configuration.getAllowedResponseCodes()).asList(Integer.class);
var list = runContext.render(this.configuration.getAllowedResponseCodes()).asList(Integer.class);
if (!list.isEmpty()) {
builder.addResponseInterceptorLast(new FailedResponseInterceptor(list));
}
@@ -185,91 +172,51 @@ public class HttpClient implements Closeable {
builder.addResponseInterceptorLast(new RunContextResponseInterceptor(this.runContext));
// builder object
connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build());
builder.setConnectionManager(connectionManagerBuilder.build());
builder.setDefaultCredentialsProvider(credentialsStore);
this.client = builder.build();
return client;
}
private SSLConnectionSocketFactory selfSignedConnectionSocketFactory() {
try {
SSLContext sslContext = SSLContexts
.custom()
.loadTrustMaterial(null, (chain, authType) -> true)
.build();
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, (chain, authType) -> true).build();
return new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
throw new IllegalArgumentException(e);
}
}
/**
* Send a request
*
* @param request the request
* @param cls the class of the response
* @param <T> the type of response expected
* @return the response
*/
public <T> HttpResponse<T> request(HttpRequest request, Class<T> cls) throws HttpClientException, IllegalVariableEvaluationException {
HttpClientContext httpClientContext = this.clientContext(request);
var httpClientContext = this.clientContext();
return this.request(request, httpClientContext, r -> {
T body = bodyHandler(cls, r.getEntity());
return HttpResponse.from(r, body, request, httpClientContext);
});
}
/**
* Send a request, getting the response with body as input stream
*
* @param request the request
* @param consumer the consumer of the response
* @return the response without the body
*/
public HttpResponse<Void> request(HttpRequest request, Consumer<HttpResponse<InputStream>> consumer) throws HttpClientException, IllegalVariableEvaluationException {
HttpClientContext httpClientContext = this.clientContext(request);
var httpClientContext = this.clientContext();
return this.request(request, httpClientContext, r -> {
HttpResponse<InputStream> from = HttpResponse.from(
r,
r.getEntity() != null ? r.getEntity().getContent() : null,
request,
httpClientContext
);
var from = HttpResponse.from(r, r.getEntity() != null ? r.getEntity().getContent() : null, request, httpClientContext);
consumer.accept(from);
return HttpResponse.from(r, null, request, httpClientContext);
});
}
/**
* Send a request and expect a json response
*
* @param request the request
* @param <T> the type of response expected
* @return the response
*/
public <T> HttpResponse<T> request(HttpRequest request) throws HttpClientException, IllegalVariableEvaluationException {
HttpClientContext httpClientContext = this.clientContext(request);
var httpClientContext = this.clientContext();
return this.request(request, httpClientContext, response -> {
T body = JacksonMapper.ofJson().readValue(response.getEntity().getContent(), new TypeReference<>() {});
T body = JacksonMapper.ofJson().readValue(response.getEntity().getContent(), new TypeReference<>() {
});
return HttpResponse.from(response, body, request, httpClientContext);
});
}
private HttpClientContext clientContext(HttpRequest request) {
ContextBuilder contextBuilder = ContextBuilder.create();
private HttpClientContext clientContext() {
var contextBuilder = ContextBuilder.create();
return contextBuilder.build();
}
@@ -277,22 +224,31 @@ public class HttpClient implements Closeable {
HttpRequest request,
HttpClientContext httpClientContext,
HttpClientResponseHandler<HttpResponse<T>> responseHandler
) throws HttpClientException {
try {
return this.client.execute(request.to(runContext), httpClientContext, responseHandler);
} catch (SocketException e) {
throw new HttpClientRequestException(e.getMessage(), request, e);
} catch (IOException e) {
if (e instanceof SSLHandshakeException) {
throw new HttpClientRequestException(e.getMessage(), request, e);
}
) throws HttpClientException, IllegalVariableEvaluationException {
if (e.getCause() instanceof HttpClientException httpClientException) {
throw httpClientException;
}
var retryableCodes = runContext.render(configuration.getRetryOnStatusCodes()).asList(Integer.class);
throw new RuntimeException(e);
}
return new RetryUtils().<HttpResponse<T>, HttpClientException>of(configuration.getRetry())
.run(
(res, throwable) -> {
if (throwable instanceof HttpClientResponseException ex) {
return retryableCodes.contains(ex.getResponse().getStatus().getCode());
}
return throwable instanceof HttpClientRequestException
|| throwable instanceof SocketException
|| throwable instanceof SSLHandshakeException;
},
() -> {
try {
return this.client.execute(request.to(runContext), httpClientContext, responseHandler);
} catch (org.apache.hc.client5.http.ClientProtocolException e) {
if (e.getCause() instanceof HttpClientException ex) {
throw ex;
}
throw new RuntimeException(e);
}
}
);
}
@SuppressWarnings("unchecked")

View File

@@ -2,6 +2,8 @@ package io.kestra.core.http.client.configurations;
import io.kestra.core.models.annotations.PluginProperty;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.retrys.AbstractRetry;
import io.kestra.core.models.tasks.retrys.Exponential;
import io.micronaut.logging.LogLevel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
@@ -55,6 +57,19 @@ public class HttpConfiguration {
@PluginProperty
private LoggingType[] logs;
@Schema(title = "Retry strategy for HTTP requests.")
@Builder.Default
private AbstractRetry retry = Exponential.builder()
.interval(Duration.ofMillis(1000))
.maxInterval(Duration.ofSeconds(30))
.maxAttempts(3)
.build();
@Setter
@Schema(title = "HTTP status codes that should be retried.")
@Builder.Default
private Property<List<Integer>> retryOnStatusCodes = Property.ofValue(List.of(502, 503, 504));
public enum LoggingType {
REQUEST_HEADERS,
REQUEST_BODY,
@@ -62,7 +77,6 @@ public class HttpConfiguration {
RESPONSE_BODY
}
// Deprecated properties
@Schema(title = "The time allowed to establish a connection to the server before failing.")
@Deprecated
private final Duration connectTimeout;
@@ -104,7 +118,6 @@ public class HttpConfiguration {
@Deprecated
private final LogLevel logLevel;
// Deprecated properties with no equivalent value to be kept, silently ignore
@Schema(title = "The time allowed for a read connection to remain idle before closing it.")
@Deprecated
private final Duration readIdleTimeout;
@@ -121,115 +134,73 @@ public class HttpConfiguration {
@Deprecated
public HttpConfigurationBuilder connectTimeout(Duration connectTimeout) {
if (this.timeout == null) {
this.timeout = TimeoutConfiguration.builder()
.build();
this.timeout = TimeoutConfiguration.builder().build();
}
this.timeout = this.timeout.toBuilder()
.connectTimeout(Property.ofValue(connectTimeout))
.build();
this.timeout = this.timeout.toBuilder().connectTimeout(Property.ofValue(connectTimeout)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder readTimeout(Duration readTimeout) {
if (this.timeout == null) {
this.timeout = TimeoutConfiguration.builder()
.build();
this.timeout = TimeoutConfiguration.builder().build();
}
this.timeout = this.timeout.toBuilder()
.readIdleTimeout(Property.ofValue(readTimeout))
.build();
this.timeout = this.timeout.toBuilder().readIdleTimeout(Property.ofValue(readTimeout)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder proxyType(Proxy.Type proxyType) {
if (this.proxy == null) {
this.proxy = ProxyConfiguration.builder()
.build();
this.proxy = ProxyConfiguration.builder().build();
}
this.proxy = this.proxy.toBuilder()
.type(Property.ofValue(proxyType))
.build();
this.proxy = this.proxy.toBuilder().type(Property.ofValue(proxyType)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder proxyAddress(String proxyAddress) {
if (this.proxy == null) {
this.proxy = ProxyConfiguration.builder()
.build();
this.proxy = ProxyConfiguration.builder().build();
}
this.proxy = this.proxy.toBuilder()
.address(Property.ofValue(proxyAddress))
.build();
this.proxy = this.proxy.toBuilder().address(Property.ofValue(proxyAddress)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder proxyPort(Integer proxyPort) {
if (this.proxy == null) {
this.proxy = ProxyConfiguration.builder()
.build();
this.proxy = ProxyConfiguration.builder().build();
}
this.proxy = this.proxy.toBuilder()
.port(Property.ofValue(proxyPort))
.build();
this.proxy = this.proxy.toBuilder().port(Property.ofValue(proxyPort)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder proxyUsername(String proxyUsername) {
if (this.proxy == null) {
this.proxy = ProxyConfiguration.builder()
.build();
this.proxy = ProxyConfiguration.builder().build();
}
this.proxy = this.proxy.toBuilder()
.username(Property.ofValue(proxyUsername))
.build();
this.proxy = this.proxy.toBuilder().username(Property.ofValue(proxyUsername)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder proxyPassword(String proxyPassword) {
if (this.proxy == null) {
this.proxy = ProxyConfiguration.builder()
.build();
this.proxy = ProxyConfiguration.builder().build();
}
this.proxy = this.proxy.toBuilder()
.password(Property.ofValue(proxyPassword))
.build();
this.proxy = this.proxy.toBuilder().password(Property.ofValue(proxyPassword)).build();
return this;
}
@SuppressWarnings("DeprecatedIsStillUsed")
@Deprecated
public HttpConfigurationBuilder basicAuthUser(String basicAuthUser) {
if (this.auth == null || !(this.auth instanceof BasicAuthConfiguration)) {
this.auth = BasicAuthConfiguration.builder()
.build();
this.auth = BasicAuthConfiguration.builder().build();
}
this.auth = ((BasicAuthConfiguration) this.auth).toBuilder()
.username(Property.ofValue(basicAuthUser))
.build();
this.auth = ((BasicAuthConfiguration) this.auth).toBuilder().username(Property.ofValue(basicAuthUser)).build();
return this;
}
@@ -237,37 +208,21 @@ public class HttpConfiguration {
@Deprecated
public HttpConfigurationBuilder basicAuthPassword(String basicAuthPassword) {
if (this.auth == null || !(this.auth instanceof BasicAuthConfiguration)) {
this.auth = BasicAuthConfiguration.builder()
.build();
this.auth = BasicAuthConfiguration.builder().build();
}
this.auth = ((BasicAuthConfiguration) this.auth).toBuilder()
.password(Property.ofValue(basicAuthPassword))
.build();
this.auth = ((BasicAuthConfiguration) this.auth).toBuilder().password(Property.ofValue(basicAuthPassword)).build();
return this;
}
@Deprecated
public HttpConfigurationBuilder logLevel(LogLevel logLevel) {
if (logLevel == LogLevel.TRACE) {
this.logs = new LoggingType[]{
LoggingType.REQUEST_HEADERS,
LoggingType.REQUEST_BODY,
LoggingType.RESPONSE_HEADERS,
LoggingType.RESPONSE_BODY
};
this.logs = new LoggingType[]{LoggingType.REQUEST_HEADERS, LoggingType.REQUEST_BODY, LoggingType.RESPONSE_HEADERS, LoggingType.RESPONSE_BODY};
} else if (logLevel == LogLevel.DEBUG) {
this.logs = new LoggingType[]{
LoggingType.REQUEST_HEADERS,
LoggingType.RESPONSE_HEADERS,
};
this.logs = new LoggingType[]{LoggingType.REQUEST_HEADERS, LoggingType.RESPONSE_HEADERS};
} else if (logLevel == LogLevel.INFO) {
this.logs = new LoggingType[]{
LoggingType.RESPONSE_HEADERS,
};
this.logs = new LoggingType[]{LoggingType.RESPONSE_HEADERS};
}
return this;
}
}

View File

@@ -6,6 +6,12 @@ import lombok.Getter;
import lombok.ToString;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.CRC32;
@AllArgsConstructor
@Getter
@@ -14,5 +20,59 @@ import java.net.URL;
public class ExternalPlugin {
private final URL location;
private final URL[] resources;
private final long crc32;
private volatile Long crc32; // lazy-val
public ExternalPlugin(URL location, URL[] resources) {
this.location = location;
this.resources = resources;
}
public Long getCrc32() {
if (this.crc32 == null) {
synchronized (this) {
if (this.crc32 == null) {
this.crc32 = computeJarCrc32(location);
}
}
}
return crc32;
}
/**
* Compute a CRC32 of the JAR File without reading the whole file
*
* @param location of the JAR File.
* @return the CRC32 of {@code -1} if the checksum can't be computed.
*/
private static long computeJarCrc32(final URL location) {
CRC32 crc = new CRC32();
try (JarFile jar = new JarFile(location.toURI().getPath(), false)) {
Enumeration<JarEntry> entries = jar.entries();
byte[] buffer = new byte[Long.BYTES]; // reusable buffer to avoid re-allocation
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
crc.update(entry.getName().getBytes(StandardCharsets.UTF_8));
updateCrc32WithLong(crc, buffer, entry.getSize());
updateCrc32WithLong(crc, buffer, entry.getCrc());
}
return crc.getValue();
} catch (Exception e) {
return -1;
}
}
private static void updateCrc32WithLong(CRC32 crc32, byte[] reusable, long val) {
// fast long -> byte conversion
reusable[0] = (byte) (val >>> 56);
reusable[1] = (byte) (val >>> 48);
reusable[2] = (byte) (val >>> 40);
reusable[3] = (byte) (val >>> 32);
reusable[4] = (byte) (val >>> 24);
reusable[5] = (byte) (val >>> 16);
reusable[6] = (byte) (val >>> 8);
reusable[7] = (byte) val;
crc32.update(reusable);;
}
}

View File

@@ -46,6 +46,7 @@ public class PluginClassLoader extends URLClassLoader {
+ "|dev.failsafe"
+ "|reactor"
+ "|io.opentelemetry"
+ "|io.netty"
+ ")\\..*$");
private final ClassLoader parent;

View File

@@ -51,8 +51,7 @@ public class PluginResolver {
final List<URL> resources = resolveUrlsForPluginPath(path);
plugins.add(new ExternalPlugin(
path.toUri().toURL(),
resources.toArray(new URL[0]),
computeJarCrc32(path)
resources.toArray(new URL[0])
));
}
} catch (final InvalidPathException | MalformedURLException e) {
@@ -124,33 +123,5 @@ public class PluginResolver {
return urls;
}
/**
* Compute a CRC32 of the JAR File without reading the whole file
*
* @param location of the JAR File.
* @return the CRC32 of {@code -1} if the checksum can't be computed.
*/
private static long computeJarCrc32(final Path location) {
CRC32 crc = new CRC32();
try (JarFile jar = new JarFile(location.toFile(), false)) {
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
crc.update(entry.getName().getBytes());
crc.update(longToBytes(entry.getSize()));
crc.update(longToBytes(entry.getCrc()));
}
} catch (Exception e) {
return -1;
}
return crc.getValue();
}
private static byte[] longToBytes(long x) {
ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES);
buffer.putLong(x);
return buffer.array();
}
}

View File

@@ -10,6 +10,7 @@ import io.kestra.core.models.flows.FlowInterface;
import io.kestra.core.models.flows.Input;
import io.kestra.core.models.flows.State;
import io.kestra.core.models.flows.input.SecretInput;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.property.PropertyContext;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.models.triggers.AbstractTrigger;
@@ -282,15 +283,15 @@ public final class RunVariables {
if (flow != null && flow.getInputs() != null) {
// we add default inputs value from the flow if not already set, this will be useful for triggers
flow.getInputs().stream()
.filter(input -> input.getDefaults() != null && !inputs.containsKey(input.getId()))
.forEach(input -> {
try {
inputs.put(input.getId(), FlowInputOutput.resolveDefaultValue(input, propertyContext));
} catch (IllegalVariableEvaluationException e) {
throw new RuntimeException("Unable to inject default value for input '" + input.getId() + "'", e);
}
});
flow.getInputs().stream()
.filter(input -> input.getDefaults() != null && !inputs.containsKey(input.getId()))
.forEach(input -> {
try {
inputs.put(input.getId(), FlowInputOutput.resolveDefaultValue(input, propertyContext));
} catch (IllegalVariableEvaluationException e) {
// Silent catch, if an input depends on another input, or a variable that is populated at runtime / input filling time, we can't resolve it here.
}
});
}
if (!inputs.isEmpty()) {

View File

@@ -180,23 +180,13 @@ public final class FileSerde {
}
private static <T> MappingIterator<T> createMappingIterator(ObjectMapper objectMapper, Reader reader, TypeReference<T> type) throws IOException {
// See https://github.com/FasterXML/jackson-dataformats-binary/issues/493
// There is a limitation with the MappingIterator that cannot differentiate between an array of things (of whatever shape)
// and a sequence/stream of things (of Array shape).
// To work around that, we need to create a JsonParser and advance to the first token.
try (var parser = objectMapper.createParser(reader)) {
parser.nextToken();
return objectMapper.readerFor(type).readValues(parser);
}
}
private static <T> MappingIterator<T> createMappingIterator(ObjectMapper objectMapper, Reader reader, Class<T> type) throws IOException {
// See https://github.com/FasterXML/jackson-dataformats-binary/issues/493
// There is a limitation with the MappingIterator that cannot differentiate between an array of things (of whatever shape)
// and a sequence/stream of things (of Array shape).
// To work around that, we need to create a JsonParser and advance to the first token.
try (var parser = objectMapper.createParser(reader)) {
parser.nextToken();
return objectMapper.readerFor(type).readValues(parser);
}
}

View File

@@ -3,12 +3,7 @@ package io.kestra.core.services;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.kestra.core.exceptions.FlowProcessingException;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.flows.FlowId;
import io.kestra.core.models.flows.FlowInterface;
import io.kestra.core.models.flows.FlowWithException;
import io.kestra.core.models.flows.FlowWithSource;
import io.kestra.core.models.flows.GenericFlow;
import io.kestra.core.models.flows.*;
import io.kestra.core.models.tasks.RunnableTask;
import io.kestra.core.models.topologies.FlowTopology;
import io.kestra.core.models.triggers.AbstractTrigger;
@@ -30,16 +25,7 @@ import org.apache.commons.lang3.builder.EqualsBuilder;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -551,23 +537,24 @@ public class FlowService {
return expandAll ? recursiveFlowTopology(new ArrayList<>(), tenant, namespace, id, destinationOnly) : flowTopologyRepository.get().findByFlow(tenant, namespace, id, destinationOnly).stream();
}
private Stream<FlowTopology> recursiveFlowTopology(List<FlowId> flowIds, String tenantId, String namespace, String id, boolean destinationOnly) {
private Stream<FlowTopology> recursiveFlowTopology(List<String> visitedTopologies, String tenantId, String namespace, String id, boolean destinationOnly) {
if (flowTopologyRepository.isEmpty()) {
throw noRepositoryException();
}
List<FlowTopology> flowTopologies = flowTopologyRepository.get().findByFlow(tenantId, namespace, id, destinationOnly);
FlowId flowId = FlowId.of(tenantId, namespace, id, null);
if (flowIds.contains(flowId)) {
return flowTopologies.stream();
}
flowIds.add(flowId);
var flowTopologies = flowTopologyRepository.get().findByFlow(tenantId, namespace, id, destinationOnly);
return flowTopologies.stream()
.flatMap(topology -> Stream.of(topology.getDestination(), topology.getSource()))
// recursively fetch child nodes
.flatMap(node -> recursiveFlowTopology(flowIds, node.getTenantId(), node.getNamespace(), node.getId(), destinationOnly));
// ignore already visited topologies
.filter(x -> !visitedTopologies.contains(x.uid()))
.flatMap(topology -> {
visitedTopologies.add(topology.uid());
Stream<FlowTopology> subTopologies = Stream
.of(topology.getDestination(), topology.getSource())
// recursively visit children and parents nodes
.flatMap(relationNode -> recursiveFlowTopology(visitedTopologies, relationNode.getTenantId(), relationNode.getNamespace(), relationNode.getId(), destinationOnly));
return Stream.concat(Stream.of(topology), subTopologies);
});
}
private IllegalStateException noRepositoryException() {

View File

@@ -19,6 +19,7 @@ import io.kestra.core.queues.QueueInterface;
import io.kestra.core.runners.RunContext;
import io.kestra.core.serializers.JacksonMapper;
import io.kestra.core.utils.IdUtils;
import io.kestra.core.utils.RetryUtils;
import io.kestra.core.utils.TestsUtils;
import io.micronaut.context.ApplicationContext;
import io.micronaut.http.HttpStatus;
@@ -109,7 +110,8 @@ class HttpClientTest {
return builder.build();
}
@RetryingTest(5) // Flaky on CI but never locally even with 100 repetitions
@RetryingTest(5)
// Flaky on CI but never locally even with 100 repetitions
void getText() throws IllegalVariableEvaluationException, HttpClientException, IOException {
Flow flow = TestsUtils.mockFlow();
Execution execution = TestsUtils.mockExecution(flow, Map.of());
@@ -292,7 +294,7 @@ class HttpClientTest {
Map<String, Object> multipart = Map.of(
"ping", "pong",
"int", 1,
"file", new File(Objects.requireNonNull(this.getClass().getClassLoader().getResource("logback.xml")).toURI()),
"file", new File(Objects.requireNonNull(this.getClass().getClassLoader().getResource("logback.xml")).toURI()),
"inputStream", new ByteArrayInputStream(IOUtils.toString(
Objects.requireNonNull(this.getClass().getClassLoader().getResourceAsStream("logback.xml")),
StandardCharsets.UTF_8
@@ -310,8 +312,7 @@ class HttpClientTest {
assertThat(response.getBody().get("ping")).isEqualTo("pong");
assertThat(response.getBody().get("int")).isEqualTo("1");
assertThat((String) response.getBody().get("file")).contains("logback");
// @FIXME: Request seems to be correct, but not returned by micronaut
// assertThat((String) response.getBody().get("inputStream"), containsString("logback"));
assertThat((String) response.getBody().get("inputStream")).contains("logback");
assertThat(response.getHeaders().firstValue(HttpHeaders.CONTENT_TYPE).orElseThrow()).isEqualTo(MediaType.APPLICATION_JSON);
}
}
@@ -321,12 +322,14 @@ class HttpClientTest {
try (HttpClient client = client()) {
URI uri = URI.create("http://localhost:1234");
HttpClientRequestException e = assertThrows(HttpClientRequestException.class, () -> {
RetryUtils.RetryFailed retryFailed = assertThrows(RetryUtils.RetryFailed.class, () -> {
client.request(HttpRequest.of(uri));
});
assertThat(e.getRequest().getUri()).isEqualTo(uri);
assertThat(e.getMessage()).contains("Connection refused");
Throwable cause = retryFailed.getCause();
assertThat(cause).isInstanceOf(org.apache.hc.client5.http.HttpHostConnectException.class);
var e = (org.apache.hc.client5.http.HttpHostConnectException) cause;
assertThat(e.getMessage()).contains("Connect to http://localhost:1234 failed");
}
}

View File

@@ -1,13 +1,24 @@
package io.kestra.core.runners;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.flows.DependsOn;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.flows.Type;
import io.kestra.core.models.flows.input.BoolInput;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.property.PropertyContext;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.runners.pebble.functions.SecretFunction;
import io.kestra.core.utils.IdUtils;
import io.micronaut.context.ApplicationContext;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -112,4 +123,25 @@ class RunVariablesTest {
assertThat(kestra.get("environment")).isEqualTo("test");
assertThat(kestra.get("url")).isEqualTo("http://localhost:8080");
}
}
@Test
void nonResolvableDynamicInputsShouldBeSkipped() throws IllegalVariableEvaluationException {
Map<String, Object> variables = new RunVariables.DefaultBuilder()
.withFlow(Flow
.builder()
.namespace("a.b")
.id("c")
.inputs(List.of(
BoolInput.builder().id("a").type(Type.BOOL).defaults(Property.ofValue(true)).build(),
BoolInput.builder().id("b").type(Type.BOOL).dependsOn(new DependsOn(List.of("a"), null)).defaults(Property.ofExpression("{{inputs.a == true}}")).build()
))
.build()
)
.withExecution(Execution.builder().id(IdUtils.create()).build())
.build(new RunContextLogger(), PropertyContext.create(new VariableRenderer(Mockito.mock(ApplicationContext.class), Mockito.mock(VariableRenderer.VariableConfiguration.class), Collections.emptyList())));
Assertions.assertEquals(Map.of(
"a", true
), variables.get("inputs"));
}
}

View File

@@ -1,6 +1,7 @@
package io.kestra.core.server;
import io.kestra.core.contexts.KestraContext;
import io.kestra.core.models.ServerType;
import io.kestra.core.utils.IdUtils;
import io.kestra.core.utils.Network;
import org.junit.jupiter.api.Assertions;
@@ -25,6 +26,7 @@ import java.util.Set;
import static io.kestra.core.server.ServiceStateTransition.Result.ABORTED;
import static io.kestra.core.server.ServiceStateTransition.Result.FAILED;
import static io.kestra.core.server.ServiceStateTransition.Result.SUCCEEDED;
import static org.mockito.Mockito.when;
@ExtendWith({MockitoExtension.class})
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -59,6 +61,8 @@ public class ServiceLivenessManagerTest {
);
KestraContext context = Mockito.mock(KestraContext.class);
KestraContext.setContext(context);
when(context.getServerType()).thenReturn(ServerType.INDEXER);
this.serviceLivenessManager = new ServiceLivenessManager(
config,
new ServiceRegistry(),
@@ -100,8 +104,7 @@ public class ServiceLivenessManagerTest {
);
// mock the state transition result
Mockito
.when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
.thenReturn(response);
// When
@@ -127,8 +130,7 @@ public class ServiceLivenessManagerTest {
);
// mock the state transition result
Mockito
.when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
.thenReturn(response);
// When
@@ -147,8 +149,7 @@ public class ServiceLivenessManagerTest {
serviceLivenessManager.updateServiceInstance(running, serviceInstanceFor(running));
// mock the state transition result
Mockito
.when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
when(serviceLivenessUpdater.update(Mockito.any(ServiceInstance.class), Mockito.any(Service.ServiceState.class)))
.thenReturn(new ServiceStateTransition.Response(ABORTED));
// When

View File

@@ -111,4 +111,11 @@ class SanityCheckTest {
assertThat(execution.getTaskRunList()).hasSize(6);
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
}
@Test
@ExecuteFlow("sanity-checks/output_values.yaml")
void qaOutputValues(Execution execution) {
assertThat(execution.getTaskRunList()).hasSize(2);
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
}
}

View File

@@ -0,0 +1,304 @@
package io.kestra.core.topologies;
import io.kestra.core.exceptions.FlowProcessingException;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.flows.FlowWithSource;
import io.kestra.core.models.topologies.FlowTopology;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
import io.kestra.core.services.FlowService;
import io.kestra.core.utils.IdUtils;
import jakarta.inject.Inject;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
public class FlowTopologyTest {
@Inject
private FlowService flowService;
@Inject
private FlowTopologyService flowTopologyService;
@Inject
private FlowTopologyRepositoryInterface flowTopologyRepository;
@Test
void should_findDependencies_simpleCase() throws FlowProcessingException {
// Given
var tenantId = randomTenantId();
var child = flowService.importFlow(tenantId,
"""
id: child
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
""");
var parent = flowService.importFlow(tenantId, """
id: parent
namespace: io.kestra.unittest
tasks:
- id: subflow
type: io.kestra.core.tasks.flows.Flow
flowId: child
namespace: io.kestra.unittest
""");
var unrelatedFlow = flowService.importFlow(tenantId, """
id: unrelated_flow
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
""");
// When
computeAndSaveTopologies(List.of(child, parent, unrelatedFlow));
System.out.println();
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
var dependencies = flowService.findDependencies(tenantId, "io.kestra.unittest", parent.getId(), false, true);
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
// Then
assertThat(dependencies.map(FlowTopologyTestData::of))
.containsExactlyInAnyOrder(
new FlowTopologyTestData(parent, child)
);
}
@Test
void should_findDependencies_subchildAndSuperParent() throws FlowProcessingException {
// Given
var tenantId = randomTenantId();
var subChild = flowService.importFlow(tenantId,
"""
id: sub_child
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
""");
var child = flowService.importFlow(tenantId,
"""
id: child
namespace: io.kestra.unittest
tasks:
- id: subflow
type: io.kestra.core.tasks.flows.Flow
flowId: sub_child
namespace: io.kestra.unittest
""");
var superParent = flowService.importFlow(tenantId, """
id: super_parent
namespace: io.kestra.unittest
tasks:
- id: subflow
type: io.kestra.core.tasks.flows.Flow
flowId: parent
namespace: io.kestra.unittest
""");
var parent = flowService.importFlow(tenantId, """
id: parent
namespace: io.kestra.unittest
tasks:
- id: subflow
type: io.kestra.core.tasks.flows.Flow
flowId: child
namespace: io.kestra.unittest
""");
var unrelatedFlow = flowService.importFlow(tenantId, """
id: unrelated_flow
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
""");
// When
computeAndSaveTopologies(List.of(subChild, child, superParent, parent, unrelatedFlow));
System.out.println();
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
System.out.println();
var dependencies = flowService.findDependencies(tenantId, "io.kestra.unittest", parent.getId(), false, true);
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
// Then
assertThat(dependencies.map(FlowTopologyTestData::of))
.containsExactlyInAnyOrder(
new FlowTopologyTestData(superParent, parent),
new FlowTopologyTestData(parent, child),
new FlowTopologyTestData(child, subChild)
);
}
@Test
void should_findDependencies_cyclicTriggers() throws FlowProcessingException {
// Given
var tenantId = randomTenantId();
var triggeredFlowOne = flowService.importFlow(tenantId,
"""
id: triggered_flow_one
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
triggers:
- id: listen
type: io.kestra.plugin.core.trigger.Flow
conditions:
- type: io.kestra.plugin.core.condition.ExecutionStatus
in:
- FAILED
""");
var triggeredFlowTwo = flowService.importFlow(tenantId, """
id: triggered_flow_two
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
triggers:
- id: listen
type: io.kestra.plugin.core.trigger.Flow
conditions:
- type: io.kestra.plugin.core.condition.ExecutionStatus
in:
- FAILED
""");
// When
computeAndSaveTopologies(List.of(triggeredFlowOne, triggeredFlowTwo));
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
var dependencies = flowService.findDependencies(tenantId, "io.kestra.unittest", triggeredFlowTwo.getId(), false, true).toList();
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
// Then
assertThat(dependencies.stream().map(FlowTopologyTestData::of))
.containsExactlyInAnyOrder(
new FlowTopologyTestData(triggeredFlowTwo, triggeredFlowOne),
new FlowTopologyTestData(triggeredFlowOne, triggeredFlowTwo)
);
}
@Test
void flowTriggerWithTargetFlow() throws FlowProcessingException {
// Given
var tenantId = randomTenantId();
var parent = flowService.importFlow(tenantId,
"""
id: parent
namespace: io.kestra.unittest
inputs:
- id: a
type: BOOL
defaults: true
- id: b
type: BOOL
defaults: "{{ inputs.a == true }}"
dependsOn:
inputs:
- a
tasks:
- id: helloA
type: io.kestra.plugin.core.log.Log
message: Hello A
""");
var child = flowService.importFlow(tenantId, """
id: child
namespace: io.kestra.unittest
tasks:
- id: helloB
type: io.kestra.plugin.core.log.Log
message: Hello B
triggers:
- id: release
type: io.kestra.plugin.core.trigger.Flow
states:
- SUCCESS
preconditions:
id: flows
flows:
- namespace: io.kestra.unittest
flowId: parent
""");
var unrelatedFlow = flowService.importFlow(tenantId, """
id: unrelated_flow
namespace: io.kestra.unittest
tasks:
- id: download
type: io.kestra.plugin.core.http.Download
""");
// When
computeAndSaveTopologies(List.of(child, parent, unrelatedFlow));
System.out.println();
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
var dependencies = flowService.findDependencies(tenantId, "io.kestra.unittest", parent.getId(), false, true);
flowTopologyRepository.findAll(tenantId).forEach(topology -> {
System.out.println(FlowTopologyTestData.of(topology));
});
// Then
assertThat(dependencies.map(FlowTopologyTestData::of))
.containsExactlyInAnyOrder(
new FlowTopologyTestData(parent, child)
);
}
/**
* this function mimics the production behaviour
*/
private void computeAndSaveTopologies(List<@NotNull FlowWithSource> flows) {
flows.forEach(flow ->
flowTopologyService
.topology(
flow,
flows
).distinct()
.forEach(topology -> flowTopologyRepository.save(topology))
);
}
private static String randomTenantId() {
return FlowTopologyTest.class + IdUtils.create();
}
record FlowTopologyTestData(String sourceUid, String destinationUid) {
public FlowTopologyTestData(FlowWithSource parent, FlowWithSource child) {
this(parent.uidWithoutRevision(), child.uidWithoutRevision());
}
public static FlowTopologyTestData of(FlowTopology flowTopology) {
return new FlowTopologyTestData(flowTopology.getSource().getUid(), flowTopology.getDestination().getUid());
}
@Override
public String toString() {
return sourceUid + " -> " + destinationUid;
}
}
}

View File

@@ -0,0 +1,27 @@
id: output_values
namespace: sanitychecks.core
variables:
var1: "myvaribale"
var2: 25
tasks:
- id: output_values
type: io.kestra.plugin.core.output.OutputValues
values:
string_value: "hello"
number_value: 42
nested_object:
key1: "value1"
key2: "value2"
text_var: "{{ vars.var1}}"
number_var: "the number value is: {{vars.var2}}"
- id: assert
type: io.kestra.plugin.core.execution.Assert
conditions:
- "{{ outputs.output_values.values.string_value == 'hello'}}"
- "{{ outputs.output_values.values.number_value == 42 }}"
- "{{ outputs.output_values.values.nested_object['key1'] == 'value1' }}"
- "{{ outputs.output_values.values.text_var == 'myvaribale' }}"
- "{{ outputs.output_values.values.number_var == 'the number value is: 25' }}"

4
dev-tools/kestra-devtools/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
coverage
.DS_Store

View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -0,0 +1,12 @@
// @ts-check
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
{
ignores: ["dist/**", "coverage/**", "node_modules/**"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
);

7175
dev-tools/kestra-devtools/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
{
"name": "kestra-devtools-cli",
"version": "1.0.0",
"description": "a CLI tool to run various dev tasks to build, test, release Kestra",
"bin": {
"my-cli": "dist/kestra-devtools-cli.cjs"
},
"main": "dist/kestra-devtools-cli.cjs",
"files": [
"dist/"
],
"scripts": {
"dev": "vitest --watch",
"build": "vite build && tsc -p tsconfig.types.json",
"test": "npm run lint && vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"format": "prettier --write .",
"prepare": "npm run build",
"start": "node dist/kestra-devtools-cli.cjs",
"link": "npm link",
"unlink": "npm unlink -g my-cli || true"
},
"engines": {
"node": ">=18.0.0"
},
"author": "",
"license": "MIT",
"type": "module",
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/node": "^24.3.1",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.43.0",
"vite": "^7.1.5",
"vitest": "^3.2.4"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"fast-xml-parser": "^5.2.5",
"octokit": "^5.0.3"
}
}

View File

@@ -0,0 +1,13 @@
import { Octokit} from "octokit";
export async function commentPR(githubToken: string, owner: string, repo: string, prNumber: number, content: string){
const octokit = new Octokit({ auth: githubToken });
await octokit.rest.issues.createComment({
owner,
repo,
issue_number:prNumber,
body: content,
});
}

View File

@@ -0,0 +1,15 @@
import core from '@actions/core';
import {context} from '@actions/github';
import {strict as assert} from 'assert';
export function getPRContext():{token: string, owner: string, repo: string, prNumber: number}{
const GITHUB_TOKEN = core.getInput('GITHUB_TOKEN') || process.env.GITHUB_TOKEN;
assert.ok(GITHUB_TOKEN, "GITHUB_TOKEN is mandatory");
assert.ok(context.issue);
assert.ok(context.issue.owner);
assert.ok(context.issue.repo);
assert.ok(context.issue.number);
return {token: GITHUB_TOKEN, owner: context.repo.owner, repo: context.repo.repo, prNumber: context.issue.number }
}

View File

@@ -0,0 +1,18 @@
import { describe, it, expect, vi } from "vitest";
import { main } from "./kestra-devtools-cli";
describe("cli tests", () => {
it("prints hello with default", async () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
await main(["node", "cli"]);
expect(spy).toHaveBeenCalledWith("Hello, world!");
spy.mockRestore();
});
it("prints hello with name", async () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
await main(["node", "cli", "Roman"]);
expect(spy).toHaveBeenCalledWith("Hello, Roman!");
spy.mockRestore();
});
});

View File

@@ -0,0 +1,88 @@
// Simple CLI entry point.
// Built to dist/kestra-devtools-cli.cjs with a shebang so it can be executed directly.
import { getWorkingDir } from "./utilities/working-dir";
import {exportTestReportSummary} from "./tests-reporting/export-test-report-summary";
import {getPRContext} from "./github-context";
function parseArgs(argv: string[]) {
// argv[0] = node, argv[1] = script, rest are args
const args = argv.slice(2);
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
flags[k] = v ?? true;
} else if (a.startsWith("-") && a.length > 1) {
const letters = a.slice(1).split("");
letters.forEach((l) => (flags[l] = true));
} else {
positionals.push(a);
}
}
return { flags, positionals };
}
export async function main(argv = process.argv) {
const { flags, positionals } = parseArgs(argv);
if (flags.h || flags.help) {
console.log(`kestra-devtools-cli
Usage:
kestra-devtools-cli [options] [name]
Options:
-h, --help Show help
-v, --version Show version
Examples:
kestra-devtools-cli generateTestReportSummary /Users/roman/Documents/git-repos/kestra --only-errors
`);
return 0;
}
if (positionals[0] === "generateTestReportSummary") {
const dirArg = positionals[1];
if (!dirArg) {
console.error(
"Error: missing working directory argument.\nUsage: kestra-devtools-cli generateTestReportSummary <absolute-path>",
);
return 1;
}
const ci = Boolean(flags["ci"]);
const workingDir = getWorkingDir(dirArg);
const summary = await exportTestReportSummary(workingDir, {
onlyErrors: Boolean(flags["only-errors"]),
githubContext: ci ? getPRContext() : undefined
});
// Print to stdout so it can be piped in CI or viewed in terminal
console.log(summary);
return 0;
}
if (flags.v || flags.version) {
// package.json is not bundled by default; prefer env-injected version if needed.
console.log("kestra-devtools-cli v0.1.0");
return 0;
}
const name = positionals[0] ?? "world";
console.log(`Hello, ${name}!`);
return 0;
}
// If executed directly, run main()
if (import.meta.url === `file://${process.argv[1]}`) {
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,20 @@
import {commentPR} from "../github-api";
import {WorkingDir} from "../utilities/working-dir";
import {generateTestReportSummary} from "./generate-test-report-summary";
import {strict as assert} from 'assert';
export async function exportTestReportSummary(workingDir: WorkingDir, options?: {
onlyErrors?: boolean,
githubContext?: { token: string, owner: string, repo: string, prNumber: number }
}) {
const report = await generateTestReportSummary(workingDir, {onlyErrors: options?.onlyErrors})
if (options?.githubContext) {
assert.ok(options.githubContext.token, "github token is mandatory");
assert.ok(options.githubContext.owner);
assert.ok(options.githubContext.repo);
assert.ok(options.githubContext.prNumber);
await commentPR(options.githubContext.token, options.githubContext.owner, options.githubContext.repo, options.githubContext.prNumber, report);
}
return report;
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getJavaProjectNameFromBuildAbsolutePath } from "./file-path-utils";
describe("test getJavaProjectNameFromBuildAbsolutePath", () => {
it("should work for Kestra modules paths", async () => {
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/Users/roman/Documents/git-repos/kestra/core/build/test-results/junit/TEST-io.kestra.core.validations.ScheduleValidationTest.xml",
),
).toEqual("core");
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/kestra/runner-memory/build/test-results/junit/open-test-report.xml",
),
).toEqual("runner-memory");
expect(
getJavaProjectNameFromBuildAbsolutePath(
"/kestra-ee/executor/build/test-results/junit/open-test-report.xml",
),
).toEqual("executor");
});
});

View File

@@ -0,0 +1,10 @@
export function getJavaProjectNameFromBuildAbsolutePath(absoluteFilePath: string): string {
const parts = absoluteFilePath.split("/");
const buildIndex = parts.lastIndexOf("build");
if (buildIndex > 0) {
return parts[buildIndex - 1];
}
// return full path if not handled
return absoluteFilePath;
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { parseJunitModuleReport } from "./parse-junit-module-report";
describe("parse-junit-report test", () => {
it("parse OK for all tests success", async () => {
const junitReport = `
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="io.kestra.core.validations.ScheduleValidationTest" tests="6" skipped="0" failures="0" errors="0" timestamp="2025-09-11T17:32:18.116Z" hostname="Romans-MacBook-Pro.local" time="0.202">
<properties/>
<testcase name="sundayDayOfTheWeekAlias()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="withSecondsValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="lateMaximumDelayValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="intervalValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<testcase name="nicknameValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.203"/>
<testcase name="cronValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.202"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
`;
const res = parseJunitModuleReport(junitReport);
expect(res).toBeDefined();
expect(res.testsuites).toEqual([
{
name: "io.kestra.core.validations.ScheduleValidationTest",
errors: 0,
failures: 0,
skipped: 0,
success: 6,
tests: 6,
status: "success",
time: 0.202,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "withSecondsValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "lateMaximumDelayValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "intervalValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
{
name: "nicknameValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.203,
status: "success",
},
{
name: "cronValidation()",
classname: "io.kestra.core.validations.ScheduleValidationTest",
time: 0.202,
status: "success",
},
],
},
]);
});
it("parse OK for test in error", async () => {
const junitReport = `
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="io.kestra.core.validations.ScheduleValidationTest" tests="1" skipped="0" failures="1" errors="0" timestamp="2025-09-11T17:56:02.292Z" hostname="Romans-MacBook-Pro.local" time="0.265">
<properties/>
<testcase name="intervalValidation()" classname="io.kestra.core.validations.ScheduleValidationTest" time="0.043">
<failure message="java.lang.RuntimeException: I failed and this is my log" type="java.lang.RuntimeException">java.lang.RuntimeException: I failed and this is my log
\tat io.kestra.core.validations.ScheduleValidationTest.intervalValidation(ScheduleValidationTest.java:93)
\tat java.base/java.lang.reflect.Method.invoke(Method.java:580)
\tat io.micronaut.test.extensions.junit5.MicronautJunit5Extension$2.proceed(MicronautJunit5Extension.java:142)
\tat io.micronaut.test.extensions.AbstractMicronautExtension.interceptEach(AbstractMicronautExtension.java:162)
\tat io.micronaut.test.extensions.AbstractMicronautExtension.interceptTest(AbstractMicronautExtension.java:119)
\tat io.micronaut.test.extensions.junit5.MicronautJunit5Extension.interceptTestMethod(MicronautJunit5Extension.java:129)
\tat java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
\tat java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
\tat java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
\tat java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
\tat java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
</failure>
</testcase>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
`;
const res = parseJunitModuleReport(junitReport);
expect(res.testsuites).length(1);
expect(res.testsuites[0].testcases).length(1);
expect(res.testsuites[0].testcases[0].status).equal("failed");
expect(res.testsuites[0].testcases[0].message).contain("I failed and this is my log");
expect(res.testsuites[0].testcases[0].details).contain("I failed and this is my log");
expect(res.testsuites[0].testcases[0].details).contain("ForkJoinWorkerThread");
});
});

View File

@@ -0,0 +1,241 @@
import { promises as fs } from "node:fs";
import { XMLParser } from "fast-xml-parser";
export type JUnitModuleReport = {
suites: number;
tests: number;
failures: number;
errors: number;
skipped: number;
success: number;
status: "success" | "failed" | "error" | "skipped";
time: number; // total duration in seconds
testsuites: Array<JunitTestSuite>;
};
export interface JunitTestSuite {
name?: string;
tests: number;
failures: number;
errors: number;
skipped: number;
success: number;
status: "success" | "failed" | "error" | "skipped";
time: number;
testcases: Array<JunitTestCase>;
}
export interface JunitTestCase {
classname?: string;
name: string;
time?: number;
status: "success" | "failed" | "error" | "skipped";
message?: string;
type?: string;
details?: string;
}
// for more info on the Junit test report format = https://github.com/testmoapp/junitxml
export function parseJunitModuleReport(xml: string): JUnitModuleReport {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
allowBooleanAttributes: true,
parseAttributeValue: true,
trimValues: false,
});
const obj = parser.parse(xml);
// JUnit can be either <testsuites> or a single <testsuite>
const rawSuites = obj?.testsuites?.testsuite ?? obj?.testsuite ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const suites = toArray<any>(rawSuites);
const report: JUnitModuleReport = {
suites: suites.length,
tests: 0,
failures: 0,
errors: 0,
skipped: 0,
success: 0,
status: "success",
time: 0,
testsuites: [],
};
for (const s of suites) {
const name: string | undefined = s.name;
// Attributes may exist on the suite OR we may need to infer from testcases
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testcases = toArray<any>(s.testcase ?? []);
const suiteCounts = {
tests: numeric(s.tests, testcases.length),
failures: numeric(s.failures, 0),
errors: numeric(s.errors, 0),
skipped: numeric(s.skipped, 0),
time: numeric(s.time, sum(testcases.map((tc) => numeric(tc.time, 0)))),
};
// If suite attributes missing, infer from testcases
if (
!isFiniteNumber(s.failures) ||
!isFiniteNumber(s.errors) ||
!isFiniteNumber(s.skipped)
) {
let f = 0,
e = 0,
sk = 0;
for (const tc of testcases) {
if (hasKey(tc, "failed")) f += toArray(tc.failed).length;
if (hasKey(tc, "error")) e += toArray(tc.error).length;
if (hasKey(tc, "skipped")) sk += toArray(tc.skipped).length || 1; // some producers put empty <skipped/>
}
if (!isFiniteNumber(suiteCounts.failures)) suiteCounts.failures = f;
if (!isFiniteNumber(suiteCounts.errors)) suiteCounts.errors = e;
if (!isFiniteNumber(suiteCounts.skipped)) suiteCounts.skipped = sk;
}
const successCount =
suiteCounts.tests - suiteCounts.errors - suiteCounts.failures - suiteCounts.skipped;
let suiteStatus: "success" | "failed" | "error" | "skipped" = "success";
if (suiteCounts.skipped === suiteCounts.tests) {
suiteStatus = "skipped";
} else if (suiteCounts.errors > 0) {
suiteStatus = "error";
} else if (suiteCounts.failures > 0) {
suiteStatus = "failed";
}
const suiteDetail: JunitTestSuite = {
name,
tests: suiteCounts.tests,
failures: suiteCounts.failures,
errors: suiteCounts.errors,
skipped: suiteCounts.skipped,
success: successCount,
status: suiteStatus,
time: suiteCounts.time,
testcases: [],
};
// Collect failed tests and build suiteDetail.testcases
for (const tc of testcases) {
const classname: string | undefined = tc.classname;
const nameTc: string = tc.name;
const time: number | undefined = isFiniteNumber(tc.time) ? Number(tc.time) : undefined;
// Determine status
if (tc.failure) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "failed",
message: tc.failure.message,
type: tc.failure.type,
details: textContent(tc.failure),
});
} else if (tc.error) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "error",
message: tc.error.message,
type: tc.error.message.type,
details: textContent(tc.error),
});
} else if (tc.skipped) {
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "skipped",
message: tc.skipped.message,
details: textContent(tc.skipped),
});
} else {
// success test
suiteDetail.testcases.push({
classname,
name: nameTc,
time,
status: "success",
});
}
}
report.tests += suiteCounts.tests;
report.failures += suiteCounts.failures;
report.errors += suiteCounts.errors;
report.skipped += suiteCounts.skipped;
report.success += suiteDetail.success;
report.time += suiteCounts.time;
report.testsuites.push(suiteDetail);
}
if (report.skipped === report.tests) {
report.status = "skipped";
} else if (report.errors > 0) {
report.status = "error";
} else if (report.failures > 0) {
report.status = "failed";
} else {
report.status = "success";
}
return report;
}
/**
* Convenience: parse a file from disk.
*/
export async function summarizeJunitReportFromFile(filePath: string): Promise<JUnitModuleReport> {
const xml = await fs.readFile(filePath, "utf8");
return parseJunitModuleReport(xml);
}
// -------------------- helpers --------------------
function toArray<T>(v: T | T[] | undefined | null): T[] {
if (v == null) return [];
return Array.isArray(v) ? v : [v];
}
function numeric<T>(value: T, fallback = 0): number {
const n = Number(value as unknown);
return Number.isFinite(n) ? n : fallback;
}
function sum(nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
function isFiniteNumber(v: unknown): v is number {
const n = Number(v);
return Number.isFinite(n);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
// Some producers put the text of <failed> / <error> inside `#text` or as the value itself.
function textContent(node: unknown): string | undefined {
if (node == null) return undefined;
if (typeof node === "string") return node;
if (isRecord(node) && typeof node["#text"] === "string") {
return node["#text"] as string;
}
return undefined;
}
function hasKey<O>(obj: O, key: PropertyKey): key is keyof O {
return obj != null && Object.prototype.hasOwnProperty.call(obj, key);
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { summarizeJunitReport, TestReport } from "./summarize-junit-report";
describe("summarize-junit-report test", () => {
const testReportsWithGreenTests: TestReport[] = [
{
projectName: "java-module-1",
projectReport: {
errors: 0,
skipped: 0,
failures: 0,
success: 1,
status: "success",
tests: 1,
time: 3,
suites: 1,
testsuites: [
{
name: "io.kestra.core.some.Test",
errors: 0,
skipped: 0,
failures: 0,
success: 1,
status: "success",
tests: 1,
time: 3,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.some.Test",
time: 3,
status: "success",
},
],
},
],
},
},
];
it("summarizeJunitReport for one green module", async () => {
const res = summarizeJunitReport(testReportsWithGreenTests);
expect(res.hasErrors).equal(false);
expect(res.markdownContent).contains("java-module-1");
expect((res.markdownContent.match(/java-module-1/g) || []).length).toBe(2);// should appear twice
expect(res.markdownContent).contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("io.kestra.core.some.Test");
});
it("summarizeJunitReport for one green module should not print tests when onlyErrors:true", async () => {
const res = summarizeJunitReport(testReportsWithGreenTests, { onlyErrors: true });
expect(res.hasErrors).equal(false);
expect(res.markdownContent).contains("java-module-1");
expect(res.markdownContent).not.contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).not.contains(
"io.kestra.core.validations.ScheduleValidationTest",
);
});
const testReportWithFailedTests: TestReport[] = [
{
projectName: "java-module-1",
projectReport: {
errors: 0,
skipped: 0,
failures: 1,
success: 1,
status: "failed",
tests: 2,
time: 3,
suites: 1,
testsuites: [
{
name: "io.kestra.core.someother.Test2",
errors: 0,
skipped: 0,
failures: 1,
success: 1,
status: "failed",
tests: 2,
time: 3,
testcases: [
{
name: "sundayDayOfTheWeekAlias()",
classname: "io.kestra.core.someother.Test2",
time: 3,
status: "success",
},
{
name: "failingTest()",
classname: "io.kestra.core.someother.Test2",
time: 3,
status: "failed",
message: "java.lang.RuntimeException: I failed and this is my log",
details: "this is the error logs details",
},
],
},
],
},
},
];
it("summarizeJunitReport for failed tests should summarize all by default without details", async () => {
const res = summarizeJunitReport(testReportWithFailedTests);
expect(res.hasErrors).equal(true);
expect(res.markdownContent).contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("failingTest()");
expect(res.markdownContent).contains(
"java.lang.RuntimeException: I failed and this is my log",
);
expect(res.markdownContent).not.contains("this is the error logs details");
});
it("summarizeJunitReport for failed tests should summarize only errors with details when onlyErrors:true", async () => {
const res = summarizeJunitReport(testReportWithFailedTests, { onlyErrors: true });
expect(res.hasErrors).equal(true);
expect(res.markdownContent).not.contains("sundayDayOfTheWeekAlias()");
expect(res.markdownContent).contains("failingTest()");
expect(res.markdownContent).contains(
"java.lang.RuntimeException: I failed and this is my log",
);
expect(res.markdownContent).contains("this is the error logs details");
});
it("summarizeJunitReport should merge module reports", async () => {
// given 2 reports for the same module, but for different tests
const reports = [...testReportsWithGreenTests, ...testReportWithFailedTests]
const res = summarizeJunitReport(reports, { onlyErrors: true });
expect(res.hasErrors).equal(true);
expect(res.markdownContent).contain("java-module-1");
// it should not be duplicated
expect((res.markdownContent.match(/java-module-1/g) || []).length).toBe(2);
});
});

View File

@@ -0,0 +1,183 @@
import { JUnitModuleReport } from "./parse-junit-module-report";
export type MarkdownString = string;
export interface TestReport {
projectName: string;
projectReport: JUnitModuleReport;
}
export interface TestReportSummary {
hasErrors: boolean;
markdownContent: MarkdownString;
}
export function summarizeJunitReport(
testReports: TestReport[],
options?: { onlyErrors: boolean },
): TestReportSummary {
const onlyErrors = options?.onlyErrors ?? false;
const testReportQuickSummaryRows: string[] = [];
const testReportDetailsRows: string[] = [];
const testReportErrorLogs: string[] = [];
let hasErrors = false;
const mergedReports = mergeSameProjectReports(testReports);
for (const report of mergedReports) {
const project = report.projectName;
const projectReport: JUnitModuleReport = report.projectReport;
testReportQuickSummaryRows.push(
`| ${escapePipe(report.projectName)} | ${escapePipe(mapStatusToEmoji(projectReport.status))} | ${escapePipe(projectReport.success)} | ${escapePipe(projectReport.skipped)} | ${projectReport.errors + projectReport.failures} |`,
);
for (const testsuite of projectReport.testsuites) {
for (const testcase of testsuite.testcases) {
const name = testcase.name ?? "";
const duration = safeNum(testcase.time);
const failed = testcase.status === "failed" || testcase.status === "error";
if (failed) hasErrors = true;
if (onlyErrors) {
// then only print errors, and details like logs
if (failed) {
const message = testcase.message ?? "";
const details = testcase.details ? "\n\n" + testcase.details : "";
testReportErrorLogs.push(
`${escapePipe(project)} > ${escapePipe(testsuite.name)} > ${escapePipe(name)} ${mapStatusToEmoji(testcase.status)} in ${duration}:
\n${codeBlock(message + details)}`,
);
}
} else {
testReportDetailsRows.push(
`| ${escapePipe(project)} | ${escapePipe(testsuite.name)} | ${escapePipe(name)} | ${mapStatusToEmoji(testcase.status)} | ${duration} | ${escapePipe(truncate(testcase.message ?? "", 200))} |`,
);
}
}
}
}
let markdownContent = "## Tests report quick summary:";
markdownContent =
markdownContent +
`\n| Project | Status | Success | Skipped | Failed |\n|---|---|---|---|---|`;
markdownContent = markdownContent + "\n" + [...testReportQuickSummaryRows].join("\n");
if (testReportDetailsRows.length > 0) {
markdownContent = markdownContent + "\n\n" + "## Tests report details:";
const header = `| Project | Suite | Test | Status | Duration (s) | Message |\n|---|---|---|---|---:|---|`;
markdownContent = markdownContent + "\n" + [header, ...testReportDetailsRows].join("\n");
}
if (testReportErrorLogs.length > 0) {
markdownContent = markdownContent + "\n## Failed tests:";
markdownContent = markdownContent + "\n" + [...testReportErrorLogs].join("\n");
}
return { hasErrors, markdownContent };
// merge reports that share the same projectName by concatenating testsuites
function mergeSameProjectReports(reports: TestReport[]): TestReport[] {
const byProject = new Map<string, JUnitModuleReport>();
for (const r of reports) {
const key = r.projectName;
const existing = byProject.get(key);
if (!existing) {
// clone a shallow copy so we don't mutate the original
const cloned: JUnitModuleReport = {
...r.projectReport,
testsuites: [...r.projectReport.testsuites],
} as JUnitModuleReport;
computeModuleAggregates(cloned);
byProject.set(key, cloned);
} else {
// concatenate testsuites and recompute aggregates
existing.testsuites = [...existing.testsuites, ...r.projectReport.testsuites];
computeModuleAggregates(existing);
}
}
// rebuild TestReport array
return Array.from(byProject.entries()).map(([projectName, projectReport]) => ({
projectName,
projectReport,
}));
}
// recompute success/skip/error/failure counts and overall status from testcases
function computeModuleAggregates(moduleReport: JUnitModuleReport): void {
let success = 0;
let skipped = 0;
let errors = 0;
let failures = 0;
for (const suite of moduleReport.testsuites) {
for (const tc of suite.testcases) {
switch (tc.status) {
case "success":
success++; break;
case "skipped":
skipped++; break;
case "error":
errors++; break;
case "failed":
failures++; break;
}
}
}
const total = success + skipped + errors + failures;
// update known aggregate fields if present on the type
moduleReport.success = success;
moduleReport.skipped = skipped;
moduleReport.errors = errors;
moduleReport.failures = failures;
if ("tests" in moduleReport) {
moduleReport.tests = total;
}
// status rules: all skipped => skipped; any error => error; any failed => failed; else success
let status: "success" | "failed" | "error" | "skipped";
if (total > 0 && skipped === total) status = "skipped";
else if (errors > 0) status = "error";
else if (failures > 0) status = "failed";
else status = "success";
moduleReport.status = status;
}
// helpers scoped below
function escapePipe(s: string | number | undefined): string {
const str = s == null ? "" : String(s);
// escape pipe and newlines for markdown table cells
return str.replace(/\|/g, "\\|").replace(/\r?\n/g, " ↵ ");
}
function codeBlock(s: string | number | undefined): string {
const str = s == null ? "" : String(s);
return `\`\`\`\n${str}\n\`\`\`\n`;
}
function truncate(s: string, max: number): string {
return s && s.length > max ? s.slice(0, max - 1) + "…" : s || "";
}
function safeNum(v: number | undefined): string {
if (v === undefined || v === null) return "";
const n = typeof v === "number" ? v : Number(String(v));
if (Number.isFinite(n)) return n.toFixed(3).replace(/\.000$/, "");
return String(v);
}
function mapStatusToEmoji(status: "success" | "failed" | "error" | "skipped"): string {
switch (status) {
case "failed":
return "failed ❌";
case "error":
return "error ❌";
case "skipped":
return "skipped ⏭️";
case "success":
return "success ✅";
default:
throw new Error("Unhandled case");
}
}
}

View File

@@ -0,0 +1,43 @@
import {WorkingDir} from "../utilities/working-dir";
import {MarkdownString, summarizeJunitReport, TestReport,} from "./functions/summarize-junit-report";
import {parseJunitModuleReport} from "./functions/parse-junit-module-report";
import fg from "fast-glob";
import fs from "fs";
import {getJavaProjectNameFromBuildAbsolutePath} from "./functions/file-path-utils";
/**
* parse files located at 'testReportsLocationPattern' and generate a summary in Markdown
* @param workingDir
* @param options
*/
export async function generateTestReportSummary(
workingDir: WorkingDir,
options?: {
onlyErrors?: boolean;
testReportsLocationPattern?: "**/build/test-results/junit/*.xml";
},
): Promise<MarkdownString> {
const onlyErrors = options?.onlyErrors ?? false;
const pattern = options?.testReportsLocationPattern ?? "**/build/test-results/junit/*.xml";
// Find matching report files under the provided working directory
const junitXmlReportsFilenames = await fg.async(pattern, {
cwd: workingDir,
absolute: true,
onlyFiles: true,
dot: true,
followSymbolicLinks: true,
});
// Parse each JUnit report into a module-level structure
const moduleReports: TestReport[] = junitXmlReportsFilenames.map((file) => {
const content = fs.readFileSync(file, "utf-8");
return {
projectName: getJavaProjectNameFromBuildAbsolutePath(file),
projectReport: parseJunitModuleReport(content),
};
});
// Summarize all parsed reports into a single Markdown string
return summarizeJunitReport(moduleReports, {onlyErrors: onlyErrors}).markdownContent;
}

View File

@@ -0,0 +1,30 @@
import fs from "fs";
import path from "path";
export type WorkingDir = string;
/**
* helper to handle working dir passed in CLI
* @param workingDir by default the repository root
*/
export function getWorkingDir(workingDir?: string): WorkingDir {
if (!workingDir) {
throw new Error(
"an absolute working dir is for required, this can be improved for better DX",
);
}
if (!path.isAbsolute(workingDir)) {
throw new Error(`Working directory must be an absolute path: ${workingDir}`);
}
if (!fs.existsSync(workingDir)) {
throw new Error(`Working directory does not exist: ${workingDir}`);
}
const stat = fs.statSync(workingDir);
if (!stat.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDir}`);
}
return workingDir;
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "vitest"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts", "eslint.config.js"]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import { builtinModules } from "node:module";
// ensure Node-builtins stay external
const externals = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)];
export default defineConfig({
build: {
target: "node18",
outDir: "dist",
emptyOutDir: true,
lib: {
entry: "src/kestra-devtools-cli.ts",
formats: ["cjs"],
fileName: () => "kestra-devtools-cli.cjs",
},
rollupOptions: {
external: externals,
output: {
// Make the output an executable CLI
banner: "#!/usr/bin/env node",
},
},
},
});

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
reporter: ["text", "html"],
reportsDirectory: "coverage",
},
},
});

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "kestra",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -20,8 +20,9 @@ dependencies {
def kafkaVersion = "4.1.0"
def opensearchVersion = "3.2.0"
def opensearchRestVersion = "3.2.0"
def flyingSaucerVersion = "9.13.3"
def jacksonVersion = "2.19.2"
def flyingSaucerVersion = "10.0.0"
def jacksonVersion = "2.20.0"
def jacksonAnnotationsVersion = "2.20"
def jugVersion = "5.1.0"
def langchain4jVersion = "1.4.0"
def langchain4jCommunityVersion = "1.4.0-beta10"
@@ -33,8 +34,8 @@ dependencies {
api platform("io.qameta.allure:allure-bom:2.29.1")
// we define cloud bom here for GCP, Azure and AWS so they are aligned for all plugins that use them (secret, storage, oss and ee plugins)
api platform('com.google.cloud:libraries-bom:26.67.0')
api platform("com.azure:azure-sdk-bom:1.2.37")
api platform('software.amazon.awssdk:bom:2.33.2')
api platform("com.azure:azure-sdk-bom:1.2.38")
api platform('software.amazon.awssdk:bom:2.33.5')
api platform("dev.langchain4j:langchain4j-bom:$langchain4jVersion")
api platform("dev.langchain4j:langchain4j-community-bom:$langchain4jCommunityVersion")
@@ -51,7 +52,7 @@ dependencies {
// ugly hack for jackson: as enforcing platform didn't work (it didn't enforce everywhere, not in plugins), we had to force all jackson libs individually.
api("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
api("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
api("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion")
api("com.fasterxml.jackson.core:jackson-annotations:$jacksonAnnotationsVersion")
api("com.fasterxml.jackson.module:jackson-module-parameter-names:$jacksonVersion")
api("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion")
api("com.fasterxml.jackson.dataformat:jackson-dataformat-smile:$jacksonVersion")
@@ -77,12 +78,12 @@ dependencies {
api 'software.amazon.awssdk.crt:aws-crt:0.38.11'
// we need at least 0.14, it could be removed when Micronaut contains a recent only version in their BOM
api "io.micrometer:micrometer-core:1.15.3"
api "io.micrometer:micrometer-core:1.15.4"
// We need at least 6.17, it could be removed when Micronaut contains a recent only version in their BOM
api "io.micronaut.openapi:micronaut-openapi-bom:6.17.3"
api "io.micronaut.openapi:micronaut-openapi-bom:6.18.0"
// Other libs
api("org.projectlombok:lombok:1.18.38")
api("org.projectlombok:lombok:1.18.40")
api("org.codehaus.janino:janino:3.1.12")
api group: 'org.apache.logging.log4j', name: 'log4j-to-slf4j', version: '2.25.1'
api group: 'org.slf4j', name: 'jul-to-slf4j', version: slf4jVersion

View File

@@ -1,7 +1,6 @@
import {setup} from "@storybook/vue3-vite";
import {withThemeByClassName} from "@storybook/addon-themes";
import initApp from "../src/utils/init";
import stores from "../src/stores/store";
import "../src/styles/vendor.scss";
import "../src/styles/app.scss";
@@ -33,9 +32,14 @@ const preview = {
]
};
setup((app) => {
initApp(app, [], stores, en);
});
setup(async (app) => {
const {piniaStore} = await initApp(app, [], {}, en);
piniaStore.use(({store}) => {
store.$http = {
get: () => Promise.resolve({data: []}),
}
});
})
window.addEventListener("unhandledrejection", (evt) => {

791
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,22 +24,22 @@
},
"dependencies": {
"@js-joda/core": "^5.6.5",
"@kestra-io/ui-libs": "^0.0.245",
"@kestra-io/ui-libs": "^0.0.249",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.46.2",
"@vue-flow/core": "^1.46.3",
"@vueuse/core": "^13.9.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.11.0",
"axios": "^1.12.0",
"bootstrap": "^5.3.8",
"buffer": "^6.0.3",
"chart.js": "^4.5.0",
"core-js": "^3.45.1",
"cronstrue": "^3.2.0",
"cronstrue": "^3.3.0",
"cytoscape": "^3.33.0",
"dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.7",
"element-plus": "2.10.5",
"element-plus": "2.11.2",
"humanize-duration": "^3.33.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
@@ -59,7 +59,7 @@
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.4.149",
"pinia": "^3.0.3",
"posthog-js": "^1.261.6",
"posthog-js": "^1.262.0",
"rapidoc": "^9.3.8",
"semver": "^7.7.2",
"shiki": "^3.12.2",
@@ -67,15 +67,14 @@
"vue": "^3.5.21",
"vue-axios": "^3.5.2",
"vue-chartjs": "^5.3.2",
"vue-gtag": "^3.5.2",
"vue-i18n": "^11.1.11",
"vue-gtag": "^3.6.1",
"vue-i18n": "^11.1.12",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.5.1",
"vue-sidebar-menu": "^5.7.0",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-popper": "^1.5.0",
"vue3-tour": "github:kestra-io/vue3-tour",
"vuex": "^4.1.0",
"xss": "^1.0.15",
"yaml": "^2.8.1"
},
@@ -86,20 +85,20 @@
"@playwright/test": "^1.54.2",
"@rushstack/eslint-patch": "^1.12.0",
"@shikijs/markdown-it": "^3.12.2",
"@storybook/addon-themes": "^9.1.4",
"@storybook/addon-vitest": "^9.1.4",
"@storybook/addon-themes": "^9.1.5",
"@storybook/addon-vitest": "^9.1.5",
"@storybook/test-runner": "^0.23.0",
"@storybook/vue3-vite": "^9.1.4",
"@storybook/vue3-vite": "^9.1.5",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/moment": "^2.11.29",
"@types/node": "^24.2.0",
"@types/node": "^24.3.1",
"@types/nprogress": "^0.2.3",
"@types/path-browserify": "^1.0.3",
"@types/semver": "^7.7.1",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/testing-library__user-event": "^4.1.1",
"@typescript-eslint/parser": "^8.42.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vitest/browser": "^3.2.4",
@@ -110,10 +109,10 @@
"change-case": "5.4.4",
"cross-env": "^10.0.0",
"decompress": "^4.2.1",
"eslint": "^9.34.0",
"eslint-plugin-storybook": "^9.1.4",
"eslint": "^9.35.0",
"eslint-plugin-storybook": "^9.1.5",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.3.0",
"globals": "^16.4.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.6",
@@ -123,13 +122,13 @@
"playwright": "^1.55.0",
"prettier": "^3.6.2",
"rollup-plugin-copy": "^3.5.0",
"sass": "^1.92.0",
"storybook": "^9.1.4",
"sass": "^1.92.1",
"storybook": "^9.1.5",
"storybook-vue3-router": "^6.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.42.0",
"uuid": "^11.1.0",
"typescript-eslint": "^8.43.0",
"uuid": "^13.0.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
},
@@ -137,9 +136,9 @@
"@esbuild/darwin-arm64": "^0.25.9",
"@esbuild/darwin-x64": "^0.25.9",
"@esbuild/linux-x64": "^0.25.9",
"@rollup/rollup-darwin-arm64": "^4.50.0",
"@rollup/rollup-darwin-x64": "^4.50.0",
"@rollup/rollup-linux-x64-gnu": "^4.50.0",
"@rollup/rollup-darwin-arm64": "^4.50.1",
"@rollup/rollup-darwin-x64": "^4.50.1",
"@rollup/rollup-linux-x64-gnu": "^4.50.1",
"@swc/core-darwin-arm64": "^1.13.5",
"@swc/core-darwin-x64": "^1.13.5",
"@swc/core-linux-x64-gnu": "^1.13.5"

View File

@@ -3,17 +3,13 @@
<template #content>
<code>{{ value }}</code>
</template>
<a href="#">
<code :id="uuid" @click="emit('click')" class="text-nowrap" :class="{'link': hasClickListener}">
{{ transformValue }}
</code>
</a>
</el-tooltip>
<a v-else href="#">
<code :id="uuid" class="text-nowrap" @click="onClick">
<code :id="uuid" @click="emit('click')" class="text-nowrap" :class="{'link': hasClickListener}">
{{ transformValue }}
</code>
</a>
</el-tooltip>
<code v-else :id="uuid" class="text-nowrap" @click="onClick">
{{ transformValue }}
</code>
</template>
<script setup lang="ts">
@@ -64,16 +60,10 @@
</script>
<style lang="scss" scoped>
a code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
code.link {
cursor: pointer;
&:hover {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
code.link {
cursor: pointer;
&:hover {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
}
}
}
</style>

View File

@@ -1,145 +1,153 @@
<template>
<el-splitter class="default-theme" v-bind="$attrs" @resize-end="onResize">
<el-splitter-panel
v-for="(panel, panelIndex) in panels"
min="10%"
:key="panelIndex"
:size="panelSizes[panelIndex] ?? panel.size"
@dragover.prevent="(e:DragEvent) => panelDragOver(e, panelIndex)"
@dragleave.prevent="panelDragLeave"
@drop.prevent="(e:DragEvent) => panelDrop(e, panelIndex)"
:class="{'panel-dragover': panel.dragover}"
>
<div class="editor-tabs-container">
<el-button
:icon="DragVertical"
link
class="tab-icon drag-handle"
draggable="true"
@dragstart="(e:DragEvent) => panelDragStart(e, panelIndex)"
/>
<div
class="editor-tabs"
role="tablist"
@dragover.prevent="dragover"
@dragleave.prevent="throttle(removeAllPotentialTabs, 300)"
@drop="drop"
:data-panel-index="panelIndex"
:class="{dragover: panel.dragover}"
ref="tabContainerRefs"
>
<template
v-for="tab in panel.tabs"
:key="tab.value"
>
<button
v-if="!tab.potential"
class="editor-tab"
role="tab"
:class="{active: tab.value === panel.activeTab?.value}"
draggable="true"
@dragstart="(e) => {
if(e.dataTransfer){
e.dataTransfer.effectAllowed = 'move';
}
dragstart(panelIndex, tab.value);
}"
@dragleave.prevent
:data-tab-id="tab.value"
@click="handleTabClick(panel, tab)"
@mouseup="middleMouseClose($event, panelIndex, tab)"
>
<component :is="tab.button.icon" class="tab-icon" />
{{ tab.button.label }}
<CircleMediumIcon v-if="tab.dirty" class="dirty-icon" />
<CloseIcon @click.stop="destroyTab(panelIndex, tab)" class="tab-icon" />
</button>
<div v-else class="potential-container">
<div class="potential" />
</div>
</template>
</div>
<div class="buttons-container">
<button
v-if="panel.tabs.filter(t => !t.potential).length > 1"
@click="splitPanel(panelIndex)"
class="split_right"
title="Split panel"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.038 20.5599C22.0402 21.35 21.4014 21.9924 20.6112 21.9946L3.47196 22.0424C2.6818 22.0446 2.03946 21.4058 2.03725 20.6157L1.98939 3.45824C1.98718 2.66808 2.62595 2.02574 3.41611 2.02353L20.5554 1.97571C21.3455 1.97351 21.9879 2.61228 21.9901 3.40244L22.038 20.5599ZM20.626 20.5807L10.5998 20.6086L10.5517 3.37297L20.5779 3.345L20.626 20.5807ZM9.10343 20.611L3.38734 20.6269L3.33925 3.39126L9.05535 3.37531L9.10343 20.611Z"
fill="currentColor"
/>
</svg>
</button>
<el-dropdown trigger="click" placement="bottom-end">
<el-button :icon="DotsVertical" link class="me-2 tab-icon" />
<template #dropdown>
<el-dropdown-menu class="m-2">
<el-dropdown-item
:icon="DockRight"
:disabled="panelIndex === panels.length - 1"
@click="movePanel(panelIndex, 'right')"
>
<span class="small-text">
{{ t("multi_panel_editor.move_right") }}
</span>
</el-dropdown-item>
<el-dropdown-item
:icon="DockLeft"
:disabled="panelIndex === 0"
@click="movePanel(panelIndex, 'left')"
>
<span class="small-text">
{{ t("multi_panel_editor.move_left") }}
</span>
</el-dropdown-item>
<el-dropdown-item :icon="Close" @click="closeAllTabs(panelIndex)">
<span class="small-text">
{{ t("multi_panel_editor.close_all_tabs") }}
</span>
</el-dropdown-item>
<el-dropdown-item
v-if="panel.activeTab?.value === 'code'"
:icon="Keyboard"
@click="showKeyShortcuts()"
>
<span class="small-text">
{{ t("editor_shortcuts.label") }}
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div
class="content-panel"
:data-panel-index="panelIndex"
@drop="drop"
@dragover.prevent="dragover"
@dragleave.prevent="removeAllPotentialTabs"
@dragenter.prevent
<Empty v-if="!panels.length" type="panels" />
<template v-else>
<el-splitter-panel
v-for="(panel, panelIndex) in panels"
min="10%"
:key="panelIndex"
:size="panelSizes[panelIndex] ?? panel.size"
@dragover.prevent="(e:DragEvent) => panelDragOver(e, panelIndex)"
@dragleave.prevent="panelDragLeave"
@drop.prevent="(e:DragEvent) => panelDrop(e, panelIndex)"
:class="{'panel-dragover': panel.dragover}"
>
<KeepAlive v-if="panel.activeTab">
<component
:key="panel.activeTab.value"
:is="panel.activeTab.component"
:panelIndex="panelIndex"
:tabIndex="panel.tabs.findIndex(t => t.value === panel.activeTab.value)"
<div class="editor-tabs-container">
<el-button
:icon="DragVertical"
link
class="tab-icon drag-handle"
draggable="true"
@dragstart="(e:DragEvent) => panelDragStart(e, panelIndex)"
/>
</KeepAlive>
<div
class="editor-tabs"
role="tablist"
@dragover.prevent="dragover"
@dragleave.prevent="throttle(removeAllPotentialTabs, 300)"
@drop="drop"
:data-panel-index="panelIndex"
:class="{dragover: panel.dragover}"
ref="tabContainerRefs"
>
<template
v-for="tab in panel.tabs"
:key="tab.value"
>
<button
v-if="!tab.potential"
class="editor-tab"
role="tab"
:class="{active: tab.value === panel.activeTab?.value}"
draggable="true"
@dragstart="(e) => {
if(e.dataTransfer){
e.dataTransfer.effectAllowed = 'move';
}
dragstart(panelIndex, tab.value);
}"
@dragleave.prevent
:data-tab-id="tab.value"
@click="handleTabClick(panel, tab)"
@mouseup="middleMouseClose($event, panelIndex, tab)"
>
<component :is="tab.button.icon" class="tab-icon" />
{{ tab.button.label }}
<CircleMediumIcon v-if="tab.dirty" class="dirty-icon" />
<CloseIcon @click.stop="destroyTab(panelIndex, tab)" class="tab-icon" />
</button>
<div v-else class="potential-container">
<div class="potential" />
</div>
</template>
</div>
<div class="buttons-container">
<button
v-if="panel.tabs.filter(t => !t.potential).length > 1"
@click="splitPanel(panelIndex)"
class="split_right"
title="Split panel"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.038 20.5599C22.0402 21.35 21.4014 21.9924 20.6112 21.9946L3.47196 22.0424C2.6818 22.0446 2.03946 21.4058 2.03725 20.6157L1.98939 3.45824C1.98718 2.66808 2.62595 2.02574 3.41611 2.02353L20.5554 1.97571C21.3455 1.97351 21.9879 2.61228 21.9901 3.40244L22.038 20.5599ZM20.626 20.5807L10.5998 20.6086L10.5517 3.37297L20.5779 3.345L20.626 20.5807ZM9.10343 20.611L3.38734 20.6269L3.33925 3.39126L9.05535 3.37531L9.10343 20.611Z"
fill="currentColor"
/>
</svg>
</button>
<el-dropdown trigger="click" placement="bottom-end">
<el-button :icon="DotsVertical" link class="me-2 tab-icon" />
<template #dropdown>
<el-dropdown-menu class="m-2">
<el-dropdown-item
:icon="DockRight"
:disabled="panelIndex === panels.length - 1"
@click="movePanel(panelIndex, 'right')"
>
<span class="small-text">
{{ t("multi_panel_editor.move_right") }}
</span>
</el-dropdown-item>
<el-dropdown-item
:icon="DockLeft"
:disabled="panelIndex === 0"
@click="movePanel(panelIndex, 'left')"
>
<span class="small-text">
{{ t("multi_panel_editor.move_left") }}
</span>
</el-dropdown-item>
<el-dropdown-item v-if="panel.tabs.length > 1" :icon="Close" @click="closeAllTabs(panelIndex)">
<span class="small-text">
{{ t("multi_panel_editor.close_all_tabs") }}
</span>
</el-dropdown-item>
<el-dropdown-item :icon="Close" @click="closeAllPanels()">
<span class="small-text">
{{ t("multi_panel_editor.close_all_panels") }}
</span>
</el-dropdown-item>
<el-dropdown-item
v-if="panel.activeTab?.value === 'code'"
:icon="Keyboard"
@click="showKeyShortcuts()"
>
<span class="small-text">
{{ t("editor_shortcuts.label") }}
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div
v-if="dragging"
class="editor-content-overlay"
:class="{dragover: panel.dragover}"
/>
</div>
</el-splitter-panel>
class="content-panel"
:data-panel-index="panelIndex"
@drop="drop"
@dragover.prevent="dragover"
@dragleave.prevent="removeAllPotentialTabs"
@dragenter.prevent
>
<KeepAlive v-if="panel.activeTab">
<component
:key="panel.activeTab.value"
:is="panel.activeTab.component"
:panelIndex="panelIndex"
:tabIndex="panel.tabs.findIndex(t => t.value === panel.activeTab.value)"
/>
</KeepAlive>
<div
v-if="dragging"
class="editor-content-overlay"
:class="{dragover: panel.dragover}"
/>
</div>
</el-splitter-panel>
</template>
</el-splitter>
<div
@@ -172,6 +180,8 @@
import {CODE_PREFIX} from "./flows/useCodePanels";
import {useKeyShortcuts} from "../utils/useKeyShortcuts";
import Empty from "./layout/empty/Empty.vue";
import CloseIcon from "vue-material-design-icons/Close.vue"
import CircleMediumIcon from "vue-material-design-icons/CircleMedium.vue"
import DragVertical from "vue-material-design-icons/DragVertical.vue";
@@ -180,6 +190,7 @@
import DockRight from "vue-material-design-icons/DockRight.vue";
import Close from "vue-material-design-icons/Close.vue";
import Keyboard from "vue-material-design-icons/Keyboard.vue";
import {useEditorStore} from "../stores/editor";
import {trackTabOpen, trackTabClose} from "../utils/tabTracking";
@@ -518,6 +529,10 @@
panels.value[panelIndex].tabs = [];
}
function closeAllPanels(){
panels.value = [];
}
function destroyTab(panelIndex:number, tab: Tab){
trackTabClose(tab);

View File

@@ -156,8 +156,8 @@
} else {
return {
name: this.routeName || this.$route.name,
params: {...this.$route.params, ...{tab: tab.name}},
query: {...(tab.query || {})}
params: {...this.$route.params, tab: tab.name},
query: {...tab.query}
};
}
},

View File

@@ -128,12 +128,22 @@
/>
</template>
</el-table-column>
<el-table-column v-if="visibleColumns.date" :label="$t('date')">
<el-table-column v-if="visibleColumns.date">
<template #header>
<el-tooltip :content="$t('last trigger date tooltip')" placement="top" popperClass="wide-tooltip">
<span>{{ $t('last trigger date') }}</span>
</el-tooltip>
</template>
<template #default="scope">
<DateAgo :inverted="true" :date="scope.row.date" />
</template>
</el-table-column>
<el-table-column v-if="visibleColumns.updatedDate" :label="$t('updated date')">
<el-table-column v-if="visibleColumns.updatedDate">
<template #header>
<el-tooltip :content="$t('context updated date tooltip')" placement="top" popperClass="wide-tooltip">
<span>{{ $t('context updated date') }}</span>
</el-tooltip>
</template>
<template #default="scope">
<DateAgo :inverted="true" :date="scope.row.updatedDate" />
</template>
@@ -143,8 +153,12 @@
prop="nextExecutionDate"
sortable="custom"
:sortOrders="['ascending', 'descending']"
:label="$t('next execution date')"
>
<template #header>
<el-tooltip :content="$t('next evaluation date tooltip')" placement="top" popperClass="wide-tooltip">
<span>{{ $t('next evaluation date') }}</span>
</el-tooltip>
</template>
<template #default="scope">
<DateAgo :inverted="true" :date="scope.row.nextExecutionDate" />
</template>
@@ -528,16 +542,16 @@
},
visibleColumns() {
const columns = [
{prop: "triggerId", label: this.$t("id")},
{prop: "flowId", label: this.$t("flow")},
{prop: "namespace", label: this.$t("namespace")},
{prop: "executionId", label: this.$t("current execution")},
{prop: "executionCurrentState", label: this.$t("state")},
{prop: "workerId", label: this.$t("workerId")},
{prop: "date", label: this.$t("date")},
{prop: "updatedDate", label: this.$t("updated date")},
{prop: "nextExecutionDate", label: this.$t("next execution date")},
{prop: "evaluateRunningDate", label: this.$t("evaluation lock date")},
{prop: "triggerId"},
{prop: "flowId"},
{prop: "namespace"},
{prop: "executionId"},
{prop: "executionCurrentState"},
{prop: "workerId"},
{prop: "date"},
{prop: "updatedDate"},
{prop: "nextExecutionDate"},
{prop: "evaluateRunningDate"},
];
return columns.reduce((acc, column) => {
@@ -545,9 +559,6 @@
return acc;
}, {});
},
triggerStore() {
return useTriggerStore();
}
}
};
</script>
@@ -599,4 +610,12 @@
color: var(--ks-content-link);
}
}
</style>
<style lang="scss">
.wide-tooltip {
max-width: 400px;
white-space: normal;
word-break: break-word;
color: var(--ks-content-primary) !important;
}
</style>

View File

@@ -17,6 +17,7 @@
v-model="prompt"
@keydown.exact.ctrl.enter="$event.preventDefault(); prompt += '\n'"
@keydown.exact.enter.prevent="submitPrompt"
class="ai-copilot-placeholder"
/>
<template v-else>
<!-- eslint-disable-next-line vue/no-v-text-v-html-on-component -->
@@ -163,4 +164,9 @@
margin-left: 6px;
}
}
.ai-copilot-placeholder :deep(textarea::placeholder) {
color: gray;
font-style: italic;
}
</style>

View File

@@ -68,7 +68,6 @@
<script setup lang="ts">
import {ref, computed} from "vue"
import {useRouter, useRoute} from "vue-router"
import {useStore} from "vuex"
import {useI18n} from "vue-i18n"
import {ElMessage} from "element-plus"
import type {FormInstance} from "element-plus"
@@ -91,7 +90,6 @@
const router = useRouter()
const route = useRoute()
const store = useStore()
const {t} = useI18n()
const coreStore = useCoreStore()
const miscStore = useMiscStore()
@@ -115,7 +113,7 @@
const validateCredentials = async (auth: string) => {
try {
document.cookie = `BASIC_AUTH=${auth};path=/;samesite=strict`;
await axios.get(`${apiUrl(store)}/usages/all`, {
await axios.get(`${apiUrl()}/usages/all`, {
timeout: 10000,
withCredentials: true
})

View File

@@ -131,7 +131,7 @@ export function useChartGenerator(props: {chart: Chart; filters: string[]; showD
const data = ref();
const generate = async (id: string, pagination?: { pageNumber: number; pageSize: number }) => {
const filters = props.filters.concat(decodeSearchParams(route.query, undefined, []) ?? []);
const parameters: Parameters = {...(pagination ?? {}), filters: (filters ?? {})};
const parameters: Parameters = {...pagination, filters: (filters ?? {})};
if (!props.showDefault) {
data.value = await dashboardStore.generate(id, props.chart.id, parameters);

View File

@@ -1,8 +1,8 @@
<template>
<Empty v-if="!loading && !getElements().length" :type="`dependencies.${SUBTYPE}`" />
<Empty v-if="!isLoading && !getElements().length" :type="`dependencies.${SUBTYPE}`" />
<el-splitter v-else class="dependencies">
<el-splitter-panel id="graph" v-bind="PANEL">
<div v-loading="loading" ref="container" />
<div v-loading="isRendering" ref="container" />
<div class="controls">
<el-button
@@ -74,7 +74,7 @@
const initialNodeID: string = SUBTYPE === FLOW || SUBTYPE === NAMESPACE ? String(route.params.id) : String(route.params.flowId);
const TESTING = false; // When true, bypasses API data fetching and uses mock/test data.
const {getElements, loading, selectedNodeID, selectNode, handlers} = useDependencies(container, SUBTYPE, initialNodeID, route.params, TESTING);
const {getElements, isLoading, isRendering, selectedNodeID, selectNode, handlers} = useDependencies(container, SUBTYPE, initialNodeID, route.params, TESTING);
</script>
<style scoped lang="scss">

View File

@@ -260,7 +260,7 @@ function hoverHandler(cy: cytoscape.Core): void {
* @param initialNodeID - Optional ID of the node to preselect after layout completes.
* @param params - Vue Router params, expected to include `id` and `namespace`.
* @param isTesting - When true, bypasses API data fetching and uses mock/test data.
* @returns An object with element getters, loading state, selected node ID,
* @returns An object with element getters, loading state, rendering state, selected node ID,
* selection helpers, and control handlers.
*/
export function useDependencies(container: Ref<HTMLElement | null>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE = FLOW, initialNodeID: string, params: RouteParams, isTesting = false) {
@@ -281,7 +281,8 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
let cy: cytoscape.Core;
const loading = ref(true);
const isLoading = ref(true);
const isRendering = ref(true);
const selectedNodeID: Ref<Node["id"] | undefined> = ref(undefined);
@@ -304,15 +305,23 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
onMounted(async () => {
if (!container.value) return;
if (isTesting) elements.value = {data: getDependencies({subtype}), count: getRandomNumber(1, 100)};
if (isTesting) {
elements.value = {data: getDependencies({subtype}), count: getRandomNumber(1, 100)};
isLoading.value = false;
}
else {
if (subtype === NAMESPACE) {
const {data} = await namespacesStore.loadDependencies({namespace: params.id as string});
const nodes = data.nodes ?? [];
elements.value = {data: transformResponse(data, NAMESPACE), count: new Set(nodes.map((r: { uid: string }) => r.uid)).size};
isLoading.value = false;
} else {
const result = await flowStore.loadDependencies({id: (subtype === FLOW ? params.id : params.flowId) as string, namespace: params.namespace as string, subtype});
elements.value = {data: result.data ?? [], count: result.count};
isLoading.value = false;
}
}
@@ -348,10 +357,9 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
selectHandler(cy, node, selectedNodeID, subtype);
});
cy.on("layoutstop", () => {
loading.value = false;
cy.on("layoutstop", () => {
// Reveal nodes after layout rendering completes
isRendering.value = false;
cy.nodes().style("display", "element");
const node = isTesting ? cy.nodes()[0] : cy.nodes().filter((n) => n.data("flow") === initialNodeID);
@@ -420,7 +428,8 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
return {
getElements: () => elements.value.data,
loading,
isLoading,
isRendering,
selectedNodeID,
selectNode,
handlers: {

View File

@@ -62,6 +62,10 @@
}
}
.main-container {
max-width: 100%;
}
.content {
margin: $spacer;

View File

@@ -76,7 +76,7 @@
import action from "../../models/action";
import {State} from "@kestra-io/ui-libs"
import Status from "../../components/Status.vue";
import ExecutionUtils from "../../utils/executionUtils";
import * as ExecutionUtils from "../../utils/executionUtils";
import {useAuthStore} from "override/stores/auth"
export default {
@@ -107,7 +107,7 @@
})
.then(response => {
if (response.data.id === this.execution.id) {
return ExecutionUtils.waitForState(this.$http, this.$store, response.data);
return ExecutionUtils.waitForState(this.$http, response.data);
} else {
return response.data;
}

View File

@@ -70,7 +70,7 @@
import action from "../../models/action";
import {State} from "@kestra-io/ui-libs"
import Status from "../../components/Status.vue";
import ExecutionUtils from "../../utils/executionUtils";
import * as ExecutionUtils from "../../utils/executionUtils";
import {shallowRef} from "vue";
import {useAuthStore} from "override/stores/auth"
@@ -109,7 +109,7 @@
})
.then(response => {
if (response.data.id === this.execution.id) {
return ExecutionUtils.waitForState(this.$http, this.$store, response.data);
return ExecutionUtils.waitForState(this.$http, response.data);
} else {
return response.data;
}

View File

@@ -69,7 +69,7 @@
return this.executionsStore.execution;
},
finalApiUrl() {
return apiUrl(this.$store);
return apiUrl();
},
canDelete() {
return this.execution && this.authStore.user?.isAllowed(permission.EXECUTION, action.DELETE, this.execution.namespace);

View File

@@ -163,11 +163,19 @@
:label="$t('id')"
>
<template #default="scope">
<Id
:value="scope.row.id"
:shrink="true"
@click="onRowDoubleClick(executionParams(scope.row))"
/>
<RouterLink
:to="{
name: 'executions/update',
params: {
namespace: scope.row.namespace,
flowId: scope.row.flowId,
id: scope.row.id
}
}"
class="execution-id"
>
<Id :value="scope.row.id" :shrink="true" />
</RouterLink>
</template>
</el-table-column>
@@ -482,6 +490,8 @@
import {useAuthStore} from "override/stores/auth.ts";
import {useFlowStore} from "../../stores/flow.ts";
import {defaultNamespace} from "../../composables/useNamespaces";
export default {
mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
components: {
@@ -705,15 +715,12 @@
}
},
beforeRouteEnter(to, _, next) {
const defaultNamespace = localStorage.getItem(
storageKeys.DEFAULT_NAMESPACE,
);
const query = {...to.query};
let queryHasChanged = false;
const queryKeys = Object.keys(query);
if (this?.namespace === undefined && defaultNamespace && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace;
if (this?.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
@@ -1125,16 +1132,8 @@
.code-text {
color: var(--ks-content-primary);
}
</style>
<style lang="scss">
.el-message-box {
padding: 2rem;
max-width: initial;
width: 500px;
.custom-warning {
margin: 1rem 0;
}
:deep(a.execution-id) code {
color: var(--bs-code-color) !important;
}
</style>
</style>

View File

@@ -15,11 +15,14 @@
import {useI18n} from "vue-i18n";
import {useToast} from "../../utils/toast";
import {useRouter, useRoute} from "vue-router";
// @ts-expect-error no types yet
import {inputsToFormData} from "../../utils/submitTask";
import {useExecutionsStore} from "../../stores/executions";
import ExecutionUtils from "../../utils/executionUtils";
import * as ExecutionUtils from "../../utils/executionUtils";
// @ts-expect-error no types yet
import FlowRun from "../../components/flows/FlowRun.vue";
import PlayBoxMultiple from "vue-material-design-icons/PlayBoxMultiple.vue";
import {useAxios} from "../../utils/axios";
const {t} = useI18n();
const toast = useToast();
@@ -38,9 +41,11 @@
const flow = computed(() => executionsStore.flow);
const axios = useAxios()
const handleReplaySubmit = async ({inputs}: any) => {
const formData = inputsToFormData({$http: null, $store: null}, flow.value.inputs, inputs);
const formData = inputsToFormData({}, flow.value.inputs, inputs);
let response = await executionsStore.replayExecutionWithInputs({
executionId: props.execution.id,
taskRunId: props.taskRun?.id,
@@ -49,7 +54,7 @@
});
if (response.data.id === props.execution.id) {
response = await ExecutionUtils.waitForState(null, null, response.data);
response = await ExecutionUtils.waitForState(axios, response.data) as any;
}
const execution = response.data;

View File

@@ -91,13 +91,12 @@
import {useI18n} from "vue-i18n"
import {useToast} from "../../utils/toast"
import {State} from "@kestra-io/ui-libs"
import {useStore} from "vuex"
import {useFlowStore} from "../../stores/flow"
import {useAuthStore} from "override/stores/auth"
import {useExecutionsStore} from "../../stores/executions"
import action from "../../models/action"
import permission from "../../models/permission"
import ExecutionUtils from "../../utils/executionUtils"
import * as ExecutionUtils from "../../utils/executionUtils"
import ReplayWithInputs from "./ReplayWithInputs.vue"
import RestartIcon from "vue-material-design-icons/Restart.vue"
import PlayBoxMultiple from "vue-material-design-icons/PlayBoxMultiple.vue"
@@ -115,7 +114,6 @@
const emit = defineEmits(["follow"])
const {t} = useI18n()
const store = useStore()
const toast = useToast()
const router = useRouter()
const flowStore = useFlowStore()
@@ -214,7 +212,7 @@
})
const execution = response.data.id === props.execution.id && $http
? await ExecutionUtils.waitForState($http, store, response.data)
? await ExecutionUtils.waitForState($http, response.data)
: response.data
executionsStore.execution = execution

View File

@@ -33,7 +33,7 @@
import action from "../../models/action";
import {State} from "@kestra-io/ui-libs"
import FlowUtils from "../../utils/flowUtils";
import ExecutionUtils from "../../utils/executionUtils";
import * as ExecutionUtils from "../../utils/executionUtils";
import InputsForm from "../../components/inputs/InputsForm.vue";
import {inputsToFormData} from "../../utils/submitTask";
import {mapStores} from "pinia";

View File

@@ -71,11 +71,11 @@
}
},
itemUrl(value) {
return `${apiUrl(this.$store)}/executions/${this.execution.id}/file?path=${encodeURI(value)}`;
return `${apiUrl()}/executions/${this.execution.id}/file?path=${encodeURI(value)}`;
},
getFileSize(){
if (this.isFile(this.value)) {
this.$http(`${apiUrl(this.$store)}/executions/${this?.execution?.id}/file/metas?path=${this.value}`, {
this.$http(`${apiUrl()}/executions/${this?.execution?.id}/file/metas?path=${this.value}`, {
validateStatus: (status) => status === 200 || status === 404 || status === 422
}).then(r => this.humanSize = Utils.humanFileSize(r.data.size))
}

View File

@@ -152,7 +152,6 @@
<script setup lang="ts">
import {ref, computed, shallowRef, onMounted, watch} from "vue";
import {ElTree} from "element-plus";
import {useStore} from "vuex";
import {useExecutionsStore} from "../../../stores/executions";
import {usePluginsStore} from "../../../stores/plugins";
@@ -166,22 +165,22 @@
import SubFlowLink from "../../flows/SubFlowLink.vue";
import TimelineTextOutline from "vue-material-design-icons/TimelineTextOutline.vue";
import TextBoxSearchOutline from "vue-material-design-icons/TextBoxSearchOutline.vue";
import {useAxios} from "../../../utils/axios";
const store = useStore();
const {t} = useI18n({useScope: "global"});
const editorValue = ref<string>("");
const debugCollapse = ref<string>("");
const debugEditor = ref<InstanceType<typeof Editor>>();
const debugExpression = ref<string>("");
const computedDebugValue = computed(() => {
const formatTask = (task) => {
const formatTask = (task: string) => {
if (!task) return "";
return task.includes("-") ? `["${task}"]` : `.${task}`;
};
const formatPath = (path) => {
const formatPath = (path: string) => {
if (!path.includes("-")) return `.${path}`;
const bracketIndex = path.indexOf("[");
@@ -210,13 +209,15 @@
const taskRunList = [...execution.value?.taskRunList ?? []];
return taskRunList.find((e) => e.taskId === filter);
};
const axios = useAxios();
const onDebugExpression = (expression: string) => {
const taskRun = selectedTask();
if (!taskRun) return;
const URL = `${apiUrl(store)}/executions/${taskRun?.executionId}/eval/${taskRun.id}`;
store.$http
const URL = `${apiUrl()}/executions/${taskRun?.executionId}/eval/${taskRun.id}`;
axios
.post(URL, expression, {headers: {"Content-type": "text/plain"}})
.then((response) => {
try {
@@ -290,7 +291,7 @@
return {label: trim(data.value), regular: true};
};
const expandedValue = ref([]);
const expandedValue = ref("");
const selected = ref<string[]>([]);
onMounted(() => {

View File

@@ -325,7 +325,7 @@
});
}
let queryEntries = filters.flatMap(({key: key, comparator: comparator, value: value}) => {
let queryEntries = filters.flatMap(({key, comparator, value}) => {
let queryKey = reversedQueryRemapper?.[key] ?? key;
if (!props.legacyQuery) {

View File

@@ -107,7 +107,7 @@
.filter((label) => label.key !== null && label.value !== null && label.key !== "" && label.value !== "")
.map((label) => this.generateExecutionLabel(label.key, label.value));
const origin = baseUrl ? apiUrl(this.$store) : `${location.origin}${basePath(this.$store)}`;
const origin = baseUrl ? apiUrl() : `${location.origin}${basePath()}`;
var url = `${origin}/executions/${this.flow.namespace}/${this.flow.id}`;

View File

@@ -11,7 +11,6 @@
import RouteContext from "../../mixins/routeContext";
import TopNavBar from "../../components/layout/TopNavBar.vue";
import MultiPanelFlowEditorView from "./MultiPanelFlowEditorView.vue";
import {storageKeys} from "../../utils/constants";
import {useBlueprintsStore} from "../../stores/blueprints";
import {useCoreStore} from "../../stores/core";
import {editorViewTypes} from "../../utils/constants";
@@ -19,6 +18,7 @@
import {getRandomID} from "../../../scripts/id";
import {useEditorStore} from "../../stores/editor";
import {useFlowStore} from "../../stores/flow";
import {defaultNamespace} from "../../composables/useNamespaces";
export default {
mixins: [RouteContext],
@@ -50,8 +50,7 @@
} else if (blueprintId && blueprintSource) {
flowYaml = await this.blueprintsStore.getBlueprintSource({type: blueprintSource, kind: "flow", id: blueprintId});
} else {
const defaultNamespace = localStorage.getItem(storageKeys.DEFAULT_NAMESPACE);
const selectedNamespace = this.$route.query.namespace || defaultNamespace || "company.team";
const selectedNamespace = this.$route.query.namespace || defaultNamespace() || "company.team";
flowYaml = `id: ${getRandomID()}
namespace: ${selectedNamespace}

View File

@@ -75,7 +75,7 @@
setTimeout(() => {
this.flowStore
.loadDependencies({namespace: flow.namespace, id: flow.id}, true)
.then(({count}) => this.dependenciesCount = count);
.then(({count}) => this.dependenciesCount = count > 0 ? (count - 1) : 0);
}, 1000);
}
},

View File

@@ -347,7 +347,7 @@
if (flowTriggers) {
const triggers = flowTriggers.map(flowTrigger => {
let pollingTrigger = this.triggers.find(trigger => trigger.triggerId === flowTrigger.id)
return {...flowTrigger, ...(pollingTrigger || {})}
return {...flowTrigger, ...pollingTrigger}
})
return !this.query ? triggers : triggers.filter(trigger => trigger.id.includes(this.query))

View File

@@ -295,7 +295,6 @@
</script>
<script>
import {mapState} from "vuex";
import {mapStores} from "pinia";
import {useExecutionsStore} from "../../stores/executions";
import _merge from "lodash/merge";
@@ -314,7 +313,7 @@
import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
import Kicon from "../Kicon.vue";
import Labels from "../layout/Labels.vue";
import {storageKeys} from "../../utils/constants";
import {defaultNamespace} from "../../composables/useNamespaces";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import YAML_CHART from "../dashboard/assets/executions_timeseries_chart.yaml?raw";
import {useAuthStore} from "override/stores/auth.ts";
@@ -431,7 +430,6 @@
};
},
computed: {
...mapState("auth", ["user"]),
...mapStores(useExecutionsStore, useFlowStore, useAuthStore),
user() {
return this.authStore.user;
@@ -486,14 +484,11 @@
}
},
beforeRouteEnter(to, _, next) {
const defaultNamespace = localStorage.getItem(
storageKeys.DEFAULT_NAMESPACE,
);
const query = {...to.query};
let queryHasChanged = false;
const queryKeys = Object.keys(query);
if (defaultNamespace && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace;
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
@@ -796,4 +791,8 @@
.flows-table .el-table__cell {
vertical-align: middle;
}
:deep(.flows-table) .el-scrollbar__thumb {
background-color: var(--ks-border-active) !important;
}
</style>

View File

@@ -252,18 +252,17 @@
const settingsEditorFontSize = localStorage.getItem("editorFontSize")
return {
...{
tabSize: 2,
fontFamily: localStorage.getItem("editorFontFamily")
? localStorage.getItem("editorFontFamily")
: "'Source Code Pro', monospace",
fontSize: settingsEditorFontSize
? parseInt(settingsEditorFontSize)
: 12,
showFoldingControls: "always",
scrollBeyondLastLine: false,
roundedSelection: false,
},
tabSize: 2,
fontFamily: localStorage.getItem("editorFontFamily")
? localStorage.getItem("editorFontFamily")
: "'Source Code Pro', monospace",
fontSize: settingsEditorFontSize
? parseInt(settingsEditorFontSize)
: 12,
showFoldingControls: "always",
scrollBeyondLastLine: false,
roundedSelection: false,
...options,
} as monaco.editor.IStandaloneEditorConstructionOptions & {
renderSideBySide?:boolean

View File

@@ -25,8 +25,8 @@
<template #absolute>
<AITriggerButton
:show="isCurrentTabFlow"
:opened="aiAgentOpened"
@click="draftSource = undefined; aiAgentOpened = true"
:opened="aiCopilotOpened"
@click="draftSource = undefined; aiCopilotOpened = true"
/>
<ContentSave v-if="!isCurrentTabFlow" @click="saveFileContent" />
</template>
@@ -35,13 +35,13 @@
</template>
</Editor>
<Transition name="el-zoom-in-center">
<AiAgent
v-if="aiAgentOpened"
<AiCopilot
v-if="aiCopilotOpened"
class="position-absolute prompt"
@close="aiAgentOpened = false"
@close="aiCopilotOpened = false"
:flow="editorContent"
:conversationId="conversationId"
@generated-yaml="(yaml: string) => {draftSource = yaml; aiAgentOpened = false}"
@generated-yaml="(yaml: string) => {draftSource = yaml; aiCopilotOpened = false}"
/>
</Transition>
<AcceptDecline
@@ -68,7 +68,7 @@
import Editor from "./Editor.vue";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import AiAgent from "../ai/AiAgent.vue";
import AiCopilot from "../ai/AiCopilot.vue";
import AITriggerButton from "../ai/AITriggerButton.vue";
import AcceptDecline from "./AcceptDecline.vue";
import PlaygroundRunTaskButton from "./PlaygroundRunTaskButton.vue";
@@ -88,10 +88,10 @@
event.stopPropagation();
event.stopImmediatePropagation();
draftSource.value = undefined;
aiAgentOpened.value = !aiAgentOpened.value;
aiCopilotOpened.value = !aiCopilotOpened.value;
}
};
const aiAgentOpened = ref(false);
const aiCopilotOpened = ref(false);
const draftSource = ref<string | undefined>(undefined);
provide(EDITOR_CURSOR_INJECTION_KEY, cursor);
@@ -289,7 +289,7 @@
function declineDraft() {
draftSource.value = undefined;
aiAgentOpened.value = true;
aiCopilotOpened.value = true;
}
const {

View File

@@ -39,7 +39,6 @@
VNode,
watch
} from "vue";
import {useStore} from "vuex";
import "monaco-editor/esm/vs/editor/editor.all.js";
import "monaco-editor/esm/vs/editor/standalone/browser/inspectTokens/inspectTokens.js";
@@ -70,7 +69,6 @@
import {useFlowStore} from "../../stores/flow.ts";
import EditorType = editor.EditorType;
const store = useStore();
const currentInstance = getCurrentInstance()!;
const {t} = useI18n();
@@ -101,7 +99,7 @@
});
import {useRoute} from "vue-router";
import {useEditorStore} from "../../stores/editor.ts";
import {useEditorStore} from "../../stores/editor";
const route = useRoute();
const highlightLine = () => {
@@ -513,7 +511,6 @@
if (props.language !== undefined) {
await configureLanguage(
store,
flowStore,
pluginsStore,
t,
@@ -727,7 +724,10 @@
});
if (editorRef.value) {
localEditor.value = monaco.editor.create(editorRef.value, options);
localEditor.value = monaco.editor.create(editorRef.value, {
...options,
fixedOverflowWidgets: true // Helps suggestion widget render above other elements
});
if (props.suggestionsOnFocus) {
localEditor.value.onMouseDown(() => {

View File

@@ -221,7 +221,7 @@
<script lang="ts">
import {mapStores} from "pinia";
import {groupBy} from "lodash";
import _groupBy from "lodash/groupBy";
import {useNamespacesStore} from "override/stores/namespaces";
import useNamespaces from "../../composables/useNamespaces";
import {NamespaceIterator} from "../../composables/useNamespaces";
@@ -357,7 +357,7 @@
let kvFetch;
if (this.namespace === undefined) {
if (this.namespaceIterator === undefined) {
this.namespaceIterator = useNamespaces(this.$store, 20);
this.namespaceIterator = useNamespaces(20);
}
const namespaces = (await ((this.namespaceIterator as NamespaceIterator).next())).map(n => n.id);
@@ -425,7 +425,7 @@
});
},
removeKvs() {
const groupedByNamespace = groupBy(this.selection, "namespace");
const groupedByNamespace = _groupBy(this.selection, "namespace");
const withDeletePermissionGroupedKvs = Object.fromEntries(Object.entries(groupedByNamespace).filter(([namespace]) => this.authStore.user.isAllowed(permission.KVSTORE, action.DELETE, namespace)));
const withDeletePermissionNamespaces = Object.keys(withDeletePermissionGroupedKvs);
const withoutDeletePermissionNamespaces = Object.keys(groupedByNamespace).filter(n => !withDeletePermissionNamespaces.includes(n));

View File

@@ -122,9 +122,9 @@
return this.stillHaveDataToFetch || this.tableView === undefined ? "100%" : `min(${this.tableView.scrollHeight}px, 100%)`;
},
async infiniteScrollLoadWithDisableHandling() {
let load = await this.infiniteScrollLoad();
let load = await this.infiniteScrollLoad?.();
while (load !== undefined && load.length === 0) {
load = await this.infiniteScrollLoad();
load = await this.infiniteScrollLoad?.();
}
this.infiniteScrollDisabled = load === undefined;

View File

@@ -238,6 +238,7 @@
background-color: transparent !important;
padding-bottom: 15px;
width: 30px !important;
z-index: 1;
svg {
position: relative;
@@ -268,7 +269,7 @@
box-shadow: none;
&_active, body &_active:hover {
background-color: var(--ks-button-background-primary);
background-color: var(--ks-button-background-primary) !important;
color: var(--ks-button-content-primary);
font-weight: normal;
}
@@ -338,6 +339,12 @@
flex-grow: 0;
}
.vsm--link_open.vsm--link_active {
.vsm--title, .vsm--icon {
color: var(--ks-button-content-primary);
}
}
.vsm--arrow_default{
width: 8px;
&:before{
@@ -406,6 +413,21 @@
bottom: 0 !important;
margin-left: 5px;
}
}
.vsm--item {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1.25rem;
z-index: 5;
background: linear-gradient(to top, var(--ks-background-left-menu), transparent);
opacity: 0.18;
}
}
}
</style>

View File

@@ -20,4 +20,5 @@ export const images: Record<string, string> = {
plugins,
triggers,
versionPlugin,
panels: triggers // TODO: Replace once https://github.com/kestra-io/kestra/issues/11244 is done
};

View File

@@ -193,6 +193,7 @@
</script>
<style scoped lang="scss">
div.line {
position: relative;
cursor: text;
white-space: pre-wrap;
word-break: break-all;
@@ -274,5 +275,16 @@ div.line {
border: 1px solid var(--ks-border-primary);
user-select: none;
}
:deep(.clipboard) {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
&:hover :deep(.clipboard) {
opacity: 1;
pointer-events: auto;
}
}
</style>
</style>

View File

@@ -57,6 +57,7 @@
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw";
import {useLogsStore} from "../../stores/logs";
import {defaultNamespace} from "../../composables/useNamespaces";
export default {
mixins: [RouteContext, RestoreUrl, DataTableActions],
@@ -147,15 +148,12 @@
}
},
beforeRouteEnter(to, _, next) {
const defaultNamespace = localStorage.getItem(
storageKeys.DEFAULT_NAMESPACE,
);
const query = {...to.query};
let queryHasChanged = false;
const queryKeys = Object.keys(query);
if (defaultNamespace && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace;
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
@@ -170,12 +168,6 @@
}
},
methods: {
LogFilterLanguage() {
return LogFilterLanguage
},
onDateFilterTypeChange(event) {
this.canAutoRefresh = event;
},
showStatChart() {
return this.showChart;
},

View File

@@ -409,14 +409,14 @@
},
methods: {
fileUrl(path) {
return `${apiUrl(this.$store)}/executions/${this.followedExecution.id}/file?path=${path}`;
return `${apiUrl()}/executions/${this.followedExecution.id}/file?path=${path}`;
},
async fetchAndStoreLogFileSize(path){
if (this.logFileSizeByPath[path] !== undefined) {
return;
}
const axiosResponse = await this.$http(`${apiUrl(this.$store)}/executions/${this.followedExecution.id}/file/metas?path=${path}`, {
const axiosResponse = await this.$http(`${apiUrl()}/executions/${this.followedExecution.id}/file/metas?path=${path}`, {
validateStatus: (status) => status === 200 || status === 404 || status === 422
});
this.logFileSizeByPath[path] = Utils.humanFileSize(axiosResponse.data.size);

View File

@@ -37,6 +37,9 @@
});
onMounted(() => {
const main = document.querySelector("main");
if(main) main.scrollTop = 0;
if (namespace.value) {
namespacesStore.load(namespace.value);
}

View File

@@ -41,7 +41,7 @@
import {useNamespacesStore} from "override/stores/namespaces"
import DotsSquare from "vue-material-design-icons/DotsSquare.vue"
import Lock from "vue-material-design-icons/Lock.vue";
import {storageKeys} from "../../../utils/constants";
import {defaultNamespace} from "../../../composables/useNamespaces";
const {t} = useI18n();
@@ -79,13 +79,13 @@
onMounted(() => {
if (modelValue.value === undefined || modelValue.value.length === 0) {
const defaultNamespace = localStorage.getItem(storageKeys.DEFAULT_NAMESPACE);
const defaultNamespaceVal = defaultNamespace();
if (Array.isArray(modelValue.value)) {
if (defaultNamespace != null) {
modelValue.value = [defaultNamespace];
if (defaultNamespaceVal != null) {
modelValue.value = [defaultNamespaceVal];
}
} else {
modelValue.value = defaultNamespace ?? modelValue.value;
modelValue.value = defaultNamespaceVal ?? modelValue.value;
}
}
})

View File

@@ -58,7 +58,7 @@
UPDATE_TASK_FUNCTION_INJECTION_KEY,
} from "./injectionKeys";
import {useFlowFields, SECTIONS_IDS} from "./utils/useFlowFields";
import {debounce} from "lodash";
import debounce from "lodash/debounce";
import {NoCodeProps} from "../flows/noCodeTypes";
import {useEditorStore} from "../../stores/editor";
import {useFlowStore} from "../../stores/flow";

View File

@@ -1,139 +0,0 @@
<template>
<el-collapse v-model="expanded" class="collapse">
<el-collapse-item
:name="title"
:title="`${title}${elements ? ` (${elements.length})` : ''}`"
>
<template #icon>
<Creation
:parentPathComplete="parentPathComplete"
:refPath="elements?.length ? elements.length - 1 : undefined"
:blockSchemaPath
/>
</template>
<Element
v-for="(element, elementIndex) in filteredElements"
:key="elementIndex"
:section="section"
:parentPathComplete="parentPathComplete"
:element
:elementIndex="elementIndex"
:moved="elementIndex == movedIndex"
:blockSchemaPath
:typeFieldSchema
@remove-element="removeElement(elementIndex)"
@move-element="
(direction: 'up' | 'down') =>
moveElement(
elements,
element.id,
elementIndex,
direction,
)
"
/>
</el-collapse-item>
</el-collapse>
</template>
<script setup lang="ts">
import {computed, inject, ref} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {CollapseItem} from "../../utils/types";
import Creation from "./buttons/Creation.vue";
import Element from "./Element.vue";
import {
CREATING_TASK_INJECTION_KEY, FULL_SCHEMA_INJECTION_KEY, FULL_SOURCE_INJECTION_KEY,
PARENT_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY,
} from "../../injectionKeys";
import {SECTIONS_MAP} from "../../../../utils/constants";
import {getValueAtJsonPath} from "../../../../utils/utils";
const emits = defineEmits(["remove", "reorder"]);
const flow = inject(FULL_SOURCE_INJECTION_KEY, ref(""));
const props = defineProps<CollapseItem>();
const filteredElements = computed(() => props.elements?.filter(Boolean) ?? []);
const expanded = ref<CollapseItem["title"]>(props.title);
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, false);
const parentPathComplete = computed(() => {
return `${[
[
parentPath,
creatingTask && refPath !== undefined
? `[${refPath + 1}]`
: refPath !== undefined
? `[${refPath}]`
: undefined,
].filter(Boolean).join(""),
props.section
].filter(p => p.length).join(".")}`;
});
const removeElement = (index: number) => {
emits(
"remove",
YAML_UTILS.deleteBlockWithPath({
source: flow.value,
path: `${parentPathComplete.value}[${index}]`
}),
index
);
};
const movedIndex = ref(-1);
const moveElement = (
items: Record<string, any>[] | undefined,
elementID: string,
index: number,
direction: "up" | "down",
) => {
const keyName = props.title === "Plugin Defaults" ? "type" : "id";
if (!items || !flow) return;
if (
(direction === "up" && index === 0) ||
(direction === "down" && index === items.length - 1)
)
return;
const newIndex = direction === "up" ? index - 1 : index + 1;
movedIndex.value = newIndex;
setTimeout(() => {
movedIndex.value = -1;
}, 200);
emits(
"reorder",
YAML_UTILS.swapBlocks({
source:flow.value,
section: SECTIONS_MAP[props.title.toLowerCase() as keyof typeof SECTIONS_MAP],
key1:elementID,
key2:items[newIndex][keyName],
keyName,
}),
);
};
const fullSchema = inject(FULL_SCHEMA_INJECTION_KEY, ref<Record<string, any>>({}));
// resolve parentPathComplete field schema from pluginsStore
const typeFieldSchema = computed(() => {
const blockSchema = getValueAtJsonPath(fullSchema.value, props.blockSchemaPath)?.properties;
return blockSchema?.type ? "type" : blockSchema?.on ? "on" : "type";
});
</script>
<style scoped lang="scss">
@import "../../styles/code.scss";
</style>

View File

@@ -1,23 +1,63 @@
<template>
<div class="tasks-wrapper">
<Collapse
:title="root"
:elements="items"
:section
:blockSchemaPath="[blockSchemaPath, 'properties', root, 'items'].join('/')"
@remove="removeItem"
@reorder="(yaml) => flowStore.flowYaml = yaml"
/>
<el-collapse v-model="expanded" class="collapse">
<el-collapse-item
:name="sectionName"
:title="`${sectionName}${elements ? ` (${elements.length})` : ''}`"
>
<template #icon>
<Creation
:parentPathComplete="parentPathComplete"
:refPath="elements?.length ? elements.length - 1 : undefined"
:blockSchemaPath
/>
</template>
<Element
v-for="(element, elementIndex) in filteredElements"
:key="elementIndex"
:section="sectionName"
:parentPathComplete="parentPathComplete"
:element
:elementIndex="elementIndex"
:moved="elementIndex == movedIndex"
:blockSchemaPath
:typeFieldSchema
@remove-element="removeElement(elementIndex)"
@move-element="
(direction: 'up' | 'down') =>
moveElement(
elements,
element.id,
elementIndex,
direction,
)
"
/>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import {computed, inject, ref} from "vue";
import Collapse from "../collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../injectionKeys";
import {useFlowStore} from "../../../../stores/flow";
import Creation from "./taskList/buttons/Creation.vue";
import Element from "./taskList/Element.vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""))
import {CollapseItem} from "../../utils/types";
import {
CREATING_TASK_INJECTION_KEY, FULL_SCHEMA_INJECTION_KEY, FULL_SOURCE_INJECTION_KEY,
PARENT_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY,
} from "../../injectionKeys";
import {SECTIONS_MAP} from "../../../../utils/constants";
import {getValueAtJsonPath} from "../../../../utils/utils";
const blockSchemaPathInjected = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""))
const blockSchemaPath = computed(() => [blockSchemaPathInjected.value, "properties", props.root, "items"].join("/"));
defineOptions({
inheritAttrs: false
@@ -39,22 +79,90 @@
root: undefined
});
const items = computed(() =>
const elements = computed(() =>
!Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
);
function removeItem(yaml: string, index: number){
flowStore.flowYaml = yaml;
let localItems = [...items.value]
function removeElement(index: number){
if(elements.value.length <= 1){
emits("update:modelValue", undefined);
return
}
let localItems = [...elements.value]
localItems.splice(index, 1)
emits("update:modelValue", localItems);
};
const section = computed(() => {
const sectionName = computed(() => {
return props.root ?? "tasks";
});
const flow = inject(FULL_SOURCE_INJECTION_KEY, ref(""));
const filteredElements = computed(() => elements.value?.filter(Boolean) ?? []);
const expanded = ref<CollapseItem["title"]>(props.root ?? "tasks");
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, false);
const parentPathComplete = computed(() => {
return `${[
[
parentPath,
creatingTask && refPath !== undefined
? `[${refPath + 1}]`
: refPath !== undefined
? `[${refPath}]`
: undefined,
].filter(Boolean).join(""),
sectionName.value
].filter(p => p.length).join(".")}`;
});
const movedIndex = ref(-1);
const moveElement = (
items: Record<string, any>[] | undefined,
elementID: string,
index: number,
direction: "up" | "down",
) => {
const keyName = sectionName.value === "Plugin Defaults" ? "type" : "id";
if (!items || !flow) return;
if (
(direction === "up" && index === 0) ||
(direction === "down" && index === items.length - 1)
)
return;
const newIndex = direction === "up" ? index - 1 : index + 1;
movedIndex.value = newIndex;
setTimeout(() => {
movedIndex.value = -1;
}, 200);
flowStore.flowYaml =
YAML_UTILS.swapBlocks({
source:flow.value,
section: SECTIONS_MAP[sectionName.value.toLowerCase() as keyof typeof SECTIONS_MAP],
key1:elementID,
key2:items[newIndex][keyName],
keyName,
})
};
const fullSchema = inject(FULL_SCHEMA_INJECTION_KEY, ref<Record<string, any>>({}));
// resolve parentPathComplete field schema from pluginsStore
const typeFieldSchema = computed(() => {
const blockSchema = getValueAtJsonPath(fullSchema.value, blockSchemaPath.value)?.properties;
return blockSchema?.type ? "type" : blockSchema?.on ? "on" : "type";
});
</script>
<style scoped lang="scss">

View File

@@ -22,7 +22,7 @@
CREATING_TASK_INJECTION_KEY,
BLOCK_SCHEMA_PATH_INJECTION_KEY
} from "../../injectionKeys";
import Element from "../collapse/Element.vue";
import Element from "./taskList/Element.vue";
const model = defineModel({
type: Object,

View File

@@ -1,62 +0,0 @@
<template>
<div class="tasks-wrapper">
<Collapse
title="tasks"
:elements="items"
:section
:blockSchemaPath="[blockSchemaPath, 'properties', root, 'items'].join('/')"
@remove="(yaml) => flowStore.flowYaml = yaml"
@reorder="(yaml) => flowStore.flowYaml = yaml"
/>
</div>
</template>
<script setup lang="ts">
import {computed, inject, ref} from "vue";
import Collapse from "../collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../injectionKeys";
import {useFlowStore} from "../../../../stores/flow";
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref())
defineOptions({
inheritAttrs: false
});
const flowStore = useFlowStore();
interface Task {
id:string,
type:string
}
const props = withDefaults(defineProps<{
modelValue?: Task[],
root?: string;
}>(), {
modelValue: () => [],
root: undefined
});
const items = computed(() =>
!Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
);
const section = computed(() => {
return props.root ?? "tasks";
});
</script>
<style scoped lang="scss">
@import "../../styles/code.scss";
.tasks-wrapper {
width: 100%;
}
.disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
</style>

View File

@@ -29,14 +29,14 @@
import {computed, inject} from "vue";
import {useI18n} from "vue-i18n";
import PlayIcon from "vue-material-design-icons/Play.vue";
import {usePluginsStore} from "../../../../stores/plugins";
import {usePlaygroundStore} from "../../../../stores/playground";
import {usePluginsStore} from "../../../../../stores/plugins";
import {usePlaygroundStore} from "../../../../../stores/playground";
import {DeleteOutline, ChevronUp, ChevronDown} from "../../utils/icons";
import {DeleteOutline, ChevronUp, ChevronDown} from "../../../utils/icons";
import {
EDIT_TASK_FUNCTION_INJECTION_KEY,
} from "../../injectionKeys";
} from "../../../injectionKeys";
import TaskIcon from "@kestra-io/ui-libs/src/components/misc/TaskIcon.vue";
@@ -81,7 +81,7 @@
</script>
<style scoped lang="scss">
@import "../../styles/code.scss";
@import "../../../styles/code.scss";
@import "@kestra-io/ui-libs/src/scss/_color-palette";
.element {

View File

@@ -8,8 +8,8 @@
import {inject} from "vue";
import {
CREATE_TASK_FUNCTION_INJECTION_KEY,
} from "../../../injectionKeys";
import {Plus} from "../../../utils/icons";
} from "../../../../injectionKeys";
import {Plus} from "../../../../utils/icons";
import {useI18n} from "vue-i18n";

View File

@@ -319,7 +319,7 @@
},
async fetchSecrets() {
if (this.secretsIterator === undefined) {
this.secretsIterator = this.namespace === undefined ? useAllSecrets(this.$store, this.authStore.user, 20) : useNamespaceSecrets(this.$store, this.namespace, 20, {
this.secretsIterator = this.namespace === undefined ? useAllSecrets(this.authStore.user, 20) : useNamespaceSecrets(this.namespace, 20, {
sort: this.$route.query.sort || "key:asc",
...(this.searchQuery === undefined ? {} : {filters: {
q: {

View File

@@ -277,6 +277,7 @@
import Column from "./components/block/Column.vue"
import {useAuthStore} from "override/stores/auth"
import {useFlowStore} from "../../stores/flow"
import {defaultNamespace} from "../../composables/useNamespaces";
export const DATE_FORMAT_STORAGE_KEY = "dateFormat";
export const TIMEZONE_STORAGE_KEY = "timezone";
@@ -342,7 +343,7 @@
};
},
created() {
this.pendingSettings.defaultNamespace = localStorage.getItem("defaultNamespace") || "company.team";
this.pendingSettings.defaultNamespace = defaultNamespace();
this.pendingSettings.editorType = localStorage.getItem(storageKeys.EDITOR_VIEW_TYPE) || "YAML";
this.pendingSettings.defaultLogLevel = localStorage.getItem("defaultLogLevel") || "INFO";
this.pendingSettings.lang = Utils.getLang();
@@ -547,7 +548,7 @@
}
break
case "theme":
Utils.switchTheme(this.$store, this.pendingSettings[key]);
Utils.switchTheme(this.miscStore, this.pendingSettings[key]);
localStorage.setItem(key, Utils.getTheme())
break
case "lang":
@@ -591,7 +592,7 @@
},
updateThemeBasedOnSystem() {
if (this.theme === "syncWithSystem") {
Utils.switchTheme(this.$store, "syncWithSystem");
Utils.switchTheme(this.miscStore, "syncWithSystem");
}
},
},

View File

@@ -173,9 +173,6 @@
},
},
methods: {
onDateFilterTypeChange(event) {
this.canAutoRefresh = event;
},
isRunning(item){
return State.isRunning(item.state.current);
},

View File

@@ -1,6 +1,5 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import {IDisposable} from "monaco-editor/esm/vs/editor/editor.api";
import {Store} from "vuex";
import {useI18n} from "vue-i18n";
import {usePluginsStore} from "../../../stores/plugins.ts";
@@ -20,13 +19,13 @@ export default abstract class AbstractLanguageConfigurator {
monaco.languages.register({id: this.language});
}
abstract configureAutoCompletion(t: ReturnType<typeof useI18n>["t"], store: Store<Record<string, any>>, editorInstance: monaco.editor.ICodeEditor | undefined): IDisposable[];
abstract configureAutoCompletion(t: ReturnType<typeof useI18n>["t"], editorInstance: monaco.editor.ICodeEditor | undefined): IDisposable[];
async configure(store: Store<Record<string, any>>, pluginsStore: ReturnType<typeof usePluginsStore>, t: ReturnType<typeof useI18n>["t"], editorInstance: monaco.editor.ICodeEditor | undefined): Promise<IDisposable[]> {
async configure(pluginsStore: ReturnType<typeof usePluginsStore>, t: ReturnType<typeof useI18n>["t"], editorInstance: monaco.editor.ICodeEditor | undefined): Promise<IDisposable[]> {
if (!AbstractLanguageConfigurator.configuredLanguages.includes(this.language)) {
AbstractLanguageConfigurator.configuredLanguages.push(this.language);
await this.configureLanguage(pluginsStore);
return this.configureAutoCompletion(t, store, editorInstance);
return this.configureAutoCompletion(t, editorInstance);
}
return []

View File

@@ -1,6 +1,5 @@
import {useValues} from "../../../../components/filter/composables/useValues.ts";
import {Value} from "../../../../components/filter/utils/types.ts";
import {Store} from "vuex";
export enum Comparators {
EQUALS = "=",
@@ -56,7 +55,7 @@ export const PICK_DATE_VALUE = "PICK_DATE";
export type ValueCompletions = Value[] | typeof PICK_DATE_VALUE;
export type Fetcher = (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions>;
export type Fetcher = (hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions>;
export class FilterKeyCompletions {
private readonly _comparators: Comparators[];

View File

@@ -1,6 +1,5 @@
import {Comparators, Completion, FilterKeyCompletions, keyOfComparator, ValueCompletions} from "./filterCompletion";
import {useValues} from "../../../../components/filter/composables/useValues";
import {Store} from "vuex";
type FilterKeyCompletionEntries = [
({ key: string, regex: RegExp }),
@@ -23,15 +22,13 @@ export abstract class FilterLanguage {
protected constructor(domain: string | undefined, filterKeyCompletions: Record<string, FilterKeyCompletions>, textFilterSupported: boolean = true) {
this._domain = domain;
this._filterKeyCompletions = [
...(Object.entries(filterKeyCompletions).map(([key, filterKeyCompletion]) => [
this._filterKeyCompletions = (Object.entries(filterKeyCompletions).map(([key, filterKeyCompletion]) => [
{
key: key,
regex: new RegExp("^" + key.replaceAll(".", "\\.").replaceAll(/\$?\{([^}]*)}/g, ".*") + "$")
},
filterKeyCompletion
]) as FilterKeyCompletionEntries)
];
]) as FilterKeyCompletionEntries);
this._textFilterSupported = textFilterSupported;
if (textFilterSupported) {
@@ -86,13 +83,13 @@ export abstract class FilterLanguage {
return completion.comparators.map(comparator => new Completion(keyOfComparator(comparator), comparator));
}
async valueCompletion(store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"], key: string): Promise<ValueCompletions> {
async valueCompletion(hardcodedValues: ReturnType<typeof useValues>["VALUES"], key: string): Promise<ValueCompletions> {
const completion = this.completionForKey(key);
if (completion === undefined) {
return [];
}
return completion.valuesFetcher(store, hardcodedValues);
return completion.valuesFetcher(hardcodedValues);
}
multipleValuesAllowed(key: string): boolean {

View File

@@ -1,7 +1,6 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import {editor, IPosition, IRange} from "monaco-editor/esm/vs/editor/editor.api";
import AbstractLanguageConfigurator from "../abstractLanguageConfigurator";
import {Store} from "vuex";
import {useI18n} from "vue-i18n";
import {FilterLanguage} from "./filterLanguage";
import {useValues} from "../../../../components/filter/composables/useValues";
@@ -39,7 +38,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
return legacyFilterRegex.test(this.language);
}
async configure(store: Store<Record<string, any>>, pluginsStore: ReturnType<typeof usePluginsStore>, t: ReturnType<typeof useI18n>["t"], editorInstance: editor.ICodeEditor | undefined): Promise<monaco.IDisposable[]> {
async configure(pluginsStore: ReturnType<typeof usePluginsStore>, t: ReturnType<typeof useI18n>["t"], editorInstance: editor.ICodeEditor | undefined): Promise<monaco.IDisposable[]> {
filterLanguages = await loadFilterLanguages();
this._filterLanguage = filterLanguages.find(filterLanguage => filterLanguage.domain === this._domain);
@@ -54,7 +53,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
?.join("|") + ")"
));
return super.configure(store, pluginsStore, t, editorInstance);
return super.configure(pluginsStore, t, editorInstance);
}
async configureLanguage(): Promise<void> {
@@ -147,7 +146,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
}
}
configureAutoCompletion(t: ReturnType<typeof useI18n>["t"], store: Store<Record<string, any>>, __: editor.ICodeEditor | undefined) {
configureAutoCompletion(t: ReturnType<typeof useI18n>["t"], __: editor.ICodeEditor | undefined) {
const filterLanguage = this._filterLanguage;
if (filterLanguage === undefined) {
return [];
@@ -318,7 +317,6 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
if (key !== undefined) {
const valueCompletions = await filterLanguage.valueCompletion(
store,
hardcodedValues,
key
);

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