Compare commits

...

23 Commits

Author SHA1 Message Date
Piyush Bhaskar
b5608e08d8 refactor: minor tweak 2025-11-10 22:39:25 +05:30
Piyush Bhaskar
9fb63284f0 fix(core): collapse menu automagically on route change 2025-11-10 22:39:25 +05:30
Piyush Bhaskar
bbd28ad2a8 Revert "fix(ui): auto close sidebar on mobile after clicking a link" 2025-11-10 22:39:25 +05:30
Piyush Bhaskar
32b6e8c6d7 Revert "fix(ui): apply saved sidebar collapse state on first load and route change" 2025-11-10 22:39:25 +05:30
shatrughantwt
93adccb716 fix(ui): apply saved sidebar collapse state on first load and route change 2025-11-10 22:39:25 +05:30
shatrughantwt
b6b854598b fix(ui): auto close sidebar on mobile after clicking a link 2025-11-10 22:39:25 +05:30
Iulian Ghita
cf42fe751e fix(core): make demo layouts responsive (#12812)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-10 19:02:36 +05:30
YannC
b144fae047 fix: make sure datafilter is validated (#12822) 2025-11-10 13:28:54 +01:00
Loïc Mathieu
fc59fd7505 fix(executions): allow reading from subflow even if we have a parent
This fixes an issue where you cannot read from a Subflow file if the execution has iteself be triggered by another Subflow task.
It was caused by the trigger check beeing too aggressive, if it didn't pass the check it fail instead of return false so the other check would not be processed.

Fixes #12629
2025-11-10 13:26:02 +01:00
suraj a
65eeea8256 refactor(core): Tabs.vue to TypeScript with composition API. (#12692)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-10 17:15:12 +05:30
Miloš Paunović
4769fa2ac5 chore(core): count only direct dependencies for badge number (#12818)
Closes https://github.com/kestra-io/kestra/issues/12817.
2025-11-10 12:40:20 +01:00
Loïc Mathieu
9a4b569d85 feat(storage): limit object name to 255 inside the local storage
Part-of: #12273
2025-11-10 12:24:49 +01:00
Piyush Bhaskar
1abef5429c fix(core): bring the usage of restore url (#12762)
Co-authored-by: Bart Ledoux <bledoux@kestra.io>
2025-11-10 16:03:16 +05:30
Hemant M Mehta
bdbd9d45f8 fix: unreadable-error-messages (#12787)
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-10 15:19:14 +05:30
YannC
7d1f064fe9 fix: when removing a queued execution, directly delete instead of fetching then delete to reduce deadlock (#12789) 2025-11-10 10:31:41 +01:00
Piyush Bhaskar
a125c8d314 fix(core): add defaults for component (#12814) 2025-11-10 14:58:16 +05:30
Piyush Bhaskar
a9d27d4757 fix(core): bulk deletion of executions (#12813) 2025-11-10 14:04:39 +05:30
Loïc Mathieu
d97f3a101c fix(executions): don't urlencode files as they would already be inside the storage 2025-11-10 09:27:09 +01:00
Shatrughan
a65310bcab Adjust TopNavBar padding for small screens and add right-side gradient (#12799) 2025-11-10 11:10:33 +05:30
Miloš Paunović
58e5efe767 refactor(core): uniform .gitignore file for javascript (#12802) 2025-11-07 14:09:41 +01:00
Miloš Paunović
c3c46ae336 chore(flows): amend flow export filename to include namespace and id parameters (#12800)
Closes https://github.com/kestra-io/kestra/issues/12790.
2025-11-07 13:57:33 +01:00
Miloš Paunović
f8bb59f76e refactor(core): replace soon-to-be-deprecated button attribute (#12796)
Resolving console warnings.

https://element-plus.org/en-US/component/button#link-button
2025-11-07 13:29:40 +01:00
Miloš Paunović
0c4425b030 chore(deps): regular dependency update (#12785)
Performing a weekly round of dependency updates in the NPM ecosystem to keep everything up to date.
2025-11-07 11:38:46 +01:00
49 changed files with 1352 additions and 1017 deletions

7
.gitignore vendored
View File

@@ -32,12 +32,13 @@ ui/node_modules
ui/.env.local
ui/.env.*.local
webserver/src/main/resources/ui
yarn.lock
webserver/src/main/resources/views
ui/coverage
ui/stats.html
ui/.frontend-gradle-plugin
ui/utils/CHANGELOG.md
ui/test-report.junit.xml
*storybook.log
storybook-static
### Docker
/.env
@@ -57,6 +58,4 @@ core/src/main/resources/gradle.properties
# Allure Reports
**/allure-results/*
*storybook.log
storybook-static
/jmh-benchmarks/src/main/resources/gradle.properties

View File

@@ -5,6 +5,7 @@ import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.data.IData;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@@ -33,9 +34,11 @@ public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
private String type;
@Valid
private Map<String, C> columns;
@Setter
@Valid
private List<AbstractFilter<F>> where;
private List<OrderBy> orderBy;

View File

@@ -5,6 +5,7 @@ import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ChartOption;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.validations.DataChartValidation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@@ -20,6 +21,7 @@ import lombok.experimental.SuperBuilder;
@DataChartValidation
public abstract class DataChart<P extends ChartOption, D extends DataFilter<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin {
@NotNull
@Valid
private D data;
public Integer minNumberOfAggregations() {

View File

@@ -1,8 +1,11 @@
package io.kestra.core.models.dashboards.filters;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@@ -32,6 +35,9 @@ import lombok.experimental.SuperBuilder;
@SuperBuilder
@Introspected
public abstract class AbstractFilter<F extends Enum<F>> {
@NotNull
@JsonProperty(value = "field", required = true)
@Valid
private F field;
private String labelKey;

View File

@@ -82,8 +82,7 @@ public abstract class FilesService {
}
private static String resolveUniqueNameForFile(final Path path) {
String filename = path.getFileName().toString();
String encodedFilename = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8);
return IdUtils.from(path.toString()) + "-" + encodedFilename;
String filename = path.getFileName().toString().replace(' ', '+');
return IdUtils.from(path.toString()) + "-" + filename;
}
}

View File

@@ -151,10 +151,7 @@ abstract class AbstractFileFunction implements Function {
// if there is a trigger of type execution, we also allow accessing a file from the parent execution
Map<String, String> trigger = (Map<String, String>) context.getVariable(TRIGGER);
if (!isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path)) {
throw new IllegalArgumentException("Unable to read the file '" + path + "' as it didn't belong to the parent execution");
}
return true;
return isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path);
}
return false;
}

View File

@@ -4,6 +4,7 @@ import io.kestra.core.annotations.Retryable;
import io.kestra.core.models.Plugin;
import io.kestra.core.models.executions.Execution;
import jakarta.annotation.Nullable;
import org.apache.commons.lang3.RandomStringUtils;
import java.io.BufferedInputStream;
import java.io.File;
@@ -361,7 +362,7 @@ public interface StorageInterface extends AutoCloseable, Plugin {
return path;
}
/**
/**
* Ensures the object name length does not exceed the allowed maximum.
* If it does, the object name is truncated and a short random prefix is added
* to avoid potential name collisions.
@@ -378,10 +379,9 @@ public interface StorageInterface extends AutoCloseable, Plugin {
String path = uri.getPath();
String objectName = path.contains("/") ? path.substring(path.lastIndexOf("/") + 1) : path;
if (objectName.length() > maxObjectNameLength) {
objectName = objectName.substring(objectName.length() - maxObjectNameLength + 6);
String prefix = org.apache.commons.lang3.RandomStringUtils.secure()
String prefix = RandomStringUtils.secure()
.nextAlphanumeric(5)
.toLowerCase();

View File

@@ -106,28 +106,28 @@ class FilesServiceTest {
var runContext = runContextFactory.of();
Path fileWithSpace = tempDir.resolve("with space.txt");
Path fileWithUnicode = tempDir.resolve("สวัสดี.txt");
Path fileWithUnicode = tempDir.resolve("สวัสดี&.txt");
Files.writeString(fileWithSpace, "content");
Files.writeString(fileWithUnicode, "content");
Path targetFileWithSpace = runContext.workingDir().path().resolve("with space.txt");
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี.txt");
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี&.txt");
Files.copy(fileWithSpace, targetFileWithSpace);
Files.copy(fileWithUnicode, targetFileWithUnicode);
Map<String, URI> outputFiles = FilesService.outputFiles(
runContext,
List.of("with space.txt", "สวัสดี.txt")
List.of("with space.txt", "สวัสดี&.txt")
);
assertThat(outputFiles).hasSize(2);
assertThat(outputFiles).containsKey("with space.txt");
assertThat(outputFiles).containsKey("สวัสดี.txt");
assertThat(outputFiles).containsKey("สวัสดี&.txt");
assertThat(runContext.storage().getFile(outputFiles.get("with space.txt"))).isNotNull();
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี.txt"))).isNotNull();
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี&.txt"))).isNotNull();
}
private URI createFile() throws IOException {

View File

@@ -112,33 +112,6 @@ public class FileSizeFunctionTest {
assertThat(size).isEqualTo(FILE_SIZE);
}
@Test
void shouldThrowIllegalArgumentException_givenTrigger_andParentExecution_andMissingNamespace() throws IOException {
String executionId = IdUtils.create();
URI internalStorageURI = getInternalStorageURI(executionId);
URI internalStorageFile = getInternalStorageFile(internalStorageURI);
Map<String, Object> variables = Map.of(
"flow", Map.of(
"id", "subflow",
"namespace", NAMESPACE,
"tenantId", MAIN_TENANT),
"execution", Map.of("id", IdUtils.create()),
"trigger", Map.of(
"flowId", FLOW,
"executionId", executionId,
"tenantId", MAIN_TENANT
)
);
Exception ex = assertThrows(
IllegalArgumentException.class,
() -> variableRenderer.render("{{ fileSize('" + internalStorageFile + "') }}", variables)
);
assertTrue(ex.getMessage().startsWith("Unable to read the file"), "Exception message doesn't match expected one");
}
@Test
void returnsCorrectSize_givenUri_andCurrentExecution() throws IOException, IllegalVariableEvaluationException {
String executionId = IdUtils.create();

View File

@@ -259,6 +259,27 @@ class ReadFileFunctionTest {
assertThat(variableRenderer.render("{{ read(nsfile) }}", variables)).isEqualTo("Hello World");
}
@Test
void shouldReadChildFileEvenIfTrigger() throws IOException, IllegalVariableEvaluationException {
String namespace = "my.namespace";
String flowId = "flow";
String executionId = IdUtils.create();
URI internalStorageURI = URI.create("/" + namespace.replace(".", "/") + "/" + flowId + "/executions/" + executionId + "/tasks/task/" + IdUtils.create() + "/123456.ion");
URI internalStorageFile = storageInterface.put(MAIN_TENANT, namespace, internalStorageURI, new ByteArrayInputStream("Hello from a task output".getBytes()));
Map<String, Object> variables = Map.of(
"flow", Map.of(
"id", "flow",
"namespace", "notme",
"tenantId", MAIN_TENANT),
"execution", Map.of("id", "notme"),
"trigger", Map.of("namespace", "notme", "flowId", "parent", "executionId", "parent")
);
String render = variableRenderer.render("{{ read('" + internalStorageFile + "') }}", variables);
assertThat(render).isEqualTo("Hello from a task output");
}
private URI createFile() throws IOException {
File tempFile = File.createTempFile("file", ".txt");
Files.write(tempFile.toPath(), "Hello World".getBytes());

View File

@@ -12,7 +12,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRepository {
protected io.kestra.jdbc.AbstractJdbcRepository<ExecutionQueued> jdbcRepository;
@@ -70,18 +69,12 @@ public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRep
this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
var select = DSL
.using(configuration)
.select(AbstractJdbcRepository.field("value"))
.from(this.jdbcRepository.getTable())
.where(buildTenantCondition(execution.getTenantId()))
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
.forUpdate();
Optional<ExecutionQueued> maybeExecution = this.jdbcRepository.fetchOne(select);
if (maybeExecution.isPresent()) {
this.jdbcRepository.delete(maybeExecution.get());
}
DSL
.using(configuration)
.deleteFrom(this.jdbcRepository.getTable())
.where(buildTenantCondition(execution.getTenantId()))
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
.execute();
});
}
}

View File

@@ -36,6 +36,7 @@ import static io.kestra.core.utils.WindowsUtils.windowsToUnixPath;
@NoArgsConstructor
public class LocalStorage implements StorageInterface {
private static final Logger log = LoggerFactory.getLogger(LocalStorage.class);
private static final int MAX_OBJECT_NAME_LENGTH = 255;
@PluginProperty
@NotNull
@@ -170,14 +171,16 @@ public class LocalStorage implements StorageInterface {
@Override
public URI put(String tenantId, @Nullable String namespace, URI uri, StorageObject storageObject) throws IOException {
File file = getLocalPath(tenantId, uri).toFile();
return putFile(uri, storageObject, file);
URI limited = limit(uri, MAX_OBJECT_NAME_LENGTH);
File file = getLocalPath(tenantId, limited).toFile();
return putFile(limited, storageObject, file);
}
@Override
public URI putInstanceResource(@Nullable String namespace, URI uri, StorageObject storageObject) throws IOException {
File file = getInstancePath(uri).toFile();
return putFile(uri, storageObject, file);
URI limited = limit(uri, MAX_OBJECT_NAME_LENGTH);
File file = getInstancePath(limited).toFile();
return putFile(limited, storageObject, file);
}
private static URI putFile(URI uri, StorageObject storageObject, File file) throws IOException {

View File

@@ -1,7 +1,35 @@
package io.kestra.storage.local;
import io.kestra.core.storage.StorageTestSuite;
import io.kestra.core.utils.IdUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LocalStorageTest extends StorageTestSuite {
// Launch test from StorageTestSuite
@Test
void putLongObjectName() throws URISyntaxException, IOException {
String longObjectName = "/" + RandomStringUtils.insecure().nextAlphanumeric(260).toLowerCase();
URI put = storageInterface.put(
IdUtils.create(),
null,
new URI(longObjectName),
new ByteArrayInputStream("Hello World".getBytes())
);
assertThat(put.getPath(), not(longObjectName));
String suffix = put.getPath().substring(7); // we remove the random 5 char + '-'
assertTrue(longObjectName.endsWith(suffix));
}
}

View File

@@ -1104,6 +1104,14 @@ public abstract class StorageTestSuite {
assertThat(withMetadata.metadata()).isEqualTo(expectedMetadata);
}
@Test
void limitShouldPreserveSpecialCharts() throws IOException {
var uri = URI.create("/%89%B4%89%B4%EC%9D%B4%EC%96%B4+%EB%A7%90+%EC%95%84%ED%8A%B8%EC%9B%8D+NP+%EC%8A%A4%ED%8C%90+%EC%9D%B8%ED%8C%85+JQ+%EB%82%A8%EC%84%B1+%EC%9D%B8%EB%B0%B4%EB%93%9C+%EB%93%9C%EB%A1%9C%EC%A6%88%2C+101470%2C+FI261DR15M001-21-1st+Fit+%28QC%29+Sample+Data+Package-en.txt");
var limited = storageInterface.limit(uri, 100);
assertThat(uri.getPath()).endsWith(limited.getPath().substring(7));
}
private URI putFile(String tenantId, String path) throws Exception {
return storageInterface.put(
tenantId,

996
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@
"@vue-flow/core": "^1.47.0",
"@vueuse/core": "^14.0.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.13.1",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"buffer": "^6.0.3",
"chart.js": "^4.5.1",
@@ -39,7 +39,7 @@
"cytoscape": "^3.33.0",
"dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.7",
"element-plus": "2.11.5",
"element-plus": "2.11.7",
"humanize-duration": "^3.33.1",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
@@ -57,16 +57,16 @@
"moment-timezone": "^0.5.46",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.4.296",
"pinia": "^3.0.3",
"posthog-js": "^1.281.0",
"pdfjs-dist": "^5.4.394",
"pinia": "^3.0.4",
"posthog-js": "^1.289.0",
"rapidoc": "^9.3.8",
"semver": "^7.7.3",
"shiki": "^3.12.2",
"vue": "^3.5.22",
"shiki": "^3.15.0",
"vue": "^3.5.24",
"vue-axios": "^3.5.2",
"vue-chartjs": "^5.3.2",
"vue-gtag": "^3.6.2",
"vue-chartjs": "^5.3.3",
"vue-gtag": "^3.6.3",
"vue-i18n": "^11.1.12",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.6.3",
@@ -80,10 +80,10 @@
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.38.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.56.1",
"@rushstack/eslint-patch": "^1.14.1",
"@shikijs/markdown-it": "^3.14.0",
"@shikijs/markdown-it": "^3.15.0",
"@storybook/addon-themes": "^9.1.16",
"@storybook/addon-vitest": "^9.1.16",
"@storybook/test-runner": "^0.23.0",
@@ -91,13 +91,13 @@
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/moment": "^2.11.29",
"@types/node": "^24.9.2",
"@types/node": "^24.10.0",
"@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.46.2",
"@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vitest/browser": "^3.2.4",
@@ -107,42 +107,42 @@
"@vueuse/router": "^14.0.0",
"change-case": "5.4.4",
"cross-env": "^10.1.0",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-plugin-storybook": "^9.1.16",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.4.0",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^27.0.1",
"jsdom": "^27.1.0",
"lint-staged": "^16.2.6",
"monaco-editor": "^0.52.2",
"monaco-yaml": "5.3.1",
"patch-package": "^8.0.1",
"playwright": "^1.55.0",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"rolldown-vite": "^7.1.20",
"rimraf": "^6.1.0",
"rolldown-vite": "^7.2.2",
"rollup-plugin-copy": "^3.5.0",
"sass": "^1.92.3",
"sass": "^1.93.3",
"storybook": "^9.1.16",
"storybook-vue3-router": "^6.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"typescript-eslint": "^8.46.3",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "^3.2.4",
"vue-tsc": "^3.1.2"
"vue-tsc": "^3.1.3"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.11",
"@esbuild/darwin-x64": "^0.25.11",
"@esbuild/linux-x64": "^0.25.11",
"@esbuild/darwin-arm64": "^0.25.12",
"@esbuild/darwin-x64": "^0.25.12",
"@esbuild/linux-x64": "^0.25.12",
"@rollup/rollup-darwin-arm64": "^4.52.5",
"@rollup/rollup-darwin-x64": "^4.52.5",
"@rollup/rollup-linux-x64-gnu": "^4.52.5",
"@swc/core-darwin-arm64": "^1.14.0",
"@swc/core-darwin-x64": "^1.14.0",
"@swc/core-linux-x64-gnu": "^1.14.0"
"@swc/core-darwin-arm64": "^1.15.0",
"@swc/core-darwin-x64": "^1.15.0",
"@swc/core-linux-x64-gnu": "^1.15.0"
},
"overrides": {
"bootstrap": {

View File

@@ -37,138 +37,137 @@
ref="tabContent"
:is="activeTab.component"
:namespace="namespaceToForward"
@go-to-detail="blueprintId => selectedBlueprintId = blueprintId"
@go-to-detail="(blueprintId: string) => selectedBlueprintId = blueprintId"
:embed="activeTab.props && activeTab.props.embed !== undefined ? activeTab.props.embed : true"
/>
</section>
</template>
<script>
<script setup lang="ts">
import {ref, computed, watch, onMounted, nextTick, useAttrs} from "vue";
import {useRoute} from "vue-router";
import EnterpriseBadge from "./EnterpriseBadge.vue";
import BlueprintDetail from "./flows/blueprints/BlueprintDetail.vue";
export default {
components: {EnterpriseBadge,BlueprintDetail},
props: {
tabs: {
type: Array,
required: true
},
routeName: {
type: String,
default: ""
},
top: {
type: Boolean,
default: true
},
/**
* The active embedded tab. If this component is not embedded, keep it undefined.
*/
embedActiveTab: {
type: String,
required: false,
default: undefined
},
namespace: {
type: String,
default: null
},
type: {
type: String,
default: undefined
}
},
emits: [
/**
* Especially useful when embedded since you need to handle the embedActiveTab prop change on the parent component.
* @property {Object} newTab the new active tab
*/
"changed"
],
data() {
interface Tab {
name?: string;
title: string;
hidden?: boolean;
disabled?: boolean;
props?: any;
count?: number;
locked?: boolean;
query?: any;
component?: any;
maximized?: boolean;
"v-on"?: any;
}
const props = withDefaults(defineProps<{
tabs: Tab[];
routeName?: string;
top?: boolean;
/**
* The active embedded tab. If this component is not embedded, keep it undefined.
*/
embedActiveTab?: string;
namespace?: string | null;
type?: string;
}>(), {
routeName: "",
top: true,
embedActiveTab: undefined,
namespace: null,
type: undefined
});
const emit = defineEmits<{
/**
* Especially useful when embedded since you need to handle the embedActiveTab prop change on the parent component.
* @property {Object} newTab the new active tab
*/
changed: [tab: Tab];
}>();
const attrs = useAttrs();
const route = useRoute();
const activeName = ref<string | undefined>(undefined);
const selectedBlueprintId = ref<string | undefined>(undefined);
const activeTab = computed(() => {
return props.tabs.filter(tab => (props.embedActiveTab ?? route?.params?.tab) === tab.name)[0] || props.tabs[0];
});
const isEditorActiveTab = computed(() => {
const TAB = activeTab.value.name;
const ROUTE = route?.name as string;
if (["flows/update", "flows/create"].includes(ROUTE)) {
return TAB === "edit";
} else if (["namespaces/update", "namespaces/create"].includes(ROUTE)) {
if (TAB === "files") return true;
}
return false;
});
const attrsWithoutClass = computed(() => {
return Object.fromEntries(
Object.entries(attrs)
.filter(([key]) => key !== "class")
);
});
const namespaceToForward = computed(() => {
return activeTab.value.props?.namespace ?? props.namespace;
// in the special case of Namespace creation on Namespaces page, the tabs are loaded before the namespace creation
// in this case this.props.namespace will be used
});
const containerClass = computed(() => getTabClasses(activeTab.value));
const embeddedTabChange = (tab: Tab) => {
emit("changed", tab);
};
const setActiveName = () => {
activeName.value = activeTab.value.name || "default";
};
const to = (tab: Tab) => {
if (activeTab.value === tab) {
setActiveName();
return route;
} else {
return {
activeName: undefined,
selectedBlueprintId : undefined
}
},
watch: {
$route() {
this.setActiveName();
},
activeTab() {
this.$nextTick(() => {
this.setActiveName();
});
}
},
mounted() {
this.setActiveName();
},
methods: {
embeddedTabChange(tab) {
this.$emit("changed", tab);
},
setActiveName() {
this.activeName = this.activeTab.name || "default";
},
click(tab) {
this.$router.push(this.to(this.tabs.filter(value => value.name === tab)[0]));
},
to(tab) {
if (this.activeTab === tab) {
this.setActiveName()
return this.$route;
} else {
return {
name: this.routeName || this.$route.name,
params: {...this.$route.params, tab: tab.name},
query: {...tab.query}
};
}
},
getTabClasses(tab) {
if(tab.locked) return {"px-0": true};
return {"container": true, "mt-4": true};
}
},
computed: {
containerClass() {
return this.getTabClasses(this.activeTab);
},
activeTab() {
return this.tabs
.filter(tab => (this.embedActiveTab ?? this.$route.params.tab) === tab.name)[0] || this.tabs[0];
},
isEditorActiveTab() {
const TAB = this.activeTab.name;
const ROUTE = this.$route.name;
if (["flows/update", "flows/create"].includes(ROUTE)) {
return TAB === "edit";
} else if (
["namespaces/update", "namespaces/create"].includes(ROUTE)
) {
if (TAB === "files") return true;
}
return false;
},
// Those are passed to the rendered component
// We need to exclude class as it's already applied to this component root div
attrsWithoutClass() {
return Object.fromEntries(
Object.entries(this.$attrs)
.filter(([key]) => key !== "class")
);
},
namespaceToForward(){
return this.activeTab.props?.namespace ?? this.namespace;
// in the special case of Namespace creation on Namespaces page, the tabs are loaded before the namespace creation
// in this case this.props.namespace will be used
}
name: props.routeName || route?.name,
params: {...route?.params, tab: tab.name},
query: {...tab.query}
};
}
};
const getTabClasses = (tab: Tab) => {
if (tab.locked) return {"px-0": true};
return {"container": true, "mt-4": true};
};
if (route) {
watch(route, () => {
setActiveName();
});
}
watch(activeTab, () => {
nextTick(() => {
setActiveName();
});
});
onMounted(() => {
setActiveName();
});
</script>
<style scoped lang="scss">

View File

@@ -373,7 +373,6 @@
import SelectTable from "../layout/SelectTable.vue";
import TriggerAvatar from "../flows/TriggerAvatar.vue";
import KSFilter from "../filter/components/KSFilter.vue";
import useRestoreUrl from "../../composables/useRestoreUrl";
import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
import useRouteContext from "../../composables/useRouteContext";
@@ -474,8 +473,6 @@
.filter(Boolean) as ColumnConfig[]
);
const {saveRestoreUrl} = useRestoreUrl();
const loadData = (callback?: () => void) => {
const query = loadQuery({
size: parseInt(String(route.query?.size ?? "25")),
@@ -501,8 +498,7 @@
const {ready, onSort, onPageChanged, queryWithFilter, load} = useDataTableActions({
dataTableRef: dataTable,
loadData,
saveRestoreUrl
loadData
});
const {

View File

@@ -1,5 +1,5 @@
<template>
<EmptyTemplate>
<EmptyTemplate class="demo-layout">
<img :src="image.source" :alt="image.alt" class="img">
<div class="message-block">
<div class="enterprise-tag">
@@ -45,6 +45,12 @@
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/color-palette.scss";
@import "@kestra-io/ui-libs/src/scss/_variables.scss";
.demo-layout {
padding: $spacer 0 !important;
margin-top: 0 !important;
}
.img {
width: 253px;
@@ -59,8 +65,10 @@
}
.message-block {
width: 665px;
width: 100%;
max-width: 665px;
margin: 0 auto;
padding: 0 1.5rem;
.enterprise-tag::before,
.enterprise-tag::after{
@@ -68,7 +76,6 @@
display: block;
position: absolute;
border-radius: 1rem;
}
.enterprise-tag::before{
@@ -97,11 +104,12 @@
.enterprise-tag{
position: relative;
background: $base-gray-200;
padding: .125rem 1rem;
border-radius: 1rem;
padding: .125rem 0.5rem;
border-radius: $border-radius;
display: inline-block;
z-index: 2;
margin: 0 auto;
font-size: 0.75rem;
html.dark &{
background: #FBFBFB26;
}
@@ -144,36 +152,38 @@
html.dark &{
display: block;
}
}
}
}
.msg-block {
text-align: left;
width: 665px;
margin: 0 auto;
h2 {
margin: 1.5rem 0;
line-height: 30px;
font-size: 20px;
.msg-block {
text-align: left;
width: 100%;
max-width: 665px;
margin: 0 auto;
padding: 0 1.5rem;
h2 {
margin: 1rem 0;
line-height: 20px;
font-size: 14px;
font-weight: 600;
text-align: center;
}
p {
line-height: 22px;
font-size: 14px;
line-height: 16px;
font-size: 11px;
text-align: left;
}
.video-container {
position: relative;
padding-bottom: 56.25%;
border-radius: 8px;
border-radius: $border-radius;
border: 1px solid var(--ks-border-primary);
overflow: hidden;
margin: 1rem auto;
margin: $spacer auto;
iframe {
position: absolute;
@@ -186,5 +196,74 @@
margin: 0;
}
}
}
</style>
}
.img {
width: 60%;
height: auto;
margin-bottom: -1.5rem;
}
@include media-breakpoint-up(md) {
.message-block,
.msg-block {
padding: 0 1rem;
}
.enterprise-tag {
padding: .125rem 0.75rem;
font-size: 0.8125rem;
}
.msg-block {
h2 {
font-size: 16px;
line-height: 24px;
}
p {
font-size: 12px;
line-height: 18px;
}
}
}
@include media-breakpoint-up(lg) {
.enterprise-tag {
font-size: 0.875rem;
padding: .125rem 1rem;
}
.msg-block {
h2 {
font-size: 18px;
line-height: 26px;
margin: 1.5rem 0;
}
p {
font-size: 13px;
line-height: 20px;
}
}
.img {
width: 253px;
height: 212px;
}
}
@include media-breakpoint-up(xl) {
.msg-block {
h2 {
font-size: 20px;
line-height: 30px;
}
p {
font-size: 14px;
line-height: 22px;
}
}
}
</style>

View File

@@ -82,7 +82,7 @@
import {useExecutionsStore} from "../../stores/executions";
import {useAuthStore} from "override/stores/auth";
const props = defineProps<{
const props = withDefaults(defineProps<{
component: string;
execution: {
id: string;
@@ -93,7 +93,10 @@
};
};
tooltipPosition: string;
}>();
}>(), {
component: "el-button",
tooltipPosition: "bottom"
});
const emit = defineEmits<{
follow: [];

View File

@@ -55,7 +55,7 @@
</template>
<template v-if="showStatChart()" #top>
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault />
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault class="mb-4" />
</template>
<template #table>
@@ -70,7 +70,7 @@
@selection-change="handleSelectionChange"
:selectable="!hidden?.includes('selection') && canCheck"
:no-data-text="$t('no_results.executions')"
:rowKey="(row: any) => `${row.namespace}-${row.id}`"
:rowKey="(row: any) => row.id"
>
<template #select-actions>
<BulkSelect
@@ -384,7 +384,7 @@
import _merge from "lodash/merge";
import {useI18n} from "vue-i18n";
import {useRoute, useRouter} from "vue-router";
import {ref, computed, onMounted, watch, h, useTemplateRef} from "vue";
import {ref, computed, watch, h, useTemplateRef} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {ElMessageBox, ElSwitch, ElFormItem, ElAlert, ElCheckbox} from "element-plus";
@@ -423,18 +423,17 @@
import {filterValidLabels} from "./utils";
import {useToast} from "../../utils/toast";
import {storageKeys} from "../../utils/constants";
import {defaultNamespace} from "../../composables/useNamespaces";
import {humanizeDuration, invisibleSpace} from "../../utils/filters";
import Utils from "../../utils/utils";
import action from "../../models/action";
import permission from "../../models/permission";
import useRestoreUrl from "../../composables/useRestoreUrl";
import useRouteContext from "../../composables/useRouteContext";
import {useTableColumns} from "../../composables/useTableColumns";
import {useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions";
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
import {useFlowStore} from "../../stores/flow";
import {useAuthStore} from "override/stores/auth";
@@ -495,7 +494,6 @@
const selectedStatus = ref(undefined);
const lastRefreshDate = ref(new Date());
const unqueueDialogVisible = ref(false);
const isDefaultNamespaceAllow = ref(true);
const changeStatusDialogVisible = ref(false);
const actionOptions = ref<Record<string, any>>({});
const dblClickRouteName = ref("executions/update");
@@ -613,11 +611,6 @@
const routeInfo = computed(() => ({title: t("executions")}));
useRouteContext(routeInfo, props.embed);
const {saveRestoreUrl} = useRestoreUrl({
restoreUrl: true,
isDefaultNamespaceAllow: isDefaultNamespaceAllow.value
});
const dataTableRef = ref(null);
const selectTableRef = useTemplateRef<typeof SelectTable>("selectTable");
@@ -633,8 +626,7 @@
dblClickRouteName: dblClickRouteName.value,
embed: props.embed,
dataTableRef,
loadData: loadData,
saveRestoreUrl
loadData: loadData
});
const {
@@ -1042,29 +1034,10 @@
emit("state-count", {runningCount, totalCount});
};
onMounted(() => {
const query = {...route.query};
let queryHasChanged = false;
const queryKeys = Object.keys(query);
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
query["filters[scope][EQUALS]"] = "USER";
queryHasChanged = true;
}
if (queryHasChanged) {
router.replace({query});
}
if (route.name === "flows/update") {
optionalColumns.value = optionalColumns.value.
filter(col => col.prop !== "namespace" && col.prop !== "flowId");
}
useApplyDefaultFilter({
namespace: props.namespace,
includeTimeRange: true,
includeScope: true
});
watch(isOpenLabelsModal, (opening) => {

View File

@@ -593,6 +593,9 @@
line-height: 2rem;
color: var(--ks-content-error) !important;
font-size: var(--font-size-sm);
word-break: break-all;
overflow-wrap: anywhere;
white-space: normal;
span {
font-weight: normal;
@@ -600,10 +603,15 @@
code{
color: var(--ks-log-content-error) !important;
word-break: break-all;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
> div {
padding-right: 3rem;
word-break: break-all;
overflow-wrap: anywhere;
}
.main-icon.material-design-icon {
@@ -628,6 +636,9 @@
.el-alert__description {
color: var(--ks-content-primary);
word-break: break-all;
overflow-wrap: anywhere;
white-space: normal;
}
.el-alert__content {
@@ -649,6 +660,8 @@
.line {
padding: .5rem;
border-top: 1px solid var(--ks-log-background-error);
word-break: break-all;
overflow-wrap: anywhere;
}
}
</style>

View File

@@ -150,7 +150,7 @@ export function useExecutionRoot() {
follow();
window.addEventListener("popstate", follow);
dependenciesCount.value = (await flowStore.loadDependencies({namespace: route.params.namespace as string, id: route.params.flowId as string, subtype: "FLOW"})).count;
dependenciesCount.value = (await flowStore.loadDependencies({namespace: route.params.namespace as string, id: route.params.flowId as string, subtype: "FLOW"}, true)).count;
previousExecutionId.value = route.params.id as string;
});

View File

@@ -24,7 +24,7 @@
@remove="emit('remove', $event)"
/>
<el-button
type="text"
link
size="small"
class="close"
:icon="Close"

View File

@@ -3,7 +3,7 @@
<el-button
v-if="!!filterKey"
ref="buttonRef"
type="text"
link
size="small"
:icon="PencilOutline"
class="edit-button"

View File

@@ -2,7 +2,7 @@
<div class="filter-header">
<label class="filter-label">{{ label }}</label>
<el-button
type="text"
link
size="small"
:icon="Close"
@click="emits('close')"

View File

@@ -0,0 +1,70 @@
import {onMounted} from "vue";
import {LocationQuery, useRoute, useRouter} from "vue-router";
import {useMiscStore} from "override/stores/misc";
import {defaultNamespace} from "../../../composables/useNamespaces";
interface DefaultFilterOptions {
namespace?: string;
includeTimeRange?: boolean;
includeScope?: boolean;
legacyQuery?: boolean;
}
const NAMESPACE_FILTER_PREFIX = "filters[namespace]";
const SCOPE_FILTER_PREFIX = "filters[scope]";
const TIME_RANGE_FILTER_PREFIX = "filters[timeRange]";
const hasFilterKey = (query: LocationQuery, prefix: string): boolean =>
Object.keys(query).some(key => key.startsWith(prefix));
export function applyDefaultFilters(
currentQuery: LocationQuery,
options: DefaultFilterOptions & {
configuration?: any;
route?: any
} = {}): { query: LocationQuery; hasChanges: boolean } {
const {configuration, route, namespace, includeTimeRange, includeScope, legacyQuery = false} = options;
const hasTimeRange = configuration && route
? configuration.keys?.some((k: any) => k.key === "timeRange") ?? false
: includeTimeRange ?? false;
const hasScope = configuration && route
? route?.name !== "logs/list" && (configuration.keys?.some((k: any) => k.key === "scope") ?? false)
: includeScope ?? false;
const query = {...currentQuery};
let hasChanges = false;
if (namespace === undefined && defaultNamespace() && !hasFilterKey(query, NAMESPACE_FILTER_PREFIX)) {
query[legacyQuery ? "namespace" : `${NAMESPACE_FILTER_PREFIX}[PREFIX]`] = defaultNamespace();
hasChanges = true;
}
if (hasScope && !hasFilterKey(query, SCOPE_FILTER_PREFIX)) {
query[legacyQuery ? "scope" : `${SCOPE_FILTER_PREFIX}[EQUALS]`] = "USER";
hasChanges = true;
}
const TIME_FILTER_KEYS = /startDate|endDate|timeRange/;
if (hasTimeRange && !Object.keys(query).some(key => TIME_FILTER_KEYS.test(key))) {
const defaultDuration = useMiscStore().configs?.chartDefaultDuration ?? "P30D";
query[legacyQuery ? "timeRange" : `${TIME_RANGE_FILTER_PREFIX}[EQUALS]`] = defaultDuration;
hasChanges = true;
}
return {query, hasChanges};
}
export function useApplyDefaultFilter(options?: DefaultFilterOptions) {
const router = useRouter();
const route = useRoute();
onMounted(() => {
const {query, hasChanges} = applyDefaultFilters(route.query, options);
if (hasChanges) {
router.replace({query});
}
});
}

View File

@@ -17,6 +17,7 @@ import {
KV_COMPARATORS
} from "../utils/filterTypes";
import {usePreAppliedFilters} from "./usePreAppliedFilters";
import {applyDefaultFilters} from "./useDefaultFilter";
export function useFilters(configuration: FilterConfiguration, showSearchInput = true, legacyQuery = false) {
const router = useRouter();
@@ -28,8 +29,7 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
const {
markAsPreApplied,
hasPreApplied,
getPreApplied,
getAllPreApplied
getPreApplied
} = usePreAppliedFilters();
const appendQueryParam = (query: Record<string, any>, key: string, value: string) => {
@@ -367,13 +367,10 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
updateRoute();
};
/**
* Resets all filters to their pre-applied state and clears the search query
*/
const resetToPreApplied = () => {
appliedFilters.value = getAllPreApplied();
const defaultQuery = applyDefaultFilters({}, {configuration, route, legacyQuery}).query;
searchQuery.value = "";
updateRoute();
router.push({query: defaultQuery});
};
return {

View File

@@ -6,7 +6,7 @@ export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => compu
const {t} = useI18n();
return {
title: t("filter.titles.namespaces_filters"),
title: t("filter.titles.namespace_filters"),
searchPlaceholder: t("filter.search_placeholders.search_namespaces"),
keys: [],
};

View File

@@ -5,7 +5,7 @@
<h6>{{ t("filter.customize columns") }}</h6>
<small>{{ t("filter.drag to reorder columns") }}</small>
</div>
<el-button type="text" :icon="Close" @click="$emit('close')" size="small" class="close-icon" />
<el-button link :icon="Close" @click="$emit('close')" size="small" class="close-icon" />
</div>
<div class="list">

View File

@@ -6,7 +6,7 @@
<small>{{ t("filter.select filter") }}</small>
</div>
<el-button
type="text"
link
:icon="Close"
@click="$emit('close')"
size="small"
@@ -27,7 +27,7 @@
</div>
<el-button
type="text"
link
size="default"
:icon="isSelected(key) ? undefined : Plus"
:class="isSelected(key) ? 'selected' : 'unselected'"

View File

@@ -5,7 +5,7 @@
{{ $t("filter.saved filters") }}
</h6>
<el-button
type="text"
link
:icon="Close"
@click="$emit('close')"
size="small"
@@ -28,7 +28,7 @@
<div class="action-buttons">
<el-tooltip :content="$t('filter.edit filter')" placement="top" effect="light">
<el-button
type="text"
link
size="small"
class="edit-button"
:icon="PencilOutline"
@@ -37,7 +37,7 @@
</el-tooltip>
<el-tooltip :content="$t('filter.delete filter')" placement="top" effect="light">
<el-button
type="text"
link
size="small"
class="delete-button"
:icon="Delete"

View File

@@ -3,7 +3,6 @@
:namespace="flowStore.flow?.namespace"
:flowId="flowStore.flow?.id"
:topbar="false"
:restoreUrl="false"
filter
/>
</template>

View File

@@ -249,8 +249,8 @@
<script setup lang="ts">
import {ref, computed, onMounted, useTemplateRef} from "vue";
import {useRoute, useRouter} from "vue-router";
import {ref, computed, useTemplateRef} from "vue";
import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n";
import _merge from "lodash/merge";
import * as FILTERS from "../../utils/filters";
@@ -284,7 +284,6 @@
import permission from "../../models/permission";
import {useToast} from "../../utils/toast";
import {defaultNamespace} from "../../composables/useNamespaces";
import {useFlowStore} from "../../stores/flow";
import {useAuthStore} from "override/stores/auth";
@@ -294,7 +293,7 @@
import {useTableColumns} from "../../composables/useTableColumns";
import {DataTableRef, useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions";
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
const props = withDefaults(defineProps<{
topbar?: boolean;
@@ -312,7 +311,6 @@
const miscStore = useMiscStore();
const route = useRoute();
const router = useRouter();
const {t} = useI18n();
const toast = useToast()
@@ -497,6 +495,11 @@
updateVisibleColumns(newColumns);
}
useApplyDefaultFilter({
namespace: props.namespace,
includeScope: true
});
function exportFlows() {
toast.confirm(
t("flow export", {flowCount: queryBulkAction.value ? flowStore.total : selection.value.length}),
@@ -633,25 +636,6 @@
operation: "EQUALS"
}];
}
onMounted(() => {
const query = {...route.query};
const queryKeys = Object.keys(query);
let queryHasChanged = false;
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
query["filters[scope][EQUALS]"] = "USER";
queryHasChanged = true;
}
if (queryHasChanged) router.replace({query});
});
</script>
<style scoped lang="scss">

View File

@@ -56,7 +56,6 @@
import DataTable from "../layout/DataTable.vue";
import SearchField from "../layout/SearchField.vue";
import NamespaceSelect from "../namespaces/components/NamespaceSelect.vue";
import useRestoreUrl from "../../composables/useRestoreUrl";
import useRouteContext from "../../composables/useRouteContext";
import {useDataTableActions} from "../../composables/useDataTableActions";
@@ -77,11 +76,9 @@
}));
useRouteContext(routeInfo);
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true, isDefaultNamespaceAllow: true});
const {onPageChanged, onDataTableValue, queryWithFilter, ready} = useDataTableActions({
loadData,
saveRestoreUrl
loadData
});
const namespace = computed({

View File

@@ -59,12 +59,12 @@
const {t} = useI18n();
const exportYaml = () => {
const src = flowStore.flowYaml
if(!src) {
return;
}
const blob = new Blob([src], {type: "text/yaml"});
localUtils.downloadUrl(window.URL.createObjectURL(blob), "flow.yaml");
if(!flowStore.flow || !flowStore.flowYaml) return;
const {id, namespace} = flowStore.flow;
const blob = new Blob([flowStore.flowYaml], {type: "text/yaml"});
localUtils.downloadUrl(window.URL.createObjectURL(blob), `${namespace}.${id}.yaml`);
};
const flowStore = useFlowStore();

View File

@@ -272,7 +272,6 @@
import DataTable from "../layout/DataTable.vue";
import _merge from "lodash/merge";
import {type DataTableRef, useDataTableActions} from "../../composables/useDataTableActions.ts";
const dataTable = useTemplateRef<DataTableRef>("dataTable");
const loadData = async (callback?: () => void) => {

View File

@@ -25,7 +25,7 @@
</div>
<el-button
type="text"
link
size="default"
:icon="isVisible(column) ? EyeOutline : EyeOffOutline"
:class="isVisible(column) ? 'selected' : 'unselected'"

View File

@@ -28,16 +28,11 @@
</template>
<script setup lang="ts">
import {
onUpdated,
ref,
computed, h
} from "vue";
import {onUpdated, ref, computed, h, watch} from "vue";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import {useMediaQuery} from "@vueuse/core";
import {SidebarMenu} from "vue-sidebar-menu";
import StarOutline from "vue-material-design-icons/StarOutline.vue";
import Environment from "./Environment.vue";
@@ -124,6 +119,14 @@
});
const collapsed = ref(localStorage.getItem("menuCollapsed") === "true")
const isSmallScreen = useMediaQuery("(max-width: 768px)")
watch(() => $route.name, (newRoute, oldRoute) => {
if (newRoute !== oldRoute && isSmallScreen.value && !collapsed.value) {
onToggleCollapse(true)
}
})
</script>
<style scoped lang="scss">

View File

@@ -161,6 +161,21 @@
overflow: hidden;
}
.top-title {
position: relative;
&::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 100%;
background: linear-gradient(to left, var(--ks-background-card), transparent);
pointer-events: none;
}
}
h1 {
line-height: 1.6;
display: flex !important;
@@ -212,7 +227,14 @@
align-items: center;
}
}
@media (max-width: 992px) {
padding: 0.75rem 1.5rem;
}
@media (max-width: 768px) {
padding: 0.4rem 0.75rem;
.mycontainer {
display: grid;
grid-template-columns: repeat(3, minmax(0, auto));
@@ -227,6 +249,8 @@
}
}
@media (max-width: 664px) {
padding: 0.3rem 0.5rem;
.mycontainer {
display: grid;
grid-template-columns: repeat(2, minmax(0, auto));

View File

@@ -2,7 +2,7 @@
<TopNavBar v-if="!embed" :title="routeInfo.title" />
<section v-bind="$attrs" :class="{'container': !embed}" class="log-panel">
<div class="log-content">
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="pageSize" :page="pageNumber" :embed="embed">
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="internalPageSize" :page="internalPageNumber" :embed="embed">
<template #navbar v-if="!embed || showFilters">
<KSFilter
:configuration="logFilter"
@@ -15,12 +15,12 @@
</template>
<template v-if="showStatChart()" #top>
<Sections ref="dashboard" :charts :dashboard="{id: 'default', charts: []}" showDefault />
<Sections ref="dashboardRef" :charts :dashboard="{id: 'default', charts: []}" showDefault class="mb-4" />
</template>
<template #table>
<div v-loading="isLoading">
<div v-if="logsStore.logs !== undefined && logsStore.logs.length > 0" class="logs-wrapper">
<div v-if="logsStore.logs !== undefined && logsStore.logs?.length > 0" class="logs-wrapper">
<LogLine
v-for="(log, i) in logsStore.logs"
:key="`${log.taskRunId}-${i}`"
@@ -42,6 +42,11 @@
</template>
<script setup lang="ts">
import {ref, computed, onMounted, watch} from "vue";
import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n";
import _merge from "lodash/merge";
import moment from "moment";
import {useLogFilter} from "../filter/configurations";
import KSFilter from "../filter/components/KSFilter.vue";
import Sections from "../dashboard/sections/Sections.vue";
@@ -49,193 +54,151 @@
import TopNavBar from "../../components/layout/TopNavBar.vue";
import LogLine from "../logs/LogLine.vue";
import NoData from "../layout/NoData.vue";
const logFilter = useLogFilter();
</script>
<script lang="ts">
import {mapStores} from "pinia";
import RouteContext from "../../mixins/routeContext";
import RestoreUrl from "../../mixins/restoreUrl";
import DataTableActions from "../../mixins/dataTableActions";
import _merge from "lodash/merge";
import {storageKeys} from "../../utils/constants";
import {decodeSearchParams} from "../filter/utils/helpers";
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";
import {defineComponent} from "vue";
import {useDataTableActions} from "../../composables/useDataTableActions";
import useRouteContext from "../../composables/useRouteContext";
export default defineComponent({
mixins: [RouteContext, RestoreUrl, DataTableActions],
props: {
logLevel: {
type: String,
default: undefined
},
embed: {
type: Boolean,
default: false
},
showFilters: {
type: Boolean,
default: false
},
filters: {
type: Object,
default: null
},
reloadLogs: {
type: Number,
default: undefined
}
},
data() {
return {
isDefaultNamespaceAllow: true,
task: undefined,
isLoading: false,
lastRefreshDate: new Date(),
canAutoRefresh: false,
showChart: localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false",
};
},
computed: {
storageKeys() {
return storageKeys
},
...mapStores(useLogsStore),
routeInfo() {
return {
title: this.$t("logs"),
};
},
isFlowEdit() {
return this.$route.name === "flows/update"
},
isNamespaceEdit() {
return this.$route.name === "namespaces/update"
},
selectedLogLevel() {
const decodedParams = decodeSearchParams(this.$route.query);
const levelFilters = decodedParams.filter(item => item?.field === "level");
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
return this.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
},
endDate() {
if (this.$route.query.endDate) {
return this.$route.query.endDate;
}
return undefined;
},
startDate() {
// we mention the last refresh date here to trick
// VueJs fine grained reactivity system and invalidate
// computed property startDate
if (this.$route.query.startDate && this.lastRefreshDate) {
return this.$route.query.startDate;
}
if (this.$route.query.timeRange) {
return this.$moment().subtract(this.$moment.duration(this.$route.query.timeRange).as("milliseconds")).toISOString(true);
}
const props = withDefaults(defineProps<{
logLevel?: string;
embed?: boolean;
showFilters?: boolean;
filters?: Record<string, any>;
reloadLogs?: number;
}>(), {
embed: false,
showFilters: false,
filters: undefined,
logLevel: undefined,
reloadLogs: undefined
});
// the default is PT30D
return this.$moment().subtract(7, "days").toISOString(true);
},
namespace() {
return this.$route.params.namespace ?? this.$route.params.id;
},
flowId() {
return this.$route.params.id;
},
charts() {
return [
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
];
}
},
beforeRouteEnter(to: any, _: any, next: (route?: any) => void) {
const query = {...to.query};
let queryHasChanged = false;
const route = useRoute();
const {t} = useI18n();
const logsStore = useLogsStore();
const logFilter = useLogFilter();
const queryKeys = Object.keys(query);
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
const routeInfo = computed(() => ({
title: t("logs"),
}));
useRouteContext(routeInfo, props.embed);
if (queryHasChanged) {
next({
...to,
query,
replace: true
});
} else {
next();
}
},
methods: {
showStatChart() {
return this.showChart;
},
onShowChartChange(value: boolean) {
this.showChart = value;
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
if (this.showStatChart()) {
this.load();
}
},
refresh() {
this.lastRefreshDate = new Date();
if (this.$refs.dashboard) {
this.$refs.dashboard.refreshCharts();
}
this.load();
},
loadQuery(base: any) {
let queryFilter = this.filters ?? this.queryWithFilter();
const isLoading = ref(false);
const lastRefreshDate = ref(new Date());
const showChart = ref(localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false");
const dashboardRef = ref();
if (this.isFlowEdit) {
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
queryFilter["filters[flowId][EQUALS]"] = this.flowId;
} else if (this.isNamespaceEdit) {
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
}
const isFlowEdit = computed(() => route.name === "flows/update");
const isNamespaceEdit = computed(() => route.name === "namespaces/update");
const selectedLogLevel = computed(() => {
const decodedParams = decodeSearchParams(route.query);
const levelFilters = decodedParams.filter(item => item?.field === "level");
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
return props.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
});
const endDate = computed(() => {
if (route.query.endDate) {
return route.query.endDate;
}
return undefined;
});
const startDate = computed(() => {
// we mention the last refresh date here to trick
// VueJs fine grained reactivity system and invalidate
// computed property startDate
if (route.query.startDate && lastRefreshDate.value) {
return route.query.startDate;
}
if (route.query.timeRange) {
return moment().subtract(moment.duration(route.query.timeRange as string).as("milliseconds")).toISOString(true);
}
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
queryFilter["startDate"] = this.startDate;
queryFilter["endDate"] = this.endDate;
}
// the default is PT30D
return moment().subtract(7, "days").toISOString(true);
});
const flowId = computed(() => route.params.id);
const namespace = computed(() => route.params.namespace ?? route.params.id);
const charts = computed(() => [
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
]);
delete queryFilter["level"];
const loadQuery = (base: any) => {
let queryFilter = props.filters ?? queryWithFilter();
return _merge(base, queryFilter)
},
load() {
this.isLoading = true
if (isFlowEdit.value) {
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
queryFilter["filters[flowId][EQUALS]"] = flowId.value;
} else if (isNamespaceEdit.value) {
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
}
const data = {
page: this.filters ? this.internalPageNumber : this.$route.query.page || this.internalPageNumber,
size: this.filters ? this.internalPageSize : this.$route.query.size || this.internalPageSize,
...this.filters
};
this.logsStore.findLogs(this.loadQuery({
...data,
minLevel: this.filters ? null : this.selectedLogLevel,
sort: "timestamp:desc"
}))
.finally(() => {
this.isLoading = false
this.saveRestoreUrl();
});
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
queryFilter["startDate"] = startDate.value;
queryFilter["endDate"] = endDate.value;
}
},
},
watch: {
reloadLogs(newValue) {
if(newValue) this.refresh();
},
delete queryFilter["level"];
return _merge(base, queryFilter);
};
const loadData = (callback?: () => void) => {
isLoading.value = true;
const data = {
page: props.filters ? internalPageNumber.value : route.query.page || internalPageNumber.value,
size: props.filters ? internalPageSize.value : route.query.size || internalPageSize.value,
...props.filters
};
logsStore.findLogs(loadQuery({
...data,
minLevel: props.filters ? null : selectedLogLevel.value,
sort: "timestamp:desc"
}))
.finally(() => {
isLoading.value = false;
if (callback) callback();
});
};
const {onPageChanged, queryWithFilter, internalPageNumber, internalPageSize} = useDataTableActions({
loadData
});
const showStatChart = () => showChart.value;
const onShowChartChange = (value: boolean) => {
showChart.value = value;
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
if (showStatChart()) {
loadData();
}
};
const refresh = () => {
lastRefreshDate.value = new Date();
if (dashboardRef.value) {
dashboardRef.value.refreshCharts();
}
loadData();
};
watch(() => route.query, () => {
loadData();
}, {deep: true});
watch(() => props.reloadLogs, (newValue) => {
if (newValue) refresh();
});
onMounted(() => {
// Load data on mount if not embedded
if (!props.embed) {
loadData();
}
});
</script>

View File

@@ -62,7 +62,7 @@
</template>
<script setup lang="ts">
import {ref, computed, onBeforeMount} from "vue";
import {ref, computed, onBeforeMount, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {isEntryAPluginElementPredicate, TaskIcon} from "@kestra-io/ui-libs";
import DottedLayout from "../layout/DottedLayout.vue";
@@ -71,6 +71,7 @@
import headerImage from "../../assets/icons/plugin.svg";
import headerImageDark from "../../assets/icons/plugin-dark.svg";
import {usePluginsStore} from "../../stores/plugins";
import useRestoreUrl from "../../composables/useRestoreUrl";
const route = useRoute();
const router = useRouter();
@@ -85,13 +86,23 @@
embed: false
});
const {saveRestoreUrl} = useRestoreUrl();
const icons = ref<Record<string, any>>({});
const searchText = ref("");
const handleSearch = (query: string) => {
searchText.value = query;
const newQuery: Record<string, any> = {...route.query};
if (query !== undefined && query !== null && String(query).trim() !== "") {
newQuery.q = query;
} else {
// remove an empty `q=` in the URL on plugins/view
delete newQuery.q;
}
router.push({
query: {...route.query, q: query || undefined}
query: newQuery
});
};
@@ -177,6 +188,11 @@
loadPluginIcons();
searchText.value = String(route.query?.q ?? "");
});
watch(() => route.query.q, (newQ) => {
searchText.value = String(newQ ?? "");
saveRestoreUrl();
});
</script>
<style scoped lang="scss">

View File

@@ -127,6 +127,7 @@
height: 300px;
overflow: visible;
direction: rtl;
flex-shrink: 0;
}
}
@@ -139,8 +140,9 @@
}
.video-container {
width: 640px;
height: 360px;
width: 100%;
max-width: 640px;
aspect-ratio: 16 / 9;
margin-bottom: 1rem;
border-radius: 8px;
border: 1px solid var(--ks-border-primary);
@@ -152,5 +154,68 @@
border: 0;
}
}
@media (max-width: 1200px) {
padding: 0 4rem;
.header-block {
.img-wrapper {
width: 250px;
height: 214px;
}
}
}
@media (max-width: 992px) {
padding: 0 2rem;
.header-block {
.d-flex.flex-row {
flex-direction: column !important;
align-items: center;
text-align: center;
.d-flex.flex-column {
align-items: center !important;
}
}
.img-wrapper {
width: 200px;
height: 171px;
direction: ltr;
}
}
}
@media (max-width: 768px) {
padding: 0 1.5rem;
.header-block {
p {
font-size: 0.8125rem;
}
}
.video-container {
max-width: 100%;
}
}
@media (max-width: 576px) {
padding: 0 1rem;
.header-block {
h5 {
font-size: 1.125rem;
}
p {
font-size: 0.75rem;
}
}
}
}
</style>
</style>

View File

@@ -3,6 +3,7 @@ import {useRoute, useRouter} from "vue-router";
import _merge from "lodash/merge";
import _cloneDeep from "lodash/cloneDeep";
import _isEqual from "lodash/isEqual";
import useRestoreUrl from "./useRestoreUrl";
interface SortItem {
prop?: string;
@@ -26,7 +27,6 @@ interface DataTableActionsOptions {
embed?: boolean;
dataTableRef?: Ref<DataTableRef | null>;
loadData?: (callback?: () => void) => void;
saveRestoreUrl?: () => void;
}
export function useDataTableActions(options: DataTableActionsOptions = {}) {
@@ -35,7 +35,6 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
const sort = ref("");
const dblClickRouteName = ref(options.dblClickRouteName);
const loadInit = ref(true);
const ready = ref(false);
const internalPageSize = ref(25);
const internalPageNumber = ref(1);
@@ -47,6 +46,8 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
const embed = computed(() => options.embed);
const dataTableRef = computed(() => options.dataTableRef?.value);
const {loadInit, saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
const sortString = (sortItem: SortItem, sortKeyMapper: (k: string) => string): string | undefined => {
if (sortItem && sortItem.prop && sortItem.order) {
return `${sortKeyMapper(sortItem.prop)}:${sortItem.order === "descending" ? "desc" : "asc"}`;
@@ -149,9 +150,7 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
ready.value = true;
loadInit.value = true;
if (options.saveRestoreUrl) {
options.saveRestoreUrl();
}
saveRestoreUrl();
if (dataTableRef.value) {
dataTableRef.value.isLoading = false;

View File

@@ -47,6 +47,11 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
}
};
/**
* Merges saved URL query parameters from sessionStorage with current route.
* Only adds missing parameters to avoid overwriting user changes.
* Updates route only when changes are made.
*/
const goToRestoreUrl = () => {
if (!restoreUrl) {
return;
@@ -84,9 +89,12 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
}
};
// Automatically call goToRestoreUrl on mount if needed (equivalent to created() hook)
/**
* Automatically restores saved URL state from sessionStorage on mount.
* Only triggers when restoreUrl is enabled and saved state exists.
*/
onMounted(() => {
if (Object.keys(route.query).length === 0 && restoreUrl) {
if (restoreUrl && localStorageValue.value) {
loadInit.value = false;
goToRestoreUrl();
}

View File

@@ -107,7 +107,6 @@
import {useDocStore} from "../../../../stores/doc";
import {canCreate} from "override/composables/blueprintsPermissions";
import {useDataTableActions} from "../../../../composables/useDataTableActions";
import useRestoreUrl from "../../../../composables/useRestoreUrl";
import {useBlueprintFilter} from "../../../../components/filter/configurations";
const blueprintFilter = useBlueprintFilter();
@@ -128,8 +127,6 @@
const {onPageChanged, onDataLoaded, load, ready, internalPageNumber, internalPageSize} = useDataTableActions({loadData});
useRestoreUrl();
const emit = defineEmits(["goToDetail", "loaded"]);
const route = useRoute();
@@ -273,15 +270,13 @@
docStore.docId = `blueprints.${props.blueprintType}`;
});
watch(route,
(newValue, oldValue) => {
if (oldValue.name === newValue.name) {
selectedTags.value = initSelectedTags();
searchText.value = route.query.q || "";
load(onDataLoaded);
}
}
);
watch(route, (newRoute, oldRoute) => {
if (newRoute.name === oldRoute.name) {
selectedTags.value = initSelectedTags();
searchText.value = newRoute.query.q || "";
load(onDataLoaded);
}
});
watch(searchText, () => {
load(onDataLoaded);

View File

@@ -89,11 +89,14 @@
import permission from "../../../models/permission";
import action from "../../../models/action";
import useRestoreUrl from "../../../composables/useRestoreUrl";
import DotsSquare from "vue-material-design-icons/DotsSquare.vue";
import TextSearch from "vue-material-design-icons/TextSearch.vue";
import {useAuthStore} from "override/stores/auth";
const namespacesFilter = useNamespacesFilter();
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
interface Node {
id: string;
@@ -127,8 +130,12 @@
onMounted(() => loadData());
watch(
() => route.query,
() => loadData(),
() => route.query.q,
() => {
loadData();
saveRestoreUrl();
},
{immediate: true}
);
const miscStore = useMiscStore();

View File

@@ -7,21 +7,23 @@ import DemoAuditLogs from "../components/demo/AuditLogs.vue"
import DemoInstance from "../components/demo/Instance.vue"
import DemoApps from "../components/demo/Apps.vue"
import DemoTests from "../components/demo/Tests.vue"
import {useMiscStore} from "override/stores/misc";
import {applyDefaultFilters} from "../components/filter/composables/useDefaultFilter";
function maybeAddTimeRangeFilter(to) {
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
// Default to the configured duration if no time range is set
if (!Object.keys(to.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
const miscStore = useMiscStore();
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D"; // Fallback to 30 days
to.query["filters[timeRange][EQUALS]"] = defaultDuration;
return true;
}
return false;
export function applyBeforeEnterFilter(options) {
return (to, _from, next) => {
const {query, hasChanges} = applyDefaultFilters(to.query, options);
if (hasChanges) {
next({
name: to.name,
params: to.params,
query,
});
return;
}
next();
};
}
export default [
@@ -35,15 +37,6 @@ export default [
path: "/:tenant?/dashboards/:dashboard?",
component: () => import("../components/dashboard/Dashboard.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
if (!to.params.dashboard) {
next({
name: "home",
@@ -53,16 +46,21 @@ export default [
},
query: to.query,
});
} else {
next();
return;
}
applyBeforeEnterFilter({includeTimeRange: true, includeScope: false})(to, from, next);
},
},
{name: "dashboards/create", path: "/:tenant?/dashboards/new", component: () => import("../components/dashboard/components/Create.vue")},
{name: "dashboards/update", path: "/:tenant?/dashboards/:dashboard/edit", component: () => import("override/components/dashboard/Edit.vue")},
//Flows
{name: "flows/list", path: "/:tenant?/flows", component: () => import("../components/flows/Flows.vue")},
{
name: "flows/list",
path: "/:tenant?/flows",
component: () => import("../components/flows/Flows.vue"),
beforeEnter: applyBeforeEnterFilter({includeTimeRange: false, includeScope: true}),
},
{name: "flows/search", path: "/:tenant?/flows/search", component: () => import("../components/flows/FlowsSearch.vue")},
{name: "flows/create", path: "/:tenant?/flows/new", component: () => import("../components/flows/FlowCreate.vue")},
{name: "flows/update", path: "/:tenant?/flows/edit/:namespace/:id/:tab?", component: () => import("../components/flows/FlowRoot.vue")},
@@ -72,18 +70,7 @@ export default [
name: "executions/list",
path: "/:tenant?/executions",
component: () => import("../components/executions/Executions.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
next();
}
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: true}),
},
{name: "executions/update", path: "/:tenant?/executions/:namespace/:flowId/:id/:tab?", component: () => import("../components/executions/ExecutionRoot.vue")},
@@ -111,18 +98,7 @@ export default [
name: "logs/list",
path: "/:tenant?/logs",
component: () => import("../components/logs/LogsWrapper.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
next();
}
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: false}),
},
//Namespaces

View File

@@ -439,7 +439,7 @@ export const useFlowStore = defineStore("flow", () => {
}
function loadDependencies(options: { namespace: string, id: string, subtype: "FLOW" | "EXECUTION" }, onlyCount = false) {
return axios.get(`${apiUrl()}/flows/${options.namespace}/${options.id}/dependencies?expandAll=true`).then(response => {
return axios.get(`${apiUrl()}/flows/${options.namespace}/${options.id}/dependencies?expandAll=${onlyCount ? false : true}`).then(response => {
return {
...(!onlyCount ? {data: transformResponse(response.data, options.subtype)} : {}),
count: response.data.nodes ? new Set(response.data.nodes.map((r:{uid:string}) => r.uid)).size : 0