mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-25 11:12:12 -05:00
Compare commits
24 Commits
fix/remove
...
spike/gene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349c5ad4ad | ||
|
|
219d68a092 | ||
|
|
2e11a6c851 | ||
|
|
d48f3b9bd9 | ||
|
|
291fba3281 | ||
|
|
db3b3236ac | ||
|
|
5a8a631b47 | ||
|
|
2da191896f | ||
|
|
111026369b | ||
|
|
e3a0e59e9c | ||
|
|
aadd0877d5 | ||
|
|
ec19287685 | ||
|
|
1a9bdf6caa | ||
|
|
dbeface7c6 | ||
|
|
ea9a86545c | ||
|
|
8b5af1f8a3 | ||
|
|
85adf521be | ||
|
|
5dd0ad6036 | ||
|
|
33abe9980e | ||
|
|
2a4097fbc9 | ||
|
|
059262514c | ||
|
|
c6b7021a0b | ||
|
|
1548e31182 | ||
|
|
aae8011221 |
170
build.gradle
170
build.gradle
@@ -171,13 +171,22 @@ allprojects {
|
||||
subprojects {subProj ->
|
||||
|
||||
if (subProj.name != 'platform' && subProj.name != 'jmh-benchmarks') {
|
||||
|
||||
apply plugin: "com.adarshr.test-logger"
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
java {
|
||||
sourceCompatibility = targetJavaVersion
|
||||
targetCompatibility = targetJavaVersion
|
||||
}
|
||||
|
||||
configurations {
|
||||
agent {
|
||||
canBeResolved = true
|
||||
canBeConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Platform
|
||||
testAnnotationProcessor enforcedPlatform(project(":platform"))
|
||||
@@ -204,9 +213,16 @@ subprojects {subProj ->
|
||||
|
||||
//assertj
|
||||
testImplementation 'org.assertj:assertj-core'
|
||||
|
||||
agent "org.aspectj:aspectjweaver:1.9.25.1"
|
||||
|
||||
testImplementation platform("io.qameta.allure:allure-bom")
|
||||
testImplementation "io.qameta.allure:allure-junit5"
|
||||
}
|
||||
|
||||
def commonTestConfig = { Test t ->
|
||||
t.ignoreFailures = true
|
||||
|
||||
// set Xmx for test workers
|
||||
t.maxHeapSize = '4g'
|
||||
|
||||
@@ -232,6 +248,52 @@ subprojects {subProj ->
|
||||
// }
|
||||
}
|
||||
|
||||
tasks.register('integrationTest', Test) { Test t ->
|
||||
description = 'Runs integration tests'
|
||||
group = 'verification'
|
||||
|
||||
useJUnitPlatform {
|
||||
includeTags 'integration'
|
||||
}
|
||||
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
|
||||
reports {
|
||||
junitXml.required = true
|
||||
junitXml.outputPerTestCase = true
|
||||
junitXml.mergeReruns = true
|
||||
junitXml.includeSystemErrLog = true
|
||||
junitXml.outputLocation = layout.buildDirectory.dir("test-results/test")
|
||||
}
|
||||
|
||||
// Integration tests typically not parallel (but you can enable)
|
||||
maxParallelForks = 1
|
||||
commonTestConfig(t)
|
||||
}
|
||||
|
||||
tasks.register('unitTest', Test) { Test t ->
|
||||
description = 'Runs unit tests'
|
||||
group = 'verification'
|
||||
|
||||
useJUnitPlatform {
|
||||
excludeTags 'flaky', 'integration'
|
||||
}
|
||||
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
|
||||
reports {
|
||||
junitXml.required = true
|
||||
junitXml.outputPerTestCase = true
|
||||
junitXml.mergeReruns = true
|
||||
junitXml.includeSystemErrLog = true
|
||||
junitXml.outputLocation = layout.buildDirectory.dir("test-results/test")
|
||||
}
|
||||
|
||||
commonTestConfig(t)
|
||||
}
|
||||
|
||||
tasks.register('flakyTest', Test) { Test t ->
|
||||
group = 'verification'
|
||||
description = 'Runs tests tagged @Flaky but does not fail the build.'
|
||||
@@ -239,7 +301,6 @@ subprojects {subProj ->
|
||||
useJUnitPlatform {
|
||||
includeTags 'flaky'
|
||||
}
|
||||
ignoreFailures = true
|
||||
|
||||
reports {
|
||||
junitXml.required = true
|
||||
@@ -249,10 +310,13 @@ subprojects {subProj ->
|
||||
junitXml.outputLocation = layout.buildDirectory.dir("test-results/flakyTest")
|
||||
}
|
||||
commonTestConfig(t)
|
||||
|
||||
}
|
||||
|
||||
test {
|
||||
// test task (default)
|
||||
tasks.named('test', Test) { Test t ->
|
||||
group = 'verification'
|
||||
description = 'Runs all non-flaky tests.'
|
||||
|
||||
useJUnitPlatform {
|
||||
excludeTags 'flaky'
|
||||
}
|
||||
@@ -263,10 +327,12 @@ subprojects {subProj ->
|
||||
junitXml.includeSystemErrLog = true
|
||||
junitXml.outputLocation = layout.buildDirectory.dir("test-results/test")
|
||||
}
|
||||
commonTestConfig(it)
|
||||
commonTestConfig(t)
|
||||
jvmArgs = ["-javaagent:${configurations.agent.singleFile}"]
|
||||
}
|
||||
|
||||
|
||||
finalizedBy(tasks.named('flakyTest'))
|
||||
tasks.named('check') {
|
||||
dependsOn(tasks.named('test'))// default behaviour
|
||||
}
|
||||
|
||||
testlogger {
|
||||
@@ -282,83 +348,25 @@ subprojects {subProj ->
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************************************************************************************************\
|
||||
* End-to-End Tests
|
||||
**********************************************************************************************************************/
|
||||
def e2eTestsCheck = tasks.register('e2eTestsCheck') {
|
||||
group = 'verification'
|
||||
description = "Runs the 'check' task for all e2e-tests modules"
|
||||
doFirst {
|
||||
project.ext.set("e2e-tests", true)
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
// Add e2e-tests modules check tasks to e2eTestsCheck
|
||||
if (project.name.startsWith("e2e-tests")) {
|
||||
test {
|
||||
onlyIf {
|
||||
project.hasProperty("e2e-tests")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
// Add e2e-tests modules check tasks to e2eTestsCheck
|
||||
if (project.name.startsWith("e2e-tests")) {
|
||||
e2eTestsCheck.configure {
|
||||
finalizedBy(check)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************************************************************************************************\
|
||||
* Allure Reports
|
||||
**********************************************************************************************************************/
|
||||
subprojects {
|
||||
if (it.name != 'platform' && it.name != 'jmh-benchmarks') {
|
||||
dependencies {
|
||||
testImplementation platform("io.qameta.allure:allure-bom")
|
||||
testImplementation "io.qameta.allure:allure-junit5"
|
||||
}
|
||||
|
||||
configurations {
|
||||
agent {
|
||||
canBeResolved = true
|
||||
canBeConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
agent "org.aspectj:aspectjweaver:1.9.25.1"
|
||||
}
|
||||
|
||||
test {
|
||||
jvmArgs = ["-javaagent:${configurations.agent.singleFile}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************************************************************************************************\
|
||||
* Jacoco
|
||||
**********************************************************************************************************************/
|
||||
subprojects {
|
||||
if (it.name != 'platform' && it.name != 'jmh-benchmarks') {
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
test {
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
jacocoTestReport {
|
||||
dependsOn test
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('testCodeCoverageReport', JacocoReport)
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
tasks.register('unitTest') {
|
||||
// No jacocoTestReport here, because it depends by default on :test,
|
||||
// and that would make :test being run twice in our CI.
|
||||
// In practice the report will be generated later in the CI by :check.
|
||||
}
|
||||
|
||||
tasks.register('integrationTest') {
|
||||
dependsOn tasks.named('testCodeCoverageReport', JacocoReport)
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
tasks.register('flakyTest') {
|
||||
dependsOn tasks.named('testCodeCoverageReport', JacocoReport)
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
tasks.named('testCodeCoverageReport') {
|
||||
|
||||
@@ -151,6 +151,12 @@ public record QueryFilter(
|
||||
return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, Op.ENDS_WITH, Op.IN, Op.NOT_IN);
|
||||
}
|
||||
},
|
||||
TRIGGER_STATE("triggerState"){
|
||||
@Override
|
||||
public List<Op> supportedOp() {
|
||||
return List.of(Op.EQUALS, Op.NOT_EQUALS);
|
||||
}
|
||||
},
|
||||
EXECUTION_ID("executionId") {
|
||||
@Override
|
||||
public List<Op> supportedOp() {
|
||||
@@ -271,7 +277,7 @@ public record QueryFilter(
|
||||
@Override
|
||||
public List<Field> supportedField() {
|
||||
return List.of(Field.QUERY, Field.SCOPE, Field.NAMESPACE, Field.WORKER_ID, Field.FLOW_ID,
|
||||
Field.START_DATE, Field.END_DATE, Field.TRIGGER_ID
|
||||
Field.START_DATE, Field.END_DATE, Field.TRIGGER_ID, Field.TRIGGER_STATE
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.kestra.core.plugins.notifications;
|
||||
|
||||
import io.kestra.core.models.property.Property;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ExecutionInterface {
|
||||
@Schema(
|
||||
title = "The execution id to use",
|
||||
description = "Default is the current execution, " +
|
||||
"change it to {{ trigger.executionId }} if you use this task with a Flow trigger to use the original execution."
|
||||
)
|
||||
Property<String> getExecutionId();
|
||||
|
||||
@Schema(
|
||||
title = "Custom fields to be added on notification"
|
||||
)
|
||||
Property<Map<String, Object>> getCustomFields();
|
||||
|
||||
@Schema(
|
||||
title = "Custom message to be added on notification"
|
||||
)
|
||||
Property<String> getCustomMessage();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package io.kestra.core.plugins.notifications;
|
||||
|
||||
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
|
||||
import io.kestra.core.models.executions.Execution;
|
||||
import io.kestra.core.models.executions.TaskRun;
|
||||
import io.kestra.core.models.flows.State;
|
||||
import io.kestra.core.models.property.Property;
|
||||
import io.kestra.core.models.tasks.retrys.Exponential;
|
||||
import io.kestra.core.repositories.ExecutionRepositoryInterface;
|
||||
import io.kestra.core.runners.DefaultRunContext;
|
||||
import io.kestra.core.runners.RunContext;
|
||||
import io.kestra.core.serializers.JacksonMapper;
|
||||
import io.kestra.core.utils.ListUtils;
|
||||
import io.kestra.core.utils.RetryUtils;
|
||||
import io.kestra.core.utils.UriProvider;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
public final class ExecutionService {
|
||||
private ExecutionService() {}
|
||||
|
||||
public static Execution findExecution(RunContext runContext, Property<String> executionId) throws IllegalVariableEvaluationException, NoSuchElementException {
|
||||
ExecutionRepositoryInterface executionRepository = ((DefaultRunContext) runContext).getApplicationContext().getBean(ExecutionRepositoryInterface.class);
|
||||
|
||||
RetryUtils.Instance<Execution, NoSuchElementException> retryInstance = RetryUtils
|
||||
.of(Exponential.builder()
|
||||
.delayFactor(2.0)
|
||||
.interval(Duration.ofSeconds(1))
|
||||
.maxInterval(Duration.ofSeconds(15))
|
||||
.maxAttempts(-1)
|
||||
.maxDuration(Duration.ofMinutes(10))
|
||||
.build(),
|
||||
runContext.logger()
|
||||
);
|
||||
|
||||
var executionRendererId = runContext.render(executionId).as(String.class).orElse(null);
|
||||
var flowTriggerExecutionState = getOptionalFlowTriggerExecutionState(runContext);
|
||||
|
||||
var flowVars = (Map<String, String>) runContext.getVariables().get("flow");
|
||||
var isCurrentExecution = isCurrentExecution(runContext, executionRendererId);
|
||||
if (isCurrentExecution) {
|
||||
runContext.logger().info("Loading execution data for the current execution.");
|
||||
}
|
||||
|
||||
return retryInstance.run(
|
||||
NoSuchElementException.class,
|
||||
() -> executionRepository.findById(flowVars.get("tenantId"), executionRendererId)
|
||||
.filter(foundExecution -> isExecutionInTheWantedState(foundExecution, isCurrentExecution, flowTriggerExecutionState))
|
||||
.orElseThrow(() -> new NoSuchElementException("Unable to find execution '" + executionRendererId + "'"))
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecutionRepository can be out of sync in ElasticSearch stack, with this filter we try to mitigate that
|
||||
*
|
||||
* @param execution the Execution we fetched from ExecutionRepository
|
||||
* @param isCurrentExecution true if this *Execution Task is configured to send a notification for the current Execution
|
||||
* @param flowTriggerExecutionState the Execution State that triggered the Flow trigger, if any
|
||||
* @return true if we think we fetched the right Execution data for our usecase
|
||||
*/
|
||||
public static boolean isExecutionInTheWantedState(Execution execution, boolean isCurrentExecution, Optional<String> flowTriggerExecutionState) {
|
||||
if (isCurrentExecution) {
|
||||
// we don't wait for current execution to be terminated as it could not be possible as long as this task is running
|
||||
return true;
|
||||
}
|
||||
|
||||
if (flowTriggerExecutionState.isPresent()) {
|
||||
// we were triggered by a Flow trigger that can be, for example: PAUSED
|
||||
if (flowTriggerExecutionState.get().equals(State.Type.RUNNING.toString())) {
|
||||
// RUNNING special case: we take the first state we got
|
||||
return true;
|
||||
} else {
|
||||
// to handle the case where the ExecutionRepository is out of sync in ElasticSearch stack,
|
||||
// we try to match an Execution with the same state
|
||||
return execution.getState().getCurrent().name().equals(flowTriggerExecutionState.get());
|
||||
}
|
||||
} else {
|
||||
return execution.getState().getCurrent().isTerminated();
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> executionMap(RunContext runContext, ExecutionInterface executionInterface) throws IllegalVariableEvaluationException {
|
||||
Execution execution = findExecution(runContext, executionInterface.getExecutionId());
|
||||
UriProvider uriProvider = ((DefaultRunContext) runContext).getApplicationContext().getBean(UriProvider.class);
|
||||
|
||||
Map<String, Object> templateRenderMap = new HashMap<>();
|
||||
templateRenderMap.put("duration", execution.getState().humanDuration());
|
||||
templateRenderMap.put("startDate", execution.getState().getStartDate());
|
||||
templateRenderMap.put("link", uriProvider.executionUrl(execution));
|
||||
templateRenderMap.put("execution", JacksonMapper.toMap(execution));
|
||||
|
||||
runContext.render(executionInterface.getCustomMessage())
|
||||
.as(String.class)
|
||||
.ifPresent(s -> templateRenderMap.put("customMessage", s));
|
||||
|
||||
final Map<String, Object> renderedCustomFields = runContext.render(executionInterface.getCustomFields()).asMap(String.class, Object.class);
|
||||
if (!renderedCustomFields.isEmpty()) {
|
||||
templateRenderMap.put("customFields", renderedCustomFields);
|
||||
}
|
||||
|
||||
var isCurrentExecution = isCurrentExecution(runContext, execution.getId());
|
||||
|
||||
List<TaskRun> taskRuns;
|
||||
|
||||
if (isCurrentExecution) {
|
||||
taskRuns = execution.getTaskRunList();
|
||||
} else {
|
||||
taskRuns = execution.getTaskRunList().stream()
|
||||
.filter(t -> (execution.hasFailed() ? State.Type.FAILED : State.Type.SUCCESS).equals(t.getState().getCurrent()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (!ListUtils.isEmpty(taskRuns)) {
|
||||
TaskRun lastTaskRun = taskRuns.getLast();
|
||||
templateRenderMap.put("firstFailed", State.Type.FAILED.equals(lastTaskRun.getState().getCurrent()) ? lastTaskRun : false);
|
||||
templateRenderMap.put("lastTask", lastTaskRun);
|
||||
}
|
||||
|
||||
return templateRenderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* if there is a state, we assume this is a Flow trigger with type: {@link io.kestra.plugin.core.trigger.Flow.Output}
|
||||
*
|
||||
* @return the state of the execution that triggered the Flow trigger, or empty if another usecase/trigger
|
||||
*/
|
||||
private static Optional<String> getOptionalFlowTriggerExecutionState(RunContext runContext) {
|
||||
var triggerVar = Optional.ofNullable(
|
||||
runContext.getVariables().get("trigger")
|
||||
);
|
||||
return triggerVar.map(trigger -> ((Map<String, String>) trigger).get("state"));
|
||||
}
|
||||
|
||||
private static boolean isCurrentExecution(RunContext runContext, String executionId) {
|
||||
var executionVars = (Map<String, String>) runContext.getVariables().get("execution");
|
||||
return executionId.equals(executionVars.get("id"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import io.kestra.core.models.dashboards.charts.DataChart;
|
||||
import io.kestra.core.plugins.DefaultPluginRegistry;
|
||||
import io.kestra.core.plugins.PluginRegistry;
|
||||
import io.kestra.core.serializers.JacksonMapper;
|
||||
import io.micronaut.context.exceptions.NoSuchBeanException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -72,7 +73,7 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
|
||||
// By default, if no plugin-registry is configured retrieve
|
||||
// the one configured from the static Kestra's context.
|
||||
pluginRegistry = KestraContext.getContext().getPluginRegistry();
|
||||
} catch (IllegalStateException ignore) {
|
||||
} catch (IllegalStateException | NoSuchBeanException ignore) {
|
||||
// This error can only happen if the KestraContext is not initialized (i.e. in unit tests).
|
||||
log.error("No plugin registry was initialized. Use default implementation.");
|
||||
pluginRegistry = DefaultPluginRegistry.getOrCreate();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN "disabled" BOOL
|
||||
GENERATED ALWAYS AS (JQ_BOOLEAN("value", '.disabled')) NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN `disabled` BOOL
|
||||
GENERATED ALWAYS AS (value ->> '$.disabled' = 'true') STORED NOT NULL
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN "disabled" BOOL
|
||||
GENERATED ALWAYS AS (CAST(value ->> 'disabled' AS BOOL)) STORED NOT NULL;
|
||||
|
||||
@@ -324,6 +324,10 @@ public abstract class AbstractJdbcRepository {
|
||||
}
|
||||
}
|
||||
|
||||
if(field == QueryFilter.Field.TRIGGER_STATE){
|
||||
return applyTriggerStateCondition(value, operation);
|
||||
}
|
||||
|
||||
// Convert the field name to lowercase and quote it
|
||||
Name columnName = getColumnName(field);
|
||||
|
||||
@@ -341,7 +345,7 @@ public abstract class AbstractJdbcRepository {
|
||||
case CONTAINS -> DSL.field(columnName).like("%" + value + "%");
|
||||
case REGEX -> DSL.field(columnName).likeRegex((String) value);
|
||||
case PREFIX -> DSL.field(columnName).like(value + "%")
|
||||
.or(DSL.field(columnName).eq(value));
|
||||
.or(DSL.field(columnName).eq(value));
|
||||
default -> throw new InvalidQueryFiltersException("Unsupported operation: " + operation);
|
||||
};
|
||||
}
|
||||
@@ -469,6 +473,23 @@ public abstract class AbstractJdbcRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private Condition applyTriggerStateCondition(Object value, QueryFilter.Op operation) {
|
||||
String triggerState = value.toString();
|
||||
Boolean isDisabled = switch (triggerState) {
|
||||
case "disabled" -> true;
|
||||
case "enabled" -> false;
|
||||
default -> null;
|
||||
};
|
||||
if (isDisabled == null) {
|
||||
return DSL.noCondition();
|
||||
}
|
||||
return switch (operation) {
|
||||
case EQUALS -> field("disabled").eq(isDisabled);
|
||||
case NOT_EQUALS -> field("disabled").ne(isDisabled);
|
||||
default -> throw new InvalidQueryFiltersException("Unsupported operation for Trigger State: " + operation);
|
||||
};
|
||||
}
|
||||
|
||||
protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType) {
|
||||
throw new UnsupportedOperationException("formatDateField() not implemented");
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import io.micronaut.context.annotation.Factory;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
import io.micronaut.test.annotation.TransactionMode;
|
||||
import io.micronaut.test.condition.TestActiveCondition;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Tag("integration")
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE, ElementType.TYPE})
|
||||
@ExtendWith(KestraTestExtension.class)
|
||||
|
||||
3
ui/.gitignore
vendored
3
ui/.gitignore
vendored
@@ -3,4 +3,5 @@ test-results
|
||||
tests/.env
|
||||
tests/data/
|
||||
tests/e2e/.env
|
||||
tests/e2e/data/application-secrets.yml
|
||||
tests/e2e/data/application-secrets.yml
|
||||
generated/
|
||||
@@ -23,6 +23,13 @@ export default defineConfig([
|
||||
],
|
||||
languageOptions: {globals: globals.node},
|
||||
},
|
||||
{
|
||||
files: ["src/generated/**/*.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
},
|
||||
},
|
||||
...pluginVue.configs["flat/strongly-recommended"],
|
||||
{
|
||||
files: ["**/*.vue", "**/*.tsx", "**/*.jsx"],
|
||||
|
||||
20
ui/heyapi-sdk-plugin/config.ts
Normal file
20
ui/heyapi-sdk-plugin/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {definePluginConfig} from "@hey-api/openapi-ts";
|
||||
import {handler} from "./plugin";
|
||||
import type {KestraSdkPlugin} from "./types";
|
||||
|
||||
const defaultConfig: KestraSdkPlugin["Config"] = {
|
||||
config: {
|
||||
output: "kestra-sdk",
|
||||
methodNameBuilder(operation) {
|
||||
return operation.operationId
|
||||
}
|
||||
},
|
||||
dependencies: ["@hey-api/typescript", "@hey-api/client-axios", "@hey-api/sdk"],
|
||||
handler,
|
||||
name: "ks-sdk",
|
||||
};
|
||||
|
||||
/**
|
||||
* Type helper for `@kestra-io/sdk-plugin` plugin, returns {@link Plugin.Config} object
|
||||
*/
|
||||
export const defineKestraHeyConfig = definePluginConfig(defaultConfig);
|
||||
2
ui/heyapi-sdk-plugin/index.ts
Normal file
2
ui/heyapi-sdk-plugin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {defineKestraHeyConfig} from "./config";
|
||||
export type {KestraSdkPlugin} from "./types";
|
||||
169
ui/heyapi-sdk-plugin/plugin.ts
Normal file
169
ui/heyapi-sdk-plugin/plugin.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {$} from "@hey-api/openapi-ts";
|
||||
import type {KestraSdkPlugin} from "./types";
|
||||
|
||||
|
||||
export const handler: KestraSdkPlugin["Handler"] = ({plugin}) => {
|
||||
const useRouteSymbol = plugin.symbol(
|
||||
"useRoute",
|
||||
{
|
||||
external: "vue-router"
|
||||
});
|
||||
|
||||
const addTenantToParametersSymbol = plugin.symbol("addTenantToParameters",{
|
||||
getFilePath: () => "sdk/ks-shared",
|
||||
});
|
||||
|
||||
const functionNode = $.func().generic("TParams")
|
||||
.params(
|
||||
$.param("parameters").type($.type("TParams"))
|
||||
).returns($.type.and($.type("TParams"), $.type.object().prop("tenant", (p) => p.type("string"))))
|
||||
.do(
|
||||
// const tenant = useRouter().params.tenant
|
||||
$.const("tenant").assign(
|
||||
$(useRouteSymbol).call().attr("params").attr("tenant").optional().as($.type("string"))
|
||||
),
|
||||
$.return($.object()
|
||||
.spread($.id("parameters"))
|
||||
.prop("tenant", "tenant")
|
||||
)
|
||||
)
|
||||
|
||||
const exportedFunctionNode = $.const(addTenantToParametersSymbol).export().assign(functionNode);
|
||||
plugin.node(exportedFunctionNode);
|
||||
|
||||
const operationsDict: Record<string, {symbol:ReturnType<typeof plugin.symbol>, methodName: string}[]> = {}
|
||||
|
||||
plugin.forEach(
|
||||
"operation",
|
||||
({operation}) => {
|
||||
// on each operation, create a method that executes the operation from the sdk
|
||||
const methodName = plugin.config.methodNameBuilder?.(operation);
|
||||
if (!methodName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParams = operation.parameters?.path || {};
|
||||
|
||||
const sym = plugin.querySymbol({
|
||||
category: "sdk",
|
||||
resource: "operation",
|
||||
resourceId: operation.id,
|
||||
})
|
||||
|
||||
if(!sym) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalOperationSymbol = $(sym);
|
||||
|
||||
const funcSymbol = plugin.symbol(methodName, {
|
||||
getFilePath: () => `sdk/ks-${operation.tags?.[0] ?? "default"}`,
|
||||
})
|
||||
|
||||
if (!operationsDict[operation.tags?.[0] ?? "default"]) {
|
||||
operationsDict[operation.tags?.[0] ?? "default"] = [];
|
||||
}
|
||||
operationsDict[operation.tags?.[0] ?? "default"].push({symbol:funcSymbol, methodName});
|
||||
|
||||
if(!pathParams || !("tenant" in pathParams)) {
|
||||
// if there is no path parameter named "tenant",
|
||||
// we export this method as is
|
||||
plugin.node(
|
||||
$.const(funcSymbol)
|
||||
.assign(originalOperationSymbol)
|
||||
.export()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsId = "options"
|
||||
|
||||
// find a cleaner way to do that (expose parameters symbol from operation ?)
|
||||
const parametersWithoutTenant = sym.node?.value._params[0]._type._exprInput["~ref"].props.filter((p: any) => p.name !== "tenant") as any
|
||||
|
||||
if(parametersWithoutTenant.length === 0) {
|
||||
|
||||
// if the only path parameter is "tenant", we can simplify the function
|
||||
const functionNode = $.func()
|
||||
.params($.param(optionsId)
|
||||
.required(false)
|
||||
.type(
|
||||
$.type("Parameters")
|
||||
.generic($.type.query(originalOperationSymbol))
|
||||
.idx(1)
|
||||
)
|
||||
)
|
||||
.do(
|
||||
$.return(originalOperationSymbol.call(
|
||||
$(addTenantToParametersSymbol).call($.object()),
|
||||
optionsId,
|
||||
))
|
||||
)
|
||||
|
||||
const exportedFunctionNode = $.const(funcSymbol).export().assign(functionNode);
|
||||
|
||||
plugin.node(exportedFunctionNode);
|
||||
return;
|
||||
}
|
||||
|
||||
const isTenantOnlyRequiredParam = Object.values(pathParams).filter(p => p.name !== "tenant" && p.required).length === 0;
|
||||
|
||||
const parameterObj = $.type.object()
|
||||
|
||||
for (const param in parametersWithoutTenant) {
|
||||
const paramDef = parametersWithoutTenant[param];
|
||||
parameterObj.prop(paramDef.name, (p) => p.required(!paramDef._optional).type(paramDef._type["~ref"]));
|
||||
}
|
||||
|
||||
const paramId = "parameters"
|
||||
const functionNode = $.func()
|
||||
.params(
|
||||
$.param(paramId)
|
||||
.required(!isTenantOnlyRequiredParam)
|
||||
.type(parameterObj)
|
||||
,
|
||||
$.param(optionsId)
|
||||
.required(false)
|
||||
.type(
|
||||
$.type("Parameters")
|
||||
.generic($.type.query(originalOperationSymbol))
|
||||
.idx(1)
|
||||
)
|
||||
)
|
||||
.do(
|
||||
isTenantOnlyRequiredParam ?
|
||||
$.return(originalOperationSymbol.call(
|
||||
$(addTenantToParametersSymbol).call($(paramId)),
|
||||
optionsId,
|
||||
))
|
||||
: $.return(originalOperationSymbol.call(
|
||||
$(addTenantToParametersSymbol).call(paramId),
|
||||
optionsId,
|
||||
))
|
||||
)
|
||||
|
||||
const exportedFunctionNode = $.const(funcSymbol).export().assign(functionNode);
|
||||
|
||||
plugin.node(exportedFunctionNode);
|
||||
},
|
||||
{
|
||||
order: "declarations",
|
||||
},
|
||||
);
|
||||
|
||||
for (const tag in operationsDict) {
|
||||
const operations = operationsDict[tag];
|
||||
const symbol = plugin.symbol(tag, {
|
||||
getFilePath: () => "ks-sdk",
|
||||
});
|
||||
|
||||
plugin.node(
|
||||
$.const(symbol)
|
||||
.export()
|
||||
.assign($.object().props(...operations.map(op => $.prop({
|
||||
kind: "prop",
|
||||
name: op.methodName
|
||||
}).value(op.symbol))))
|
||||
);
|
||||
}
|
||||
};
|
||||
21
ui/heyapi-sdk-plugin/types.d.ts
vendored
Normal file
21
ui/heyapi-sdk-plugin/types.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {DefinePlugin} from "@hey-api/openapi-ts";
|
||||
|
||||
export type UserConfig = {
|
||||
/**
|
||||
* Plugin name. Must be unique.
|
||||
*/
|
||||
name: "ks-sdk";
|
||||
/**
|
||||
* Name of the generated file.
|
||||
*
|
||||
* @default 'ks-sdk'
|
||||
*/
|
||||
output?: string;
|
||||
/**
|
||||
* Function to build method names from operations.
|
||||
* Receives the operation object and must return a string or undefined to skip the operation.
|
||||
*/
|
||||
methodNameBuilder?: (operation: any) => string;
|
||||
};
|
||||
|
||||
export type KestraSdkPlugin = DefinePlugin<UserConfig>;
|
||||
35
ui/openapi-ts.config.ts
Normal file
35
ui/openapi-ts.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {defineConfig} from "@hey-api/openapi-ts";
|
||||
import {defineKestraHeyConfig} from "./heyapi-sdk-plugin";
|
||||
|
||||
const generateHash = (str: string) => {
|
||||
let hash = 0;
|
||||
for (const char of str) {
|
||||
hash = (hash << 5) - hash + char.charCodeAt(0);
|
||||
hash |= 0; // Constrain to 32bit integer
|
||||
}
|
||||
return hash.toString(16).replace("-", "0");
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
input: "../webserver/build/classes/java/main/META-INF/swagger/kestra.yml",
|
||||
output: {
|
||||
path: "./src/generated/kestra-api",
|
||||
lint: "eslint"
|
||||
},
|
||||
|
||||
plugins: [
|
||||
{
|
||||
name: "@hey-api/client-axios",
|
||||
},
|
||||
{
|
||||
name: "@hey-api/sdk",
|
||||
paramsStructure: "flat",
|
||||
methodNameBuilder(operation) {
|
||||
return `__${generateHash(operation.id)}__`
|
||||
}
|
||||
},
|
||||
defineKestraHeyConfig({
|
||||
output: "./src/generated/kestra-heyapi-sdk",
|
||||
})
|
||||
],
|
||||
});
|
||||
299
ui/package-lock.json
generated
299
ui/package-lock.json
generated
@@ -67,6 +67,7 @@
|
||||
"@codecov/vite-plugin": "^1.9.1",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@hey-api/openapi-ts": "^0.89.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@rushstack/eslint-patch": "^1.14.1",
|
||||
"@shikijs/markdown-it": "^3.20.0",
|
||||
@@ -1982,6 +1983,118 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/codegen-core": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.4.0.tgz",
|
||||
"integrity": "sha512-o8rBbEXEUhEPzrHbqImYjwIHm4Oj0r1RPS+5cp8Z66kPO7SEN7PYUgK7XpmSxoy9LPMNK1M5qmCO4cGGwT+ELQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-colors": "4.1.3",
|
||||
"color-support": "1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/json-schema-ref-parser": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.2.tgz",
|
||||
"integrity": "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts": {
|
||||
"version": "0.89.2",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.89.2.tgz",
|
||||
"integrity": "sha512-iEhWfvPbzfcS7BMqHzh2FbMG1ouZVAHwHoYhq/61Rmq/r1Q1NgCZ3xy6QuXZgbTdHe9enMjWXjINLnTj57kWwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/codegen-core": "^0.4.0",
|
||||
"@hey-api/json-schema-ref-parser": "1.2.2",
|
||||
"ansi-colors": "4.1.3",
|
||||
"c12": "3.3.2",
|
||||
"color-support": "1.1.3",
|
||||
"commander": "14.0.2",
|
||||
"open": "11.0.0",
|
||||
"semver": "7.7.3"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-ts": "bin/run.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts/node_modules/commander": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
||||
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts/node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts/node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.4.0",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-in-ssh": "^1.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"powershell-utils": "^0.1.0",
|
||||
"wsl-utils": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2831,6 +2944,13 @@
|
||||
"integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kestra-io/ui-libs": {
|
||||
"version": "0.0.268",
|
||||
"resolved": "https://registry.npmjs.org/@kestra-io/ui-libs/-/ui-libs-0.0.268.tgz",
|
||||
@@ -7149,6 +7269,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -7793,6 +7923,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.2.tgz",
|
||||
@@ -8329,6 +8475,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -9278,6 +9434,36 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
||||
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
"default-browser-id": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-require-extensions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
|
||||
@@ -12070,6 +12256,54 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-ssh": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
|
||||
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"is-inside-container": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container/node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -17102,6 +17336,19 @@
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.27.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
@@ -18713,6 +18960,19 @@
|
||||
"points-on-path": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -19216,9 +19476,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "9.1.16",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.16.tgz",
|
||||
"integrity": "sha512-339U14K6l46EFyRvaPS2ZlL7v7Pb+LlcXT8KAETrGPxq8v1sAjj2HAOB6zrlAK3M+0+ricssfAwsLCwt7Eg8TQ==",
|
||||
"version": "9.1.17",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.17.tgz",
|
||||
"integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -21753,6 +22013,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz",
|
||||
"integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0",
|
||||
"powershell-utils": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils/node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
|
||||
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"prepare": "cd .. && husky ui/.husky && rimraf .git/hooks",
|
||||
"postinstall": "patch-package"
|
||||
"generate:openapi": "openapi-ts",
|
||||
"postinstall": "patch-package && openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-joda/core": "^5.6.5",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@codecov/vite-plugin": "^1.9.1",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@hey-api/openapi-ts": "^0.89.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@rushstack/eslint-patch": "^1.14.1",
|
||||
"@shikijs/markdown-it": "^3.20.0",
|
||||
|
||||
@@ -54,11 +54,12 @@
|
||||
import {useI18n} from "vue-i18n";
|
||||
import TopNavBar from "../layout/TopNavBar.vue";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
import {useAxios} from "../../utils/axios";
|
||||
import {useAxios, useSDK} from "../../utils/axios";
|
||||
import IconEdit from "vue-material-design-icons/Pencil.vue";
|
||||
import {apiUrl, apiUrlWithoutTenants} from "override/utils/route";
|
||||
import {apiUrlWithoutTenants} from "override/utils/route";
|
||||
import DataTable from "../layout/DataTable.vue";
|
||||
import NoData from "../layout/NoData.vue";
|
||||
import {PagedResultsConcurrencyLimit} from "../../generated/kestra-api";
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
@@ -78,15 +79,13 @@
|
||||
const KEYS: (keyof ConcurrencyLimit)[] = ["tenantId", "namespace", "flowId", "running"];
|
||||
|
||||
const axios = useAxios();
|
||||
const data = ref<{
|
||||
total: number;
|
||||
results: ConcurrencyLimit[]
|
||||
}>();
|
||||
const sdk = useSDK();
|
||||
const data = ref<PagedResultsConcurrencyLimit>();
|
||||
|
||||
async function loadData(){
|
||||
const response = await axios.get(`${apiUrl()}/concurrency-limit/search`);
|
||||
if(response?.status !== 200){
|
||||
throw new Error(`Failed to load concurrency limits: ${response?.statusText}`);
|
||||
const response = await sdk.Executions.searchConcurrencyLimits();
|
||||
if(response?.status !== 200 && response?.error){
|
||||
throw new Error(`Failed to load concurrency limits: ${response.error ?? "unknown error"}`);
|
||||
}
|
||||
data.value = response.data;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ export function useValues(label: string | undefined, t?: ReturnType<typeof useI1
|
||||
STATUSES: buildFromArray(["PENDING", "ACCEPTED", "EXPIRED"]),
|
||||
AGGREGATIONS: buildFromArray(["SUM", "AVG", "MIN", "MAX"]),
|
||||
RELATIVE_DATE,
|
||||
TRIGGER_STATES:[
|
||||
{label: t("filter.triggerState.enabled"), value: "enabled"},
|
||||
{label: t("filter.triggerState.disabled"), value: "disabled"}
|
||||
]
|
||||
};
|
||||
|
||||
return {VALUES, getRelativeDateLabel};
|
||||
|
||||
@@ -41,9 +41,9 @@ export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => {
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
@@ -116,8 +116,22 @@ export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => {
|
||||
],
|
||||
valueType: "text",
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
key: "triggerState",
|
||||
label: t("filter.triggerState.label"),
|
||||
description: t("filter.triggerState.description"),
|
||||
comparators: [
|
||||
Comparators.EQUALS,
|
||||
Comparators.NOT_EQUALS
|
||||
],
|
||||
valueType: "select",
|
||||
valueProvider: async () => {
|
||||
const {VALUES} = useValues("triggers");
|
||||
return VALUES.TRIGGER_STATES;
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
id="side-menu"
|
||||
:menu
|
||||
@update:collapsed="onToggleCollapse"
|
||||
width="268px"
|
||||
width="280px"
|
||||
:collapsed="collapsed"
|
||||
linkComponentName="LeftMenuLink"
|
||||
hideToggle
|
||||
|
||||
@@ -1,45 +1,70 @@
|
||||
import {computed} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
||||
import {useRoute, useRouter, type RouteRecordNameGeneric} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
|
||||
import {getDashboard} from "../../components/dashboard/composables/useDashboards";
|
||||
|
||||
// Main icons
|
||||
import ChartLineVariant from "vue-material-design-icons/ChartLineVariant.vue";
|
||||
import FileTreeOutline from "vue-material-design-icons/FileTreeOutline.vue";
|
||||
import LayersTripleOutline from "vue-material-design-icons/LayersTripleOutline.vue";
|
||||
import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
|
||||
import TimelineClockOutline from "vue-material-design-icons/TimelineClockOutline.vue";
|
||||
import TimelineTextOutline from "vue-material-design-icons/TimelineTextOutline.vue";
|
||||
import BallotOutline from "vue-material-design-icons/BallotOutline.vue";
|
||||
import ShieldAccountVariantOutline from "vue-material-design-icons/ShieldAccountVariantOutline.vue";
|
||||
import ViewDashboardVariantOutline from "vue-material-design-icons/ViewDashboardVariantOutline.vue";
|
||||
import Connection from "vue-material-design-icons/Connection.vue";
|
||||
import DotsSquare from "vue-material-design-icons/DotsSquare.vue";
|
||||
import FormatListGroupPlus from "vue-material-design-icons/FormatListGroupPlus.vue";
|
||||
import DatabaseOutline from "vue-material-design-icons/DatabaseOutline.vue";
|
||||
import ShieldKeyOutline from "vue-material-design-icons/ShieldKeyOutline.vue";
|
||||
import PlayOutline from "vue-material-design-icons/PlayOutline.vue";
|
||||
import FileDocumentOutline from "vue-material-design-icons/FileDocumentOutline.vue";
|
||||
import FlaskOutline from "vue-material-design-icons/FlaskOutline.vue";
|
||||
// import PackageVariantClosed from "vue-material-design-icons/PackageVariantClosed.vue";
|
||||
import FolderOpenOutline from "vue-material-design-icons/FolderOpenOutline.vue";
|
||||
import PuzzleOutline from "vue-material-design-icons/PuzzleOutline.vue";
|
||||
import ShapePlusOutline from "vue-material-design-icons/ShapePlusOutline.vue";
|
||||
import OfficeBuildingOutline from "vue-material-design-icons/OfficeBuildingOutline.vue";
|
||||
import ServerNetworkOutline from "vue-material-design-icons/ServerNetworkOutline.vue";
|
||||
|
||||
// Blueprints icons
|
||||
import Wrench from "vue-material-design-icons/Wrench.vue";
|
||||
|
||||
// Tenant Administration icons
|
||||
import Monitor from "vue-material-design-icons/Monitor.vue";
|
||||
import DatabaseOutline from "vue-material-design-icons/DatabaseOutline.vue";
|
||||
import LockOutline from "vue-material-design-icons/LockOutline.vue";
|
||||
import LightningBolt from "vue-material-design-icons/LightningBolt.vue";
|
||||
import Battery40 from "vue-material-design-icons/Battery40.vue";
|
||||
import ShieldAccount from "vue-material-design-icons/ShieldAccount.vue";
|
||||
|
||||
export type MenuItem = {
|
||||
title: string;
|
||||
routes?: RouteRecordNameGeneric[];
|
||||
href?: {
|
||||
path?: string,
|
||||
name: string,
|
||||
params?: Record<string, any>,
|
||||
query?: Record<string, any>
|
||||
},
|
||||
child?: MenuItem[],
|
||||
disabled?: boolean,
|
||||
name: string;
|
||||
params?: Record<string, any>;
|
||||
query?: Record<string, any>;
|
||||
};
|
||||
icon?: {
|
||||
element?: any;
|
||||
class?: any;
|
||||
};
|
||||
child?: MenuItem[];
|
||||
attributes?: {
|
||||
locked?: boolean;
|
||||
};
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export function useLeftMenu() {
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
const $route = useRoute();
|
||||
const $router = useRouter();
|
||||
const miscStore = useMiscStore();
|
||||
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
const configs = useMiscStore().configs;
|
||||
|
||||
/**
|
||||
* Returns all route names that start with the given route
|
||||
* @param route
|
||||
* @returns
|
||||
* Returns the names of all registered routes whose name starts with the given prefix.
|
||||
*
|
||||
* @param route - The route name prefix to match against.
|
||||
* @returns An array of route names starting with the provided prefix.
|
||||
*/
|
||||
function routeStartWith(route: string) {
|
||||
return $router
|
||||
@@ -50,140 +75,145 @@ export function useLeftMenu() {
|
||||
.map((r) => r.name);
|
||||
}
|
||||
|
||||
const flatMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items.flatMap(item => item.child ? [item, ...flatMenuItems(item.child)] : [item])
|
||||
}
|
||||
|
||||
const menu = computed(() => {
|
||||
const generatedMenu = [
|
||||
const menu = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
title: t("dashboards.labels.plural"),
|
||||
href: {
|
||||
name: "home",
|
||||
params: {dashboard: getDashboard($route, "id")},
|
||||
params: {
|
||||
dashboard: getDashboard($route, "id"),
|
||||
},
|
||||
},
|
||||
title: t("dashboards.labels.plural"),
|
||||
icon: {
|
||||
element: ViewDashboardVariantOutline,
|
||||
class: "menu-icon",
|
||||
element: ChartLineVariant,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "flows/list"},
|
||||
routes: routeStartWith("flows"),
|
||||
title: t("flows"),
|
||||
routes: routeStartWith("flows"),
|
||||
href: {
|
||||
name: "flows/list",
|
||||
},
|
||||
icon: {
|
||||
element: FileTreeOutline,
|
||||
class: "menu-icon",
|
||||
},
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
href: {name: "apps/list"},
|
||||
routes: routeStartWith("apps"),
|
||||
title: t("apps"),
|
||||
routes: routeStartWith("apps"),
|
||||
href: {
|
||||
name: "apps/list",
|
||||
},
|
||||
icon: {
|
||||
element: FormatListGroupPlus,
|
||||
class: "menu-icon",
|
||||
element: LayersTripleOutline,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "templates/list"},
|
||||
routes: routeStartWith("templates"),
|
||||
title: t("templates"),
|
||||
icon: {
|
||||
element: ContentCopy,
|
||||
class: "menu-icon",
|
||||
},
|
||||
hidden: !miscStore.configs?.isTemplateEnabled,
|
||||
},
|
||||
{
|
||||
href: {name: "executions/list"},
|
||||
routes: routeStartWith("executions"),
|
||||
title: t("executions"),
|
||||
routes: routeStartWith("executions"),
|
||||
href: {
|
||||
name: "executions/list",
|
||||
},
|
||||
icon: {
|
||||
element: TimelineClockOutline,
|
||||
class: "menu-icon",
|
||||
element: PlayOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "logs/list"},
|
||||
routes: routeStartWith("logs"),
|
||||
title: t("logs"),
|
||||
routes: routeStartWith("logs"),
|
||||
href: {
|
||||
name: "logs/list",
|
||||
},
|
||||
icon: {
|
||||
element: TimelineTextOutline,
|
||||
class: "menu-icon",
|
||||
element: FileDocumentOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "tests/list"},
|
||||
routes: routeStartWith("tests"),
|
||||
title: t("demos.tests.label"),
|
||||
routes: routeStartWith("tests"),
|
||||
href: {
|
||||
name: "tests/list",
|
||||
},
|
||||
icon: {
|
||||
element: FlaskOutline,
|
||||
class: "menu-icon"
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
// TODO: To add Assets entry here in future release
|
||||
// Uncomment PackageVariantClosed on line 25 and use as the icon
|
||||
{
|
||||
href: {name: "namespaces/list"},
|
||||
routes: routeStartWith("namespaces"),
|
||||
title: t("namespaces"),
|
||||
routes: routeStartWith("namespaces"),
|
||||
href: {
|
||||
name: "namespaces/list",
|
||||
},
|
||||
icon: {
|
||||
element: DotsSquare,
|
||||
class: "menu-icon",
|
||||
element: FolderOpenOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "kv/list"},
|
||||
routes: routeStartWith("kv"),
|
||||
title: t("kv.name"),
|
||||
title: t("templates"),
|
||||
routes: routeStartWith("templates"),
|
||||
href: {
|
||||
name: "templates/list",
|
||||
},
|
||||
icon: {
|
||||
element: DatabaseOutline,
|
||||
class: "menu-icon",
|
||||
element: ContentCopy,
|
||||
},
|
||||
hidden: !configs?.isTemplateEnabled,
|
||||
},
|
||||
{
|
||||
title: t("plugins.names"),
|
||||
routes: routeStartWith("plugins"),
|
||||
href: {
|
||||
name: "plugins/list",
|
||||
},
|
||||
icon: {
|
||||
element: PuzzleOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "secrets/list"},
|
||||
routes: routeStartWith("secrets"),
|
||||
title: t("secret.names"),
|
||||
icon: {
|
||||
element: ShieldKeyOutline,
|
||||
class: "menu-icon",
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
routes: routeStartWith("blueprints"),
|
||||
title: t("blueprints.title"),
|
||||
routes: routeStartWith("blueprints"),
|
||||
icon: {
|
||||
element: BallotOutline,
|
||||
class: "menu-icon",
|
||||
element: ShapePlusOutline,
|
||||
},
|
||||
child: [
|
||||
{
|
||||
title: t("blueprints.custom"),
|
||||
routes: routeStartWith("blueprints/flow"),
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
routes: routeStartWith("blueprints/flow/custom"),
|
||||
href: {
|
||||
name: "blueprints",
|
||||
params: {kind: "flow", tab: "custom"},
|
||||
params: {
|
||||
kind: "flow",
|
||||
tab: "custom",
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
element: Wrench,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("blueprints.flows"),
|
||||
routes: routeStartWith("blueprints/flow"),
|
||||
routes: routeStartWith("blueprints/flow/community"),
|
||||
href: {
|
||||
name: "blueprints",
|
||||
params: {kind: "flow", tab: "community"},
|
||||
params: {
|
||||
kind: "flow",
|
||||
tab: "community",
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
element: FileTreeOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -191,91 +221,144 @@ export function useLeftMenu() {
|
||||
routes: routeStartWith("blueprints/dashboard"),
|
||||
href: {
|
||||
name: "blueprints",
|
||||
params: {kind: "dashboard", tab: "community"},
|
||||
params: {
|
||||
kind: "dashboard",
|
||||
tab: "community",
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
element: ChartLineVariant,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: {name: "plugins/list"},
|
||||
routes: routeStartWith("plugins"),
|
||||
title: t("plugins.names"),
|
||||
title: t("tenant_administration"),
|
||||
routes: [
|
||||
"admin/stats",
|
||||
"kv",
|
||||
"secrets",
|
||||
"admin/triggers",
|
||||
"admin/auditlogs",
|
||||
"admin/iam",
|
||||
"admin/concurrency-limits",
|
||||
]
|
||||
.map(routeStartWith)
|
||||
.find((routes) => routes.length > 0),
|
||||
icon: {
|
||||
element: Connection,
|
||||
class: "menu-icon",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("administration"),
|
||||
routes: routeStartWith("admin"),
|
||||
icon: {
|
||||
element: ShieldAccountVariantOutline,
|
||||
class: "menu-icon",
|
||||
element: OfficeBuildingOutline,
|
||||
},
|
||||
child: [
|
||||
{
|
||||
href: {name: "admin/iam"},
|
||||
routes: routeStartWith("admin/iam"),
|
||||
title: t("iam"),
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "admin/auditlogs/list"},
|
||||
routes: routeStartWith("admin/auditlogs"),
|
||||
title: t("auditlogs"),
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "admin/triggers"},
|
||||
routes: routeStartWith("admin/triggers"),
|
||||
title: t("triggers"),
|
||||
},
|
||||
{
|
||||
href: {name: "admin/instance"},
|
||||
routes: routeStartWith("admin/instance"),
|
||||
title: t("instance"),
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "admin/tenants/list"},
|
||||
routes: routeStartWith("admin/tenants"),
|
||||
title: t("tenant.names"),
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: {name: "admin/concurrency-limits"},
|
||||
routes: routeStartWith("admin/concurrency-limits"),
|
||||
title: t("concurrency limits"),
|
||||
hidden: !miscStore.configs?.isConcurrencyViewEnabled,
|
||||
},
|
||||
{
|
||||
href: {name: "admin/stats"},
|
||||
routes: routeStartWith("admin/stats"),
|
||||
title: t("system overview"),
|
||||
routes: routeStartWith("admin/stats"),
|
||||
href: {
|
||||
name: "admin/stats",
|
||||
},
|
||||
icon: {
|
||||
element: Monitor,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("kv.name"),
|
||||
routes: routeStartWith("kv"),
|
||||
href: {
|
||||
name: "kv/list",
|
||||
},
|
||||
icon: {
|
||||
element: DatabaseOutline,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("secret.names"),
|
||||
routes: routeStartWith("secrets"),
|
||||
href: {
|
||||
name: "secrets/list",
|
||||
},
|
||||
icon: {
|
||||
element: LockOutline,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("triggers"),
|
||||
routes: routeStartWith("admin/triggers"),
|
||||
href: {
|
||||
name: "admin/triggers",
|
||||
},
|
||||
icon: {
|
||||
element: LightningBolt,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("auditlogs"),
|
||||
routes: routeStartWith("admin/auditlogs"),
|
||||
href: {
|
||||
name: "admin/auditlogs/list",
|
||||
},
|
||||
icon: {
|
||||
element: FileDocumentOutline,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("concurrency limits"),
|
||||
routes: routeStartWith("admin/concurrency-limits"),
|
||||
href: {
|
||||
name: "admin/concurrency-limits",
|
||||
},
|
||||
icon: {
|
||||
element: Battery40,
|
||||
},
|
||||
hidden: !configs?.isConcurrencyViewEnabled,
|
||||
},
|
||||
{
|
||||
title: t("iam"),
|
||||
routes: routeStartWith("admin/iam"),
|
||||
href: {
|
||||
name: "admin/iam",
|
||||
},
|
||||
icon: {
|
||||
element: ShieldAccount,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("instance_administration"),
|
||||
routes: routeStartWith("admin/instance"),
|
||||
href: {
|
||||
name: "admin/instance",
|
||||
},
|
||||
icon: {
|
||||
element: ServerNetworkOutline,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
].map((item: MenuItem) => {
|
||||
if (item.icon?.element) {
|
||||
item.icon.class = "menu-icon"; // Add default class to all menu icons
|
||||
}
|
||||
];
|
||||
|
||||
flatMenuItems(generatedMenu).forEach(menuItem => {
|
||||
if (menuItem.href !== undefined && menuItem.href?.name === $route.name) {
|
||||
menuItem.href.query = {...$route.query, ...menuItem.href?.query};
|
||||
if (item.href && item.href?.name === $route.name) {
|
||||
item.href.query = {
|
||||
...$route.query,
|
||||
...item.href?.query,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return generatedMenu;
|
||||
});
|
||||
|
||||
return {
|
||||
routeStartWith,
|
||||
menu
|
||||
};
|
||||
return {menu};
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default [
|
||||
|
||||
//Admin
|
||||
{name: "admin/triggers", path: "/:tenant?/admin/triggers", component: () => import("../components/admin/Triggers.vue")},
|
||||
{name: "admin/stats", path: "/:tenant?/admin/stats", component: () => import("override/components/admin/stats/Stats.vue")},
|
||||
{name: "admin/stats", path: "/:tenant?/admin/stats/:type?", component: () => import("override/components/admin/stats/Stats.vue")},
|
||||
{name: "admin/concurrency-limits", path: "/:tenant?/admin/concurrency-limits", component: () => import("../components/admin/ConcurrencyLimits.vue")},
|
||||
|
||||
//Setup
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import axios from "axios";
|
||||
import {defineStore} from "pinia";
|
||||
import {apiUrl} from "override/utils/route";
|
||||
import {AI} from "../generated/kestra-api/ks-sdk.gen";
|
||||
|
||||
export const useAiStore = defineStore("ai", {
|
||||
actions: {
|
||||
async generateFlow({userPrompt, flowYaml, conversationId}: {userPrompt: string, flowYaml: string, conversationId: string}) {
|
||||
const response = await axios.post(`${apiUrl()}/ai/generate/flow`, {
|
||||
userPrompt,
|
||||
flowYaml,
|
||||
conversationId
|
||||
const response = await AI.generateFlow({
|
||||
flowGenerationPrompt:{
|
||||
userPrompt,
|
||||
flowYaml,
|
||||
conversationId
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {apiUrl} from "override/utils/route";
|
||||
import {ref} from "vue";
|
||||
import {useAxios} from "../utils/axios";
|
||||
import {Message} from "../components/ErrorToast.vue";
|
||||
import * as sdk from "../generated/kestra-api/ks-sdk.gen";
|
||||
|
||||
interface GuidedProperties {
|
||||
tourStarted: boolean;
|
||||
@@ -21,13 +20,13 @@ export const useCoreStore = defineStore("core", () => {
|
||||
template: undefined,
|
||||
})
|
||||
const monacoYamlConfigured = ref(false)
|
||||
const tutorialFlows = ref<any[]>([])
|
||||
|
||||
const axios = useAxios();
|
||||
const tutorialFlows = ref<any[]>([]);
|
||||
|
||||
async function readTutorialFlows() {
|
||||
const response = await axios.get(`${apiUrl()}/flows/tutorial`);
|
||||
tutorialFlows.value = response.data;
|
||||
const response = await sdk.Flows.listFlowsByNamespace({
|
||||
namespace: "tutorials",
|
||||
})
|
||||
tutorialFlows.value = response.data ?? [];
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@ import {apiUrl} from "override/utils/route";
|
||||
|
||||
import Utils from "../utils/utils";
|
||||
|
||||
import type {Dashboard, Chart, Request, Parameters} from "../components/dashboard/composables/useDashboards";
|
||||
import type {Chart, Request, Parameters} from "../components/dashboard/composables/useDashboards";
|
||||
import {useAxios} from "../utils/axios";
|
||||
import {removeRefPrefix, usePluginsStore} from "./plugins";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import _throttle from "lodash/throttle";
|
||||
import {useCoreStore} from "./core";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {Dashboards} from "../generated/kestra-api/ks-sdk.gen";
|
||||
import {Dashboard} from "../generated/kestra-api";
|
||||
|
||||
|
||||
|
||||
@@ -46,17 +48,25 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
|
||||
async function list(options: Record<string, any>) {
|
||||
const {sort, ...params} = options;
|
||||
const response = await axios.get(`${apiUrl()}/dashboards?size=100${sort ? `&sort=${sort}` : ""}`, {params});
|
||||
const response = await Dashboards.searchDashboards({
|
||||
size: 100,
|
||||
sort,
|
||||
page: 1,
|
||||
...params
|
||||
})
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function load(id: Dashboard["id"]) {
|
||||
const response = await axios.get(`${apiUrl()}/dashboards/${id}`, {validateStatus});
|
||||
let dashboardLoaded: Dashboard;
|
||||
async function load(id: string) {
|
||||
const response = await Dashboards.getDashboard({id}, {validateStatus});
|
||||
let dashboardLoaded: Dashboard & {id: string};
|
||||
|
||||
if (response.status === 200) dashboardLoaded = response.data;
|
||||
else dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
|
||||
if (response.status === 200 && response.data) {
|
||||
dashboardLoaded = {...response.data, id};
|
||||
} else {
|
||||
dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
|
||||
}
|
||||
|
||||
dashboard.value = dashboardLoaded;
|
||||
sourceCode.value = dashboardLoaded.sourceCode ?? ""
|
||||
@@ -69,12 +79,12 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update({id, source}: {id: Dashboard["id"]; source: Dashboard["sourceCode"];}) {
|
||||
async function update({id, source}: {id: string; source: Dashboard["sourceCode"];}) {
|
||||
const response = await axios.put(`${apiUrl()}/dashboards/${id}`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteDashboard(id: Dashboard["id"]) {
|
||||
async function deleteDashboard(id: string) {
|
||||
const response = await axios.delete(`${apiUrl()}/dashboards/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -84,7 +94,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function generate(id: Dashboard["id"], chartId: Chart["id"], parameters: Parameters) {
|
||||
async function generate(id: string, chartId: Chart["id"], parameters: Parameters) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/${id}/charts/${chartId}`, parameters, {validateStatus});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ import {useUnsavedChangesStore} from "./unsavedChanges";
|
||||
import {defineStore} from "pinia";
|
||||
import {FlowGraph} from "@kestra-io/ui-libs/vue-flow-utils";
|
||||
import {makeToast} from "../utils/toast";
|
||||
import {InputType} from "../utils/inputs";
|
||||
import {globalI18n} from "../translations/i18n";
|
||||
import {transformResponse} from "../components/dependencies/composables/useDependencies";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useAxios} from "../utils/axios";
|
||||
import {defaultNamespace} from "../composables/useNamespaces";
|
||||
import {Flow, FlowWithSource} from "../generated/kestra-api";
|
||||
import * as sdk from "../generated/kestra-api/ks-sdk.gen";
|
||||
import {InputType} from "../utils/inputs";
|
||||
|
||||
const textYamlHeader = {
|
||||
headers: {
|
||||
@@ -54,26 +56,9 @@ interface FlowValidations {
|
||||
deprecationPaths?: string[];
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
id: string;
|
||||
namespace: string;
|
||||
source: string;
|
||||
revision?: number;
|
||||
deleted?: boolean;
|
||||
disabled?: boolean;
|
||||
labels?: Record<string, string | boolean>;
|
||||
triggers?: Trigger[];
|
||||
inputs?: Input[];
|
||||
errors?: { message: string; code?: string, id?: string }[];
|
||||
concurrency?: {
|
||||
limit: number;
|
||||
behavior: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const useFlowStore = defineStore("flow", () => {
|
||||
const flows = ref<Flow[]>()
|
||||
const flow = ref<Flow>()
|
||||
const flow = ref<FlowWithSource>()
|
||||
const task = ref<Task>()
|
||||
const search = ref<any[]>()
|
||||
const total = ref<number>(0)
|
||||
@@ -254,7 +239,10 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
const isCreatingBackup = isCreating.value;
|
||||
if (isCreating.value && !overrideFlow) {
|
||||
await createFlow({flow: flowSource ?? ""})
|
||||
.then((response: Flow) => {
|
||||
.then((response) => {
|
||||
if(!response){
|
||||
return;
|
||||
}
|
||||
toast.saved(response.id);
|
||||
isCreating.value = false;
|
||||
});
|
||||
@@ -291,7 +279,7 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
|
||||
async function initYamlSource() {
|
||||
if (!flow.value) return;
|
||||
const {source} = flow.value;
|
||||
const {source = ""} = flow.value;
|
||||
flowYaml.value = source;
|
||||
flowYamlOrigin.value = source;
|
||||
if (flowHaveTasks.value) {
|
||||
@@ -299,21 +287,22 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
}
|
||||
|
||||
// validate flow on first load
|
||||
return validateFlow({flow: isCreating.value ? source : yamlWithNextRevision.value})
|
||||
return validateFlow({
|
||||
flow: isCreating.value ? source : yamlWithNextRevision.value
|
||||
})
|
||||
}
|
||||
|
||||
function findFlows(options: { [key: string]: any }) {
|
||||
const sortString = options.sort ? `?sort=${options.sort}` : ""
|
||||
delete options.sort
|
||||
return axios.get(`${apiUrl()}/flows/search${sortString}`, {
|
||||
params: options
|
||||
}).then(response => {
|
||||
function findFlows(options: Parameters<typeof sdk.flowsSearchFlows>[0] & { onlyTotal?: boolean }) {
|
||||
return sdk.flowsSearchFlows(options).then(response => {
|
||||
if(!response.data){
|
||||
return undefined
|
||||
}
|
||||
if (options.onlyTotal) {
|
||||
return response.data.total;
|
||||
}
|
||||
|
||||
else {
|
||||
flows.value = response.data.results
|
||||
flows.value = response.data?.results
|
||||
total.value = response.data.total
|
||||
overallTotal.value = response.data.results.filter((f: any) => f.namespace !== "tutorial").length
|
||||
|
||||
@@ -340,20 +329,22 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
})
|
||||
}
|
||||
|
||||
function loadFlow(options: { namespace: string, id: string, revision?: string, allowDeleted?: boolean, source?: boolean, store?: boolean, deleted?: boolean, httpClient?: any }) {
|
||||
const httpClient = options.httpClient ?? axios
|
||||
return httpClient.get(`${apiUrl()}/flows/${options.namespace}/${options.id}`,
|
||||
{
|
||||
params: {
|
||||
function loadFlow(options: { namespace: string, id: string, revision?: number, allowDeleted?: boolean, source?: boolean, store?: boolean, deleted?: boolean, httpClient?: any }) {
|
||||
const httpClient = options.httpClient
|
||||
return sdk.flowsGetFlow({
|
||||
id: options.id,
|
||||
namespace: options.namespace,
|
||||
revision: options.revision,
|
||||
allowDeleted: options.allowDeleted,
|
||||
source: options.source === undefined ? true : undefined
|
||||
},
|
||||
validateStatus: (status: number) => {
|
||||
return options.deleted ? status === 200 || status === 404 : status === 200;
|
||||
allowDeleted: options.allowDeleted ?? false,
|
||||
source: options.source === undefined ? true : false
|
||||
}
|
||||
, {
|
||||
client: httpClient
|
||||
})
|
||||
.then((response: any) => {
|
||||
.then((response) => {
|
||||
if(!response.data){
|
||||
return Promise.reject("Flow not found");
|
||||
}
|
||||
if (response.data.exception) {
|
||||
coreStore.message = {
|
||||
title: "Invalid source code",
|
||||
@@ -373,8 +364,8 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
}
|
||||
|
||||
flow.value = response.data;
|
||||
flowYaml.value = response.data.source;
|
||||
flowYamlOrigin.value = response.data.source;
|
||||
flowYaml.value = response.data.source ?? "";
|
||||
flowYamlOrigin.value = response.data.source ?? "";
|
||||
overallTotal.value = 1;
|
||||
|
||||
return response.data;
|
||||
@@ -430,11 +421,11 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
}
|
||||
|
||||
function createFlow(options: { flow: string }) {
|
||||
return axios.post(`${apiUrl()}/flows`, options.flow, {
|
||||
return sdk.flowsCreateFlow({body: options.flow}, {
|
||||
...textYamlHeader,
|
||||
...VALIDATE
|
||||
}).then(response => {
|
||||
if (response.status >= 300) {
|
||||
if (!response?.status || response.status >= 300) {
|
||||
return Promise.reject(response)
|
||||
}
|
||||
|
||||
@@ -653,7 +644,12 @@ function deleteFlowAndDependencies() {
|
||||
return axios.delete(`${apiUrl()}/flows/delete/by-query`, {params: options})
|
||||
}
|
||||
|
||||
function validateFlow(options: { flow: string }) {
|
||||
function validateFlow(options: { flow?: string }) {
|
||||
if(!options.flow) {
|
||||
return Promise.resolve({
|
||||
constraints: t("flow must not be empty")
|
||||
});
|
||||
}
|
||||
const flowValidationIssues: FlowValidations = {};
|
||||
if(isCreating.value) {
|
||||
const {namespace} = YAML_UTILS.getMetadata(options.flow);
|
||||
|
||||
@@ -1,208 +1,233 @@
|
||||
@import "@kestra-io/ui-libs/src/scss/variables.scss";
|
||||
|
||||
#app {
|
||||
.vsm--item {
|
||||
padding: 0 30px;
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
.vsm--icon {
|
||||
transition: left 0.2s ease;
|
||||
font-size: 1.5em;
|
||||
background-color: transparent !important;
|
||||
padding-bottom: 15px;
|
||||
width: 30px !important;
|
||||
z-index: 20; // in collapsed menu, keep the icon above the opening menu
|
||||
.vsm--icon {
|
||||
width: 20px;
|
||||
margin-right: calc($spacer / 2);
|
||||
transition: left 0.2s ease;
|
||||
background-color: transparent !important;
|
||||
padding-bottom: 15px;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
margin-top: 13px;
|
||||
svg {
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
position: relative;
|
||||
margin-top: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--title {
|
||||
font-size: $font-size-sm;
|
||||
|
||||
&>span {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--child {
|
||||
.vsm--item {
|
||||
padding: 0;
|
||||
|
||||
.vsm--title {
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
// Make Plugins icon appear as outline
|
||||
.vsm--link[href*="plugins"] .vsm--icon svg {
|
||||
fill: none !important;
|
||||
stroke: currentColor !important;
|
||||
stroke-width: 1.5 !important;
|
||||
.vsm--icon {
|
||||
width: 1rem;
|
||||
|
||||
svg {
|
||||
height: 1rem !important;
|
||||
width: 1rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--link {
|
||||
height: 30px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
border-radius: .25rem;
|
||||
transition: padding 0.2s ease;
|
||||
color: var(--ks-content-primary);
|
||||
box-shadow: none;
|
||||
|
||||
&_active,
|
||||
body &_active:hover {
|
||||
background-color: var(--ks-button-background-primary) !important;
|
||||
color: var(--ks-button-content-primary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.vsm--item {
|
||||
padding: 0 30px;
|
||||
transition: padding 0.2s ease;
|
||||
&.vsm--link_open,
|
||||
&.vsm--link_open:hover {
|
||||
background-color: var(--ks-background-left-menu);
|
||||
color: var(--ks-content-primary);
|
||||
}
|
||||
|
||||
.vsm--child {
|
||||
.vsm--item {
|
||||
padding: 0;
|
||||
.vsm--title {
|
||||
padding-left: 10px;
|
||||
&_disabled {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
body &_hover {
|
||||
background-color: var(--ks-button-background-secondary-hover);
|
||||
}
|
||||
|
||||
.el-tooltip__trigger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&>span {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--link_open {
|
||||
position: relative !important;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.vsm_collapsed .vsm--link_open {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.vsm--child .vsm--link {
|
||||
padding: 0 0.2rem;
|
||||
position: relative !important;
|
||||
margin-left: 1.8rem;
|
||||
|
||||
&.vsm--link_level-3 {
|
||||
margin-left: 3.6rem;
|
||||
|
||||
& span {
|
||||
margin-left: calc($spacer / 4);
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--icon {
|
||||
margin-left: calc($spacer / 2);
|
||||
color: var(--ks-content-secondary);
|
||||
}
|
||||
|
||||
&.vsm--link_active .vsm--icon {
|
||||
color: var(--ks-button-content-primary);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -.8rem;
|
||||
height: 150%;
|
||||
border: 2px solid var(--ks-border-primary);
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
z-index: 2;
|
||||
// mask the right half of the object and the top border
|
||||
clip-path: polygon(50% 8px, 50% 100%, 0 100%, 0 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--title span:first-child {
|
||||
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 {
|
||||
border-left-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
a.vsm--link_active[href="#"] {
|
||||
cursor: initial !important;
|
||||
}
|
||||
|
||||
.vsm--dropdown {
|
||||
background-color: var(--ks-background-left-menu);
|
||||
border-radius: 4px;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.vsm--title {
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--scroll-thumb {
|
||||
background: var(--ks-border-primary) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vsm--mobile-bg {
|
||||
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
|
||||
}
|
||||
|
||||
.vsm_collapsed {
|
||||
.logo {
|
||||
>* {
|
||||
left: 10px;
|
||||
|
||||
span.img {
|
||||
background-size: 207px 55px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--link {
|
||||
padding: 0.3rem 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
border-radius: .25rem;
|
||||
transition: padding 0.2s ease;
|
||||
color: var(--ks-content-primary);
|
||||
box-shadow: none;
|
||||
padding-left: 13px;
|
||||
|
||||
&_active, body &_active:hover {
|
||||
background-color: var(--ks-button-background-primary) !important;
|
||||
color: var(--ks-button-content-primary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&.vsm--link_open, &.vsm--link_open:hover {
|
||||
background-color: var(--ks-background-left-menu);
|
||||
color: var(--ks-content-primary);
|
||||
}
|
||||
|
||||
&_disabled {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover, body &_hover {
|
||||
background-color: var(--ks-button-background-secondary-hover);
|
||||
}
|
||||
|
||||
.el-tooltip__trigger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > span{
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--link_open{
|
||||
position: relative !important;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.vsm_collapsed .vsm--link_open{
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.vsm--child .vsm--link{
|
||||
padding: 0 0.2rem;
|
||||
position: relative!important;
|
||||
font-size: 14px;
|
||||
margin-left: 1.8rem;
|
||||
.vsm--icon {
|
||||
margin-right:4px;
|
||||
color: var(--ks-content-secondary);
|
||||
}
|
||||
&.vsm--link_active .vsm--icon{
|
||||
&.vsm--link_hover {
|
||||
background-color: var(--ks-button-background-primary);
|
||||
color: var(--ks-button-content-primary);
|
||||
}
|
||||
&:before{
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -.8rem;
|
||||
top: -2.5rem;
|
||||
border-radius: 8px;
|
||||
width: 1.6rem;
|
||||
height: 170%;
|
||||
border: 2px solid var(--ks-border-primary);
|
||||
border-top:0;
|
||||
border-right:0;
|
||||
z-index: 2;
|
||||
// mask the right half of the object and the top border
|
||||
clip-path: polygon(50% 8px, 50% 100%, 0 100%, 0 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--title span:first-child{
|
||||
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{
|
||||
border-left-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
a.vsm--link_active[href="#"] {
|
||||
cursor: initial !important;
|
||||
}
|
||||
|
||||
.vsm--dropdown {
|
||||
background-color: var(--ks-background-left-menu);
|
||||
border-radius: 4px;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.vsm--title {
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--scroll-thumb {
|
||||
background: var(--ks-border-primary) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vsm--mobile-bg {
|
||||
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
|
||||
}
|
||||
|
||||
.vsm_collapsed {
|
||||
.logo {
|
||||
> * {
|
||||
left: 10px;
|
||||
|
||||
span.img {
|
||||
background-size: 207px 55px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--link {
|
||||
padding-left: 13px;
|
||||
&.vsm--link_hover {
|
||||
background-color: var(--ks-button-background-primary);
|
||||
color: var(--ks-button-content-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.vsm--item {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.el-tooltip__trigger .lock-icon.material-design-icon > .material-design-icon__svg {
|
||||
bottom: 0 !important;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.vsm--item {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 10px;
|
||||
height: 1.25rem;
|
||||
z-index: 5;
|
||||
background: linear-gradient(to top, var(--ks-background-left-menu), transparent);
|
||||
opacity: 0.18;
|
||||
}
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.el-tooltip__trigger .lock-icon.material-design-icon>.material-design-icon__svg {
|
||||
bottom: 0 !important;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.vsm--item {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 10px;
|
||||
height: 1.25rem;
|
||||
z-index: 5;
|
||||
background: linear-gradient(to top, var(--ks-background-left-menu), transparent);
|
||||
opacity: 0.18;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "oder benutzerdefinierte Dauer eingeben:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "Instanz",
|
||||
"instance_administration": "Instanzverwaltung",
|
||||
"invalid bulk delete": "Ausführungen konnten nicht gelöscht werden",
|
||||
"invalid bulk force run": "Konnte Ausführungen nicht erzwingen",
|
||||
"invalid bulk kill": "Ausführungen konnten nicht beendet werden",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Mandanten"
|
||||
},
|
||||
"tenantId": "Mandanten-ID",
|
||||
"tenant_administration": "Mandantenverwaltung",
|
||||
"test-badge-text": "Test",
|
||||
"test-badge-tooltip": "Diese Ausführung wurde durch einen Test erstellt",
|
||||
"theme": "Modus",
|
||||
|
||||
@@ -391,6 +391,8 @@
|
||||
"conditions": "Conditions",
|
||||
"triggerId": "Trigger ID",
|
||||
"tenantId": "Tenant ID",
|
||||
"tenant_administration": "Tenant Administration",
|
||||
"instance_administration": "Instance Administration",
|
||||
"codeDisabled": "Disabled in Flow",
|
||||
"paused": "Paused",
|
||||
"Fold auto": "Editor: automatic fold of multi-lines",
|
||||
@@ -1727,6 +1729,12 @@
|
||||
"label": "Trigger Execution ID",
|
||||
"description": "Filter by trigger execution ID"
|
||||
},
|
||||
"triggerState":{
|
||||
"label": " Trigger State",
|
||||
"description": "Filter by trigger state",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"scope_flow": {
|
||||
"label": "Scope",
|
||||
"description": "Filter by flow scope"
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "o ingrese duración personalizada:",
|
||||
"inputs": "Entradas",
|
||||
"instance": "Instancia",
|
||||
"instance_administration": "Administración de Instancia",
|
||||
"invalid bulk delete": "No se pudieron eliminar las ejecuciones",
|
||||
"invalid bulk force run": "No se pudo forzar la ejecución de ejecuciones",
|
||||
"invalid bulk kill": "No se pudieron matar las ejecuciones",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Arrendatarios"
|
||||
},
|
||||
"tenantId": "ID de Mandante",
|
||||
"tenant_administration": "Administración de Mandantes",
|
||||
"test-badge-text": "Prueba",
|
||||
"test-badge-tooltip": "Esta ejecución fue creada por una prueba",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "ou saisir une durée personnalisée :",
|
||||
"inputs": "Entrées",
|
||||
"instance": "Instance",
|
||||
"instance_administration": "Administration de l'Instance",
|
||||
"invalid bulk delete": "Impossible de supprimer les exécutions",
|
||||
"invalid bulk force run": "Impossible de forcer l'exécution des exécutions",
|
||||
"invalid bulk kill": "Impossible d'arrêter les exécutions",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Mandants"
|
||||
},
|
||||
"tenantId": "ID du mandant",
|
||||
"tenant_administration": "Administration des Mandants",
|
||||
"test-badge-text": "Test",
|
||||
"test-badge-tooltip": "Cette exécution a été créée par un Test",
|
||||
"theme": "Thème",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "या कस्टम अवधि दर्ज करें:",
|
||||
"inputs": "इनपुट्स",
|
||||
"instance": "इंस्टेंस",
|
||||
"instance_administration": "इंस्टेंस प्रशासन",
|
||||
"invalid bulk delete": "निष्पादन हटाने में असमर्थ",
|
||||
"invalid bulk force run": "निष्पादन को जबरन चलाने में असमर्थ",
|
||||
"invalid bulk kill": "निष्पादन kill करने में असमर्थ",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "मंडल"
|
||||
},
|
||||
"tenantId": "टेनेंट ID",
|
||||
"tenant_administration": "किरायेदार प्रशासन",
|
||||
"test-badge-text": "परीक्षण",
|
||||
"test-badge-tooltip": "यह execution एक Test द्वारा बनाया गया था",
|
||||
"theme": "थीम",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "oppure inserisci durata personalizzata:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "Istanza",
|
||||
"instance_administration": "Amministrazione dell'istanza",
|
||||
"invalid bulk delete": "Impossibile eliminare le esecuzioni",
|
||||
"invalid bulk force run": "Impossibile forzare l'esecuzione delle esecuzioni",
|
||||
"invalid bulk kill": "Impossibile kill le esecuzioni",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Mandanti"
|
||||
},
|
||||
"tenantId": "ID del Mandante",
|
||||
"tenant_administration": "Amministrazione del Mandante",
|
||||
"test-badge-text": "Test",
|
||||
"test-badge-tooltip": "Questa esecuzione è stata creata da un Test",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "またはカスタム期間を入力してください:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "インスタンス",
|
||||
"instance_administration": "インスタンス管理",
|
||||
"invalid bulk delete": "実行を削除できませんでした",
|
||||
"invalid bulk force run": "実行を強制的に開始できませんでした",
|
||||
"invalid bulk kill": "実行をkillできませんでした",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "テナント"
|
||||
},
|
||||
"tenantId": "テナントID",
|
||||
"tenant_administration": "テナント管理",
|
||||
"test-badge-text": "テスト",
|
||||
"test-badge-tooltip": "この実行はテストによって作成されました",
|
||||
"theme": "テーマ",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "또는 사용자 지정 기간 입력:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "인스턴스",
|
||||
"instance_administration": "인스턴스 관리",
|
||||
"invalid bulk delete": "실행을 삭제할 수 없습니다",
|
||||
"invalid bulk force run": "실행을 강제로 실행할 수 없습니다.",
|
||||
"invalid bulk kill": "실행을 강제 종료할 수 없습니다",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "테넌트"
|
||||
},
|
||||
"tenantId": "테넌트 ID",
|
||||
"tenant_administration": "테넌트 관리",
|
||||
"test-badge-text": "테스트",
|
||||
"test-badge-tooltip": "이 실행은 테스트에 의해 생성되었습니다.",
|
||||
"theme": "테마",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "lub wprowadź niestandardowy czas trwania:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "Instancja",
|
||||
"instance_administration": "Administracja Instancji",
|
||||
"invalid bulk delete": "Nie można usunąć wykonań",
|
||||
"invalid bulk force run": "Nie można wymusić uruchomienia wykonania",
|
||||
"invalid bulk kill": "Nie można zabić wykonań",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Najemcy"
|
||||
},
|
||||
"tenantId": "Identyfikator Mandanta",
|
||||
"tenant_administration": "Administracja Mandanta",
|
||||
"test-badge-text": "Test",
|
||||
"test-badge-tooltip": "To wykonanie zostało utworzone przez Test.",
|
||||
"theme": "Motyw",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "ou insira uma duração personalizada:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "Instância",
|
||||
"instance_administration": "Administração da Instância",
|
||||
"invalid bulk delete": "Não foi possível deletar execuções",
|
||||
"invalid bulk force run": "Não foi possível forçar a execução das execuções",
|
||||
"invalid bulk kill": "Não foi possível matar execuções",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Mandantes"
|
||||
},
|
||||
"tenantId": "ID do Mandante",
|
||||
"tenant_administration": "Administração do Mandante",
|
||||
"test-badge-text": "Teste",
|
||||
"test-badge-tooltip": "Esta execução foi criada por um Teste",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "ou insira uma duração personalizada:",
|
||||
"inputs": "Inputs",
|
||||
"instance": "Instância",
|
||||
"instance_administration": "Administração da Instância",
|
||||
"invalid bulk delete": "Não foi possível excluir execuções",
|
||||
"invalid bulk force run": "Não foi possível forçar a execução das execuções",
|
||||
"invalid bulk kill": "Não foi possível matar execuções",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Clientes"
|
||||
},
|
||||
"tenantId": "ID do Cliente",
|
||||
"tenant_administration": "Administração de Tenant",
|
||||
"test-badge-text": "Teste",
|
||||
"test-badge-tooltip": "Esta execução foi criada por um Teste",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "или введите пользовательскую продолжительность:",
|
||||
"inputs": "Входные данные",
|
||||
"instance": "Экземпляр",
|
||||
"instance_administration": "Администрирование экземпляра",
|
||||
"invalid bulk delete": "Не удалось удалить выполнения",
|
||||
"invalid bulk force run": "Не удалось принудительно запустить executions",
|
||||
"invalid bulk kill": "Не удалось убить выполнения",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "Арендаторы"
|
||||
},
|
||||
"tenantId": "ID арендатора",
|
||||
"tenant_administration": "Администрирование Манданта",
|
||||
"test-badge-text": "Тест",
|
||||
"test-badge-tooltip": "Это выполнение было создано тестом",
|
||||
"theme": "Тема",
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
"input_custom_duration": "或输入自定义持续时间:",
|
||||
"inputs": "输入",
|
||||
"instance": "实例",
|
||||
"instance_administration": "实例管理",
|
||||
"invalid bulk delete": "无法删除执行",
|
||||
"invalid bulk force run": "无法强制运行执行",
|
||||
"invalid bulk kill": "无法终止执行",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"names": "租户"
|
||||
},
|
||||
"tenantId": "租户 ID",
|
||||
"tenant_administration": "租户管理",
|
||||
"test-badge-text": "测试",
|
||||
"test-badge-tooltip": "此执行由测试创建",
|
||||
"theme": "主题",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError, AxiosProgressEvent} from "axios"
|
||||
import axios, {AxiosRequestConfig, AxiosResponse, AxiosError, AxiosProgressEvent} from "axios"
|
||||
import NProgress from "nprogress"
|
||||
import {Router, useRouter} from "vue-router"
|
||||
import {storageKeys} from "./constants"
|
||||
@@ -8,6 +8,9 @@ import * as BasicAuth from "../utils/basicAuth"
|
||||
import {useAuthStore} from "override/stores/auth"
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
import {useUnsavedChangesStore} from "../stores/unsavedChanges"
|
||||
import {client} from "../generated/kestra-api/client.gen"
|
||||
import {Client} from "../generated/kestra-api/client"
|
||||
export * as sdk from "../generated/kestra-api/sdk.gen"
|
||||
|
||||
let pendingRoute = false
|
||||
let requestsTotal = 0
|
||||
@@ -72,7 +75,7 @@ interface QueueItem {
|
||||
resolve: (value: AxiosResponse | Promise<AxiosResponse>) => void
|
||||
}
|
||||
|
||||
export const createAxios = (
|
||||
const createAxios = (
|
||||
router: Router | undefined,
|
||||
oss: boolean
|
||||
) => {
|
||||
@@ -283,28 +286,38 @@ export const createAxios = (
|
||||
}
|
||||
})
|
||||
|
||||
return instance;
|
||||
client.setConfig({
|
||||
axios: instance
|
||||
})
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export default (
|
||||
callback: (instance: AxiosInstance) => void,
|
||||
callback: (clientInstance: Client["instance"]) => void,
|
||||
_store: any,
|
||||
...args: Parameters<typeof createAxios>
|
||||
) => {
|
||||
callback(createAxios(...args));
|
||||
callback(createAxios(...args).instance);
|
||||
}
|
||||
|
||||
let axiosInstance: AxiosInstance | null = null;
|
||||
let clientInstance: Client | null = null;
|
||||
|
||||
export const useAxios = () => {
|
||||
export function useClient(){
|
||||
const router = useRouter();
|
||||
|
||||
const miscStore = useMiscStore();
|
||||
const {edition} = miscStore.configs || {};
|
||||
|
||||
if (!axiosInstance) {
|
||||
axiosInstance = createAxios(router, edition === "OSS");
|
||||
if (!clientInstance) {
|
||||
clientInstance = createAxios(router, edition === "OSS");
|
||||
}
|
||||
|
||||
return axiosInstance;
|
||||
return clientInstance;
|
||||
};
|
||||
|
||||
export function useAxios(){
|
||||
const clientInstance = useClient();
|
||||
|
||||
return clientInstance.instance;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ public class ConcurrencyLimitController {
|
||||
|
||||
@ExecuteOn(TaskExecutors.IO)
|
||||
@Get(uri = "/search")
|
||||
@Operation(tags = {"Flows", "Executions"}, summary = "Search for flow concurrency limits")
|
||||
@Operation(tags = {"Flows"}, summary = "Search for flow concurrency limits")
|
||||
public PagedResults<ConcurrencyLimit> searchConcurrencyLimits() {
|
||||
var results = concurrencyLimitService.find(tenantService.resolveTenant());
|
||||
return PagedResults.of(new ArrayListTotal<>(results, results.size()));
|
||||
@@ -33,7 +33,7 @@ public class ConcurrencyLimitController {
|
||||
|
||||
@ExecuteOn(TaskExecutors.IO)
|
||||
@Put("/{namespace}/{flowId}")
|
||||
@Operation(tags = {"Flows", "Executions"}, summary = "Update a flow concurrency limit")
|
||||
@Operation(tags = {"Flows"}, summary = "Update a flow concurrency limit")
|
||||
public HttpResponse<ConcurrencyLimit> updateConcurrencyLimit(@Body ConcurrencyLimit concurrencyLimit) {
|
||||
var existing = concurrencyLimitService.findById(concurrencyLimit.getTenantId(), concurrencyLimit.getNamespace(), concurrencyLimit.getFlowId());
|
||||
if (existing.isEmpty()) {
|
||||
|
||||
Reference in New Issue
Block a user