Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions[bot]
23329f4d48 chore(version): update to version '1.1.1' 2025-11-06 17:18:11 +00:00
Loïc Mathieu
ed60cb6670 fix(core): relax assertion on ConcurrencyLimitServiceTest.findById() 2025-11-06 18:16:32 +01:00
brian-mulier-p
f6306883b4 fix(kv): all types properly handled and avoid trimming string KV values (#12765)
closes https://github.com/kestra-io/kestra-ee/issues/5718
2025-11-06 15:47:44 +01:00
Loïc Mathieu
89433dc04c fix(system): killing a paused flow should kill the Pause task attempt
Fixes #12421
2025-11-06 15:33:56 +01:00
Loïc Mathieu
4837408c59 chore(test): try to un-flaky ConcurrencyLimitServiceTest.findById().
By making sure the unqueueExecution() test wait for the unqueued execution to ends to avoid any potential races.
2025-11-06 15:33:56 +01:00
Miloš Paunović
5a8c36caa5 fix(variables): properly send kv value when the type is json (#12759)
Closes https://github.com/kestra-io/kestra/issues/12739.
2025-11-06 15:33:56 +01:00
Piyush Bhaskar
a2335abc0c fix(core): make the interval in triggers work (#12764) 2025-11-06 19:39:10 +05:30
Piyush Bhaskar
310a7bbbe9 Revert "fix(core): apply timeRange filter in triggers (#12721)" 2025-11-06 18:56:37 +05:30
Jay Balwani
162feaf38c Fix(UI)/kv type boolean (#12643)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-06 16:37:01 +05:30
Piyush Bhaskar
94050be49c fix(core): apply timeRange filter in triggers (#12721) 2025-11-06 16:29:47 +05:30
brian-mulier-p
848a5ac9d7 fix(cli): metadata commands weren't working with external storages (#12743)
closes #12713
2025-11-06 11:47:59 +01:00
Barthélémy Ledoux
9ac7a9ce9a fix: responsive dashboard grid (#12608) 2025-11-06 11:03:26 +01:00
Piyush Bhaskar
c42838f3e1 feat(ui): persist scroll across No‑code, editor tabs, and docs via Pinia view-state and scroll-memory (#12358) 2025-11-06 11:53:07 +05:30
Irfan
c499d62b63 fix(core): going back from plugin doc will take to plugins home (#12621)
Co-authored-by: iitzIrFan <irfanlhawk@gmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-06 11:50:34 +05:30
Piyush Bhaskar
8fbc62e12c fix(core): proper deletion of single and multi ns files (#12618) 2025-11-06 11:49:51 +05:30
Vipin Chandra Sao
ae143f29f4 fix(ui): prevent "Invalid date" display in Gantt view for executions … (#12605)
* fix(ui): prevent "Invalid date" display in Gantt view for executions that never started

- Added defensive checks wherever histories arrays might be empty
- Now renders blank or safe values instead of "Invalid date"
- Improved comments for maintainability and future debugging
- Addresses issue #12583

* revert the changes

* fix: remove the card when invalid date

---------

Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
Co-authored-by: Piyush Bhaskar <102078527+Piyush-r-bhaskar@users.noreply.github.com>
2025-11-06 11:49:02 +05:30
Piyush Bhaskar
e4a11fc9ce fix(core): remove double info icon (#12623) 2025-11-06 11:48:36 +05:30
Piyush Bhaskar
ebacfc70b9 fix(core): use proper option after P30D in misc (#12624) 2025-11-06 11:47:57 +05:30
Loïc Mathieu
5bf67180a3 fix(system): trigger an execution once per condition on flow triggers
Fixes #12560
2025-11-05 15:31:41 +01:00
Roman Acevedo
1e670b5e7e test(kv): plain text header is sent now 2025-11-04 15:17:02 +01:00
brian.mulier
0dacad5ee1 chore(version): upgrade to v1.1.0 2025-11-04 13:58:32 +01:00
41 changed files with 492 additions and 163 deletions

View File

@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
import io.kestra.cli.AbstractCommand;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import lombok.extern.slf4j.Slf4j;
import picocli.CommandLine;
@@ -12,13 +13,13 @@ import picocli.CommandLine;
@Slf4j
public class KvMetadataMigrationCommand extends AbstractCommand {
@Inject
private MetadataMigrationService metadataMigrationService;
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
@Override
public Integer call() throws Exception {
super.call();
try {
metadataMigrationService.kvMigration();
metadataMigrationServiceProvider.get().kvMigration();
} catch (Exception e) {
System.err.println("❌ KV Metadata migration failed: " + e.getMessage());
e.printStackTrace();

View File

@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
import io.kestra.cli.AbstractCommand;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import lombok.extern.slf4j.Slf4j;
import picocli.CommandLine;
@@ -12,13 +13,13 @@ import picocli.CommandLine;
@Slf4j
public class SecretsMetadataMigrationCommand extends AbstractCommand {
@Inject
private MetadataMigrationService metadataMigrationService;
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
@Override
public Integer call() throws Exception {
super.call();
try {
metadataMigrationService.secretMigration();
metadataMigrationServiceProvider.get().secretMigration();
} catch (Exception e) {
System.err.println("❌ Secrets Metadata migration failed: " + e.getMessage());
e.printStackTrace();

View File

@@ -383,6 +383,7 @@ public class ExecutionService {
if (!isFlowable || s.equals(taskRunId)) {
TaskRun newTaskRun;
State.Type targetState = newState;
if (task instanceof Pause pauseTask) {
State.Type terminalState = newState == State.Type.RUNNING ? State.Type.SUCCESS : newState;
Pause.Resumed _resumed = resumed != null ? resumed : Pause.Resumed.now(terminalState);
@@ -392,23 +393,23 @@ public class ExecutionService {
// if it's a Pause task with no subtask, we terminate the task
if (ListUtils.isEmpty(pauseTask.getTasks()) && ListUtils.isEmpty(pauseTask.getErrors()) && ListUtils.isEmpty(pauseTask.getFinally())) {
if (newState == State.Type.RUNNING) {
newTaskRun = newTaskRun.withState(State.Type.SUCCESS);
targetState = State.Type.SUCCESS;
} else if (newState == State.Type.KILLING) {
newTaskRun = newTaskRun.withState(State.Type.KILLED);
} else {
newTaskRun = newTaskRun.withState(newState);
targetState = State.Type.KILLED;
}
} else {
// we should set the state to RUNNING so that subtasks are executed
newTaskRun = newTaskRun.withState(State.Type.RUNNING);
targetState = State.Type.RUNNING;
}
newTaskRun = newTaskRun.withState(targetState);
} else {
newTaskRun = originalTaskRun.withState(newState);
newTaskRun = originalTaskRun.withState(targetState);
}
if (originalTaskRun.getAttempts() != null && !originalTaskRun.getAttempts().isEmpty()) {
ArrayList<TaskRunAttempt> attempts = new ArrayList<>(originalTaskRun.getAttempts());
attempts.set(attempts.size() - 1, attempts.getLast().withState(newState));
attempts.set(attempts.size() - 1, attempts.getLast().withState(targetState));
newTaskRun = newTaskRun.withAttempts(attempts);
}

View File

@@ -267,6 +267,12 @@ public abstract class AbstractRunnerTest {
multipleConditionTriggerCaseTest.flowTriggerMultiplePreconditions();
}
@Test
@LoadFlows({"flows/valids/flow-trigger-multiple-conditions-flow-a.yaml", "flows/valids/flow-trigger-multiple-conditions-flow-listen.yaml"})
void flowTriggerMultipleConditions() throws Exception {
multipleConditionTriggerCaseTest.flowTriggerMultipleConditions();
}
@Test
@LoadFlows({"flows/valids/each-null.yaml"})
void eachWithNull() throws Exception {

View File

@@ -445,6 +445,7 @@ class ExecutionServiceTest {
assertThat(killed.getState().getCurrent()).isEqualTo(State.Type.CANCELLED);
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getAttempts().getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
assertThat(killed.getState().getHistories()).hasSize(5);
}

View File

@@ -212,4 +212,24 @@ public class MultipleConditionTriggerCaseTest {
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.preconditions", "flow-trigger-multiple-preconditions-flow-listen", Duration.ofSeconds(1)));
}
public void flowTriggerMultipleConditions() throws TimeoutException, QueueException {
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions",
"flow-trigger-multiple-conditions-flow-a");
assertThat(execution.getTaskRunList().size()).isEqualTo(1);
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
// trigger is done
Execution triggerExecution = runnerUtils.awaitFlowExecution(
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen");
executionRepository.delete(triggerExecution);
assertThat(triggerExecution.getTaskRunList().size()).isEqualTo(1);
assertThat(triggerExecution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
// we assert that we didn't have any other flow triggered
assertThrows(RuntimeException.class, () -> runnerUtils.awaitFlowExecution(
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen", Duration.ofSeconds(1)));
}
}

View File

@@ -12,20 +12,24 @@ import io.kestra.core.queues.QueueInterface;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.runners.ConcurrencyLimit;
import io.kestra.core.runners.RunnerUtils;
import io.kestra.core.utils.TestsUtils;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static io.kestra.core.utils.Rethrow.throwRunnable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KestraTest(startRunner = true)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@@ -54,14 +58,29 @@ class ConcurrencyLimitServiceTest {
@Test
@LoadFlows("flows/valids/flow-concurrency-queue.yml")
void unqueueExecution() throws QueueException, TimeoutException {
void unqueueExecution() throws QueueException, TimeoutException, InterruptedException {
// run a first flow so the second is queued
runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
Execution first = runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
Execution result = runUntilQueued(TESTS_FLOW_NS, "flow-concurrency-queue");
assertThat(result.getState().isQueued()).isTrue();
// await for the execution to be terminated
CountDownLatch terminated = new CountDownLatch(2);
Flux<Execution> receive = TestsUtils.receive(executionQueue, (either) -> {
if (either.getLeft().getId().equals(first.getId()) && either.getLeft().getState().isTerminated()) {
terminated.countDown();
}
if (either.getLeft().getId().equals(result.getId()) && either.getLeft().getState().isTerminated()) {
terminated.countDown();
}
});
Execution unqueued = concurrencyLimitService.unqueue(result, State.Type.RUNNING);
assertThat(unqueued.getState().isRunning()).isTrue();
executionQueue.emit(unqueued);
assertTrue(terminated.await(10, TimeUnit.SECONDS));
receive.blockLast();
}
@Test
@@ -73,7 +92,6 @@ class ConcurrencyLimitServiceTest {
assertThat(limit.get().getTenantId()).isEqualTo(execution.getTenantId());
assertThat(limit.get().getNamespace()).isEqualTo(execution.getNamespace());
assertThat(limit.get().getFlowId()).isEqualTo(execution.getFlowId());
assertThat(limit.get().getRunning()).isEqualTo(0);
}
@Test

View File

@@ -0,0 +1,10 @@
id: flow-trigger-multiple-conditions-flow-a
namespace: io.kestra.tests.trigger.multiple.conditions
labels:
some: label
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "from parents: {{execution.id}}"

View File

@@ -0,0 +1,23 @@
id: flow-trigger-multiple-conditions-flow-listen
namespace: io.kestra.tests.trigger.multiple.conditions
triggers:
- id: on_completion
type: io.kestra.plugin.core.trigger.Flow
states: [ SUCCESS ]
conditions:
- type: io.kestra.plugin.core.condition.ExecutionFlow
namespace: io.kestra.tests.trigger.multiple.conditions
flowId: flow-trigger-multiple-conditions-flow-a
- id: on_failure
type: io.kestra.plugin.core.trigger.Flow
states: [ FAILED ]
conditions:
- type: io.kestra.plugin.core.condition.ExecutionFlow
namespace: io.kestra.tests.trigger.multiple.conditions
flowId: flow-trigger-multiple-conditions-flow-a
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "It works"

View File

@@ -1,4 +1,4 @@
version=1.1.0-SNAPSHOT
version=1.1.1
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true

View File

@@ -1230,8 +1230,10 @@ public class JdbcExecutor implements ExecutorInterface {
private void processFlowTriggers(Execution execution) throws QueueException {
// directly process simple conditions
flowTriggerService.withFlowTriggersOnly(allFlows.stream())
.filter(f ->ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f.getFlow()), Optional.empty()).stream())
.filter(f -> ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
.map(f -> f.getFlow())
.distinct() // as computeExecutionsFromFlowTriggers is based on flow, we must map FlowWithFlowTrigger to a flow and distinct to avoid multiple execution for the same flow
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f), Optional.empty()).stream())
.forEach(throwConsumer(exec -> executionQueue.emit(exec)));
// send multiple conditions to the multiple condition queue for later processing

View File

@@ -35,16 +35,18 @@
<WeatherSunny v-else />
</el-button>
</div>
<div class="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
<div class="panelWrapper" ref="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
<div :style="{overflow: 'hidden'}">
<button v-if="activeTab.length" class="closeButton" @click="setActiveTab('')">
<Close />
</button>
<ContextDocs v-if="activeTab === 'docs'" />
<ContextNews v-else-if="activeTab === 'news'" />
<template v-else>
{{ activeTab }}
</template>
<KeepAlive>
<ContextDocs v-if="activeTab === 'docs'" />
<ContextNews v-else-if="activeTab === 'news'" />
<template v-else>
{{ activeTab }}
</template>
</KeepAlive>
</div>
</div>
</template>
@@ -96,6 +98,7 @@
});
const panelWidth = ref(640)
const panelWrapper = ref<HTMLDivElement | null>(null)
const {startResizing, resizing} = useResizablePanel(activeTab)

View File

@@ -4,14 +4,22 @@
<slot name="back-button" />
<h2>{{ title }}</h2>
</div>
<div class="content">
<div class="content" ref="contentRef">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue";
defineProps<{title:string}>();
const contentRef = ref<HTMLDivElement | null>(null);
defineExpose({
contentRef
});
</script>
<style scoped lang="scss">

View File

@@ -197,7 +197,6 @@
import {trackTabOpen, trackTabClose} from "../utils/tabTracking";
import {Panel, Tab, TabLive} from "../utils/multiPanelTypes";
import {usePanelDefaultSize} from "../composables/usePanelDefaultSize";
const {t} = useI18n();
const {showKeyShortcuts} = useKeyShortcuts();
@@ -449,7 +448,7 @@
}
}
const defaultSize = usePanelDefaultSize(panels);
const defaultSize = computed(() => panels.value.length === 0 ? 1 : (panels.value.reduce((acc, panel) => acc + panel.size, 0) / panels.value.length));
function newPanelDrop(_e: DragEvent, direction: "left" | "right") {
if (!movedTabInfo.value) return;

View File

@@ -696,7 +696,7 @@
};
const loadQuery = (base: any) => {
let queryFilter = queryWithFilter();
const queryFilter = queryWithFilter("triggers");
return _merge(base, queryFilter);
};

View File

@@ -28,33 +28,10 @@
}
}
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
const result: { error: string | null; data: null | {
id?: string;
name?: string;
type?: string;
chartOptions?: Record<string, any>;
dataFilters?: any[];
charts?: any[];
}; raw: any } = {
error: null,
data: null,
raw: {}
};
const errors = await dashboardStore.validateChart(yamlChart);
if (errors.constraints) {
result.error = errors.constraints;
} else {
result.data = {...chart, content: yamlChart, raw: chart};
}
return result;
}
async function updateChartPreview(event: any) {
const chart = YAML_UTILS.getChartAtPosition(event.model.getValue(), event.position);
if (chart) {
const result = await loadChart(chart);
const result = await dashboardStore.loadChart(chart);
dashboardStore.selectedChart = typeof result.data === "object"
? {
...result.data,

View File

@@ -37,6 +37,7 @@
FIELDNAME_INJECTION_KEY,
FULL_SCHEMA_INJECTION_KEY,
FULL_SOURCE_INJECTION_KEY,
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
PARENT_PATH_INJECTION_KEY,
POSITION_INJECTION_KEY,
REF_PATH_INJECTION_KEY,
@@ -111,6 +112,15 @@
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? dashboardStore.schema.$ref ?? ""));
provide(FULL_SOURCE_INJECTION_KEY, computed(() => dashboardStore.sourceCode ?? ""));
provide(POSITION_INJECTION_KEY, props.position ?? "after");
provide(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt) => {
const type = elt?.type;
dashboardStore.loadChart(elt);
if(type){
pluginsStore.updateDocumentation({type});
}else{
pluginsStore.updateDocumentation();
}
})
const pluginsStore = usePluginsStore();

View File

@@ -1,6 +1,7 @@
<template>
<div class="w-100 p-4">
<Sections
:key="dashboardStore.sourceCode"
:dashboard="{id: 'default', charts: []}"
:charts="charts.map(chart => chart.data).filter(chart => chart !== null)"
showDefault
@@ -9,11 +10,12 @@
</template>
<script lang="ts" setup>
import {onMounted, ref} from "vue";
import {ref, watch} from "vue";
import Sections from "../sections/Sections.vue";
import {Chart} from "../composables/useDashboards";
import {useDashboardStore} from "../../../stores/dashboard";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import throttle from "lodash/throttle";
interface Result {
error: string[] | null;
@@ -23,21 +25,27 @@
const charts = ref<Result[]>([])
onMounted(async () => {
validateAndLoadAllCharts();
});
const dashboardStore = useDashboardStore();
function validateAndLoadAllCharts() {
charts.value = [];
const validateAndLoadAllChartsThrottled = throttle(validateAndLoadAllCharts, 500);
async function validateAndLoadAllCharts() {
const allCharts = YAML_UTILS.getAllCharts(dashboardStore.sourceCode) ?? [];
allCharts.forEach(async (chart: any) => {
const loadedChart = await loadChart(chart);
charts.value.push(loadedChart);
});
charts.value = await Promise.all(allCharts.map(async (chart: any) => {
return loadChart(chart);
}));
}
watch(
() => dashboardStore.sourceCode,
() => {
validateAndLoadAllChartsThrottled();
}
, {immediate: true}
);
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
const result: Result = {

View File

@@ -1,12 +1,13 @@
<template>
<section id="charts" :class="{padding}">
<el-row :gutter="16">
<el-col
<div class="dashboard-sections-container">
<section id="charts" :class="{padding}">
<div
v-for="chart in props.charts"
:key="`chart__${chart.id}`"
:xs="24"
:sm="(chart.chartOptions?.width || 6) * 4"
:md="(chart.chartOptions?.width || 6) * 2"
class="dashboard-block"
:class="{
[`dash-width-${chart.chartOptions?.width || 6}`]: true
}"
>
<div class="d-flex flex-column">
<div class="d-flex justify-content-between">
@@ -64,9 +65,9 @@
/>
</div>
</div>
</el-col>
</el-row>
</section>
</div>
</section>
</div>
</template>
<script setup lang="ts">
@@ -133,14 +134,28 @@
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
.dashboard-sections-container{
container-type: inline-size;
}
$smallMobile: 375px;
$tablet: 768px;
section#charts {
display: grid;
gap: 1rem;
grid-template-columns: repeat(3, 1fr);
@container (min-width: #{$smallMobile}) {
grid-template-columns: repeat(6, 1fr);
}
@container (min-width: #{$tablet}) {
grid-template-columns: repeat(12, 1fr);
}
&.padding {
padding: 0 2rem 1rem;
}
& .el-row .el-col {
margin-bottom: 1rem;
.dashboard-block {
& > div {
height: 100%;
padding: 1.5rem;
@@ -159,5 +174,24 @@ section#charts {
opacity: 1;
}
}
.dash-width-3, .dash-width-6, .dash-width-9, .dash-width-12 {
grid-column: span 3;
}
@container (min-width: #{$smallMobile}) {
.dash-width-6, .dash-width-9, .dash-width-12 {
grid-column: span 6;
}
}
@container (min-width: #{$tablet}) {
.dash-width-9 {
grid-column: span 9;
}
.dash-width-12 {
grid-column: span 12;
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ContextInfoContent :title="routeInfo.title">
<ContextInfoContent :title="routeInfo.title" ref="contextInfoRef">
<template v-if="isOnline" #back-button>
<button
class="back-button"
@@ -26,7 +26,7 @@
<OpenInNew class="blank" />
</router-link>
</template>
<div ref="docWrapper" class="docs-controls">
<div class="docs-controls">
<template v-if="isOnline">
<ContextDocsSearch />
<DocsMenu />
@@ -42,7 +42,7 @@
</template>
<script setup lang="ts">
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted, nextTick} from "vue";
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted} from "vue";
import {useDocStore} from "../../stores/doc";
import {useI18n} from "vue-i18n";
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
@@ -55,7 +55,9 @@
import ContextInfoContent from "../ContextInfoContent.vue";
import ContextChildTableOfContents from "./ContextChildTableOfContents.vue";
import {useNetwork} from "@vueuse/core"
import {useScrollMemory} from "../../composables/useScrollMemory"
const {isOnline} = useNetwork()
import Markdown from "../../components/layout/Markdown.vue";
@@ -64,19 +66,18 @@
const docStore = useDocStore();
const {t} = useI18n({useScope: "global"});
const docWrapper = ref<HTMLDivElement | null>(null);
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
const docHistory = ref<string[]>([]);
const currentHistoryIndex = ref(-1);
const ast = ref<any>(undefined);
const pageMetadata = computed(() => docStore.pageMetadata);
const docPath = computed(() => docStore.docPath);
const routeInfo = computed(() => ({
title: pageMetadata.value?.title ?? t("docs"),
}));
const canGoBack = computed(() => docHistory.value.length > 1 && currentHistoryIndex.value > 0);
const addToHistory = (path: string) => {
// Always store the path, even empty ones
const pathToAdd = path || "";
@@ -179,8 +180,10 @@
addToHistory(val);
refreshPage(val);
nextTick(() => docWrapper.value?.scrollTo(0, 0));
}, {immediate: true});
const scrollableElement = computed(() => contextInfoRef.value?.contentRef ?? null)
useScrollMemory(ref("context-panel-docs"), scrollableElement as any)
</script>
<style scoped lang="scss">
@@ -241,4 +244,4 @@
margin-bottom: 1rem;
}
}
</style>
</style>

View File

@@ -23,9 +23,15 @@
</template>
<script setup lang="ts">
import {ref} from "vue"
import {ref, computed} from "vue"
import {useRoute} from "vue-router";
import {useScrollMemory} from "../../composables/useScrollMemory";
const collapsed = ref(false);
const route = useRoute();
const scrollKey = computed(() => `docs:${route.fullPath}`);
useScrollMemory(scrollKey, undefined, true);
</script>
@@ -224,4 +230,4 @@
padding-bottom: 1px !important;
}
}
</style>
</style>

View File

@@ -3,8 +3,8 @@
v-if="!isExecutionStarted"
:execution="execution"
/>
<el-card id="gantt" shadow="never" v-else-if="execution && executionsStore.flow">
<template #header>
<el-card id="gantt" shadow="never" :class="{'no-border': !hasValidDate}" v-else-if="execution && executionsStore.flow">
<template #header v-if="hasValidDate">
<div class="d-flex">
<Duration class="th text-end" :histories="execution.state.histories" />
<span class="text-end" v-for="(date, i) in dates" :key="i">
@@ -234,6 +234,9 @@
isExecutionStarted() {
return this.execution?.state?.current && !["CREATED", "QUEUED"].includes(this.execution.state.current);
},
hasValidDate() {
return isFinite(this.delta());
},
},
methods: {
forwardEvent(type, event) {
@@ -443,6 +446,9 @@
}
}
.no-border {
border: none !important;
}
// To Separate through Line
:deep(.vue-recycle-scroller__item-view) {

View File

@@ -15,6 +15,7 @@
<script lang="ts" setup>
import {ref, computed, watch, PropType} from "vue";
import DateSelect from "./DateSelect.vue";
import {useI18n} from "vue-i18n";
interface TimePreset {
value?: string;
@@ -64,9 +65,11 @@
timeFilterPresets.value.map(preset => preset.value)
);
const {t} = useI18n();
const customAwarePlaceholder = computed<string | undefined>(() => {
if (props.placeholder) return props.placeholder;
return props.allowCustom ? "datepicker.custom" : undefined;
return props.allowCustom ? t("datepicker.custom") : undefined;
});
const onTimeRangeSelect = (range: string | undefined) => {
@@ -92,4 +95,4 @@
},
{immediate: true}
);
</script>
</script>

View File

@@ -43,7 +43,7 @@ export function useValues(label: string | undefined, t?: ReturnType<typeof useI1
{label: t("datepicker.last24hours"), value: "PT24H"},
{label: t("datepicker.last48hours"), value: "PT48H"},
{label: t("datepicker.last7days"), value: "PT168H"},
{label: t("datepicker.last30days"), value: "PT720H"},
{label: t("datepicker.last30days"), value: "P30D"},
{label: t("datepicker.last365days"), value: "PT8760H"},
];

View File

@@ -39,7 +39,7 @@ export const decodeSearchParams = (query: LocationQuery) =>
operation
};
})
.filter(Boolean);
.filter(v => v !== null);
type Filter = Pick<AppliedFilter, "key" | "comparator" | "value">;

View File

@@ -83,6 +83,7 @@
/* eslint-disable vue/enforce-style-attribute */
import {computed, onMounted, ref, shallowRef, watch} from "vue";
import {useI18n} from "vue-i18n";
import {useThrottleFn} from "@vueuse/core";
import UnfoldLessHorizontal from "vue-material-design-icons/UnfoldLessHorizontal.vue";
import UnfoldMoreHorizontal from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
import Help from "vue-material-design-icons/Help.vue";
@@ -94,6 +95,7 @@
import {TabFocus} from "monaco-editor/esm/vs/editor/browser/config/tabFocus";
import MonacoEditor from "./MonacoEditor.vue";
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import {useScrollMemory} from "../../composables/useScrollMemory";
const {t} = useI18n()
@@ -123,6 +125,7 @@
shouldFocus: {type: Boolean, default: true},
showScroll: {type: Boolean, default: false},
diffOverviewBar: {type: Boolean, default: true},
scrollKey: {type: String, default: undefined},
})
defineOptions({
@@ -312,6 +315,29 @@
return
}
const codeEditor = editor as monaco.editor.IStandaloneCodeEditor;
const scrollMemory = props.scrollKey ? useScrollMemory(ref(props.scrollKey)) : null;
if (props.scrollKey && scrollMemory) {
const savedState = scrollMemory.loadData<monaco.editor.ICodeEditorViewState>("viewState");
if (savedState) {
codeEditor.restoreViewState(savedState);
codeEditor.revealLineInCenterIfOutsideViewport?.(codeEditor.getPosition()?.lineNumber ?? 1);
}
const top = scrollMemory.loadData<number>("scrollTop", 0);
if (typeof top === "number") {
codeEditor.setScrollTop(top);
}
const throttledSave = useThrottleFn(() => {
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
scrollMemory.saveData(codeEditor.getScrollTop(), "scrollTop");
}, 100);
codeEditor.onDidScrollChange?.(throttledSave);
}
if (!isDiff.value) {
editor.onDidBlurEditorWidget?.(() => {
emit("focusout", isCodeEditor(editor)
@@ -468,6 +494,10 @@
position: position,
model: model,
});
// Save view state when cursor changes
if (scrollMemory) {
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
}
}, 100) as unknown as number;
highlightPebble();
});

View File

@@ -19,6 +19,7 @@
:creating="isCreating"
:path="path"
:diffOverviewBar="false"
:scrollKey="editorScrollKey"
@update:model-value="editorUpdate"
@cursor="updatePluginDocumentation"
@save="flow ? saveFlowYaml(): saveFileContent()"
@@ -224,6 +225,19 @@
const namespacesStore = useNamespacesStore();
const miscStore = useMiscStore();
const editorScrollKey = computed(() => {
if (props.flow) {
const ns = flowStore.flow?.namespace ?? "";
const id = flowStore.flow?.id ?? "";
return `flow:${ns}/${id}:code`;
}
const ns = namespace.value;
if (ns && props.path) {
return `file:${ns}:${props.path}`;
}
return undefined;
});
function loadPluginsHash() {
miscStore.loadConfigs().then(config => {
hash.value = config.pluginsHash;

View File

@@ -688,21 +688,22 @@
async function removeItems() {
if(confirmation.value.nodes === undefined) return;
for (const node of confirmation.value.nodes) {
await Promise.all(confirmation.value.nodes.map(async (node, i) => {
const path = filesStore.getPath(node.id) ?? "";
try {
await namespacesStore.deleteFileDirectory({
namespace: props.currentNS ?? route.params.namespace as string,
path: filesStore.getPath(node) ?? "",
path,
});
tree.value.remove(node.id);
closeTab?.({
path: filesStore.getPath(node) ?? "",
path,
});
} catch (error) {
console.error(`Failed to delete file: ${node.fileName}`, error);
toast.error(`Failed to delete file: ${node.fileName}`);
}
}
}));
confirmation.value = {visible: false, nodes: []};
toast.success("Selected files deleted successfully.");
}

View File

@@ -235,7 +235,7 @@
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import _groupBy from "lodash/groupBy";
import {computed, ref, useTemplateRef, watch} from "vue";
import {computed, nextTick, ref, useTemplateRef, watch} from "vue";
import Check from "vue-material-design-icons/Check.vue";
import Delete from "vue-material-design-icons/Delete.vue";
@@ -491,6 +491,8 @@
kv.value.key = entry.key;
const {type, value} = await namespacesStore.kv({namespace: entry.namespace, key: entry.key});
kv.value.type = type;
// Force the type reset before setting the value
await nextTick();
if (type === "JSON") {
kv.value.value = JSON.stringify(value);
} else if (type === "BOOLEAN") {
@@ -504,7 +506,7 @@
}
function removeKv(namespace: string, key: string) {
toast.confirm("delete confirm", async () => {
toast.confirm(t("delete confirm"), async () => {
return namespacesStore
.deleteKv({namespace, key: key})
.then(() => {
@@ -543,14 +545,16 @@
const type = kv.value.type;
let value: any = kv.value.value;
if (type === "STRING" || type === "DURATION") {
if (type === "STRING") {
value = JSON.stringify(value);
} else if (["DURATION", "JSON"].includes(type)) {
value = value || "";
} else if (type === "DATETIME") {
value = new Date(value!).toISOString();
} else if (type === "DATE") {
value = new Date(value!).toISOString().split("T")[0];
} else if (["NUMBER", "BOOLEAN", "JSON"].includes(type)) {
value = JSON.stringify(value);
} else {
value = String(value);
}
const contentType = "text/plain";
@@ -605,10 +609,9 @@
const formRef = ref();
watch(() => kv.value.type, () => {
if (formRef.value) {
(formRef.value as any).clearValidate("value");
}
watch(() => kv.value.type, (newType) => {
formRef.value?.clearValidate("value");
if (newType === "BOOLEAN") kv.value.value = false;
});
defineExpose({

View File

@@ -1,5 +1,5 @@
<template>
<ContextInfoContent :title="t('feeds.title')">
<ContextInfoContent ref="contextInfoRef" :title="t('feeds.title')">
<div
class="post"
:class="{
@@ -46,9 +46,10 @@
</template>
<script setup lang="ts">
import {computed, onMounted, reactive} from "vue";
import {computed, onMounted, reactive, ref} from "vue";
import {useI18n} from "vue-i18n";
import {useStorage} from "@vueuse/core"
import {useScrollMemory} from "../../composables/useScrollMemory"
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
import MenuDown from "vue-material-design-icons/MenuDown.vue";
@@ -62,6 +63,7 @@
const apiStore = useApiStore();
const {t} = useI18n({useScope: "global"});
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
const feeds = computed(() => apiStore.feeds);
const expanded = reactive<Record<string, boolean>>({});
@@ -70,6 +72,9 @@
onMounted(() => {
lastNewsReadDate.value = feeds.value[0].publicationDate;
});
const scrollableElement = computed(() => contextInfoRef.value?.contentRef || null)
useScrollMemory(ref("context-panel-news"), scrollableElement as any)
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-code">
<div class="no-code" ref="scrollContainer">
<div class="p-4">
<Task
v-if="creatingTask || editingTask"
@@ -64,6 +64,7 @@
import {usePluginsStore} from "../../stores/plugins";
import {useKeyboardSave} from "./utils/useKeyboardSave";
import {deepEqual} from "../../utils/utils";
import {useScrollMemory} from "../../composables/useScrollMemory";
const props = defineProps<NoCodeProps>();
@@ -195,6 +196,28 @@
emit("editTask", parentPath, blockSchemaPath, refPath)
})
// Scroll position persistence for No-code editor
const scrollContainer = ref<HTMLDivElement | null>(null);
const flowIdentity = computed(() => {
const namespace = flowStore.flow?.namespace ?? "";
const flowId = flowStore.flow?.id ?? "";
return `${namespace}/${flowId}`;
});
const scrollKey = computed(() => {
const base = `nocode:${flowIdentity.value}`;
// home screen
if (!props.creatingTask && !props.editingTask) return `${base}:home`;
// task-specific
const action = props.creatingTask ? "create" : "edit";
const parentPath = props.parentPath ?? "";
const refPath = props.refPath ?? "";
const fieldName = props.fieldName ?? "";
return `${base}:task:${action}:parentPath:${parentPath}:refPath:${refPath}:fieldName:${fieldName}`;
});
useScrollMemory(scrollKey, scrollContainer);
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="playgroundStore.enabled && isTask && taskObject?.id" class="flow-playground">
<PlaygroundRunTaskButton :taskId="taskObject?.id" />
<div v-if="playgroundStore.enabled && isTask && taskModel?.id" class="flow-playground">
<PlaygroundRunTaskButton :taskId="taskModel?.id" />
</div>
<el-form v-if="isTaskDefinitionBasedOnType" labelPosition="top">
<el-form-item>
@@ -17,12 +17,12 @@
/>
</el-form-item>
</el-form>
<div @click="isPlugin && pluginsStore.updateDocumentation(taskObject as Parameters<typeof pluginsStore.updateDocumentation>[0])">
<div @click="() => onTaskEditorClick(taskModel)">
<TaskObject
v-loading="isLoading"
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && schema"
name="root"
:modelValue="taskObject"
:modelValue="taskModel"
@update:model-value="onTaskInput"
:schema
:properties
@@ -43,6 +43,7 @@
FULL_SCHEMA_INJECTION_KEY,
SCHEMA_DEFINITIONS_INJECTION_KEY,
DATA_TYPES_MAP_INJECTION_KEY,
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
} from "../injectionKeys";
import {removeNullAndUndefined} from "../utils/cleanUp";
import {removeRefPrefix, usePluginsStore} from "../../../stores/plugins";
@@ -63,9 +64,9 @@
const pluginsStore = usePluginsStore();
const playgroundStore = usePlaygroundStore();
type PartialCodeElement = Partial<NoCodeElement>;
type PartialNoCodeElement = Partial<NoCodeElement>;
const taskObject = ref<PartialCodeElement | undefined>({});
const taskModel = ref<PartialNoCodeElement | undefined>({});
const selectedTaskType = ref<string>();
const isLoading = ref(false);
@@ -108,7 +109,7 @@
watch(modelValue, (v) => {
if (!v) {
taskObject.value = {};
taskModel.value = {};
selectedTaskType.value = undefined;
} else {
setup()
@@ -150,20 +151,20 @@
});
function setup() {
const parsed = YAML_UTILS.parse<PartialCodeElement>(modelValue.value);
const parsed = YAML_UTILS.parse<PartialNoCodeElement>(modelValue.value);
if(isPluginDefaults.value){
const {forced, type, values} = parsed as any;
taskObject.value = {...values, forced, type};
taskModel.value = {...values, forced, type};
}else{
taskObject.value = parsed;
taskModel.value = parsed;
}
selectedTaskType.value = taskObject.value?.type;
selectedTaskType.value = taskModel.value?.type;
}
// when tab is opened, load the documentation
onActivated(() => {
if(selectedTaskType.value && parentPath !== "inputs"){
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
}
});
@@ -218,7 +219,7 @@
const resolvedType = computed<string>(() => {
if(resolvedTypes.value.length > 1 && selectedTaskType.value){
// find the resolvedType that match the current dataType
const dataType = taskObject.value?.data?.type;
const dataType = taskModel.value?.data?.type;
if(dataType){
for(const typeLocal of resolvedTypes.value){
const schema = definitions.value?.[typeLocal];
@@ -330,13 +331,13 @@
watch([selectedTaskType, fullSchema], ([task]) => {
if (task) {
if(isPlugin.value){
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
}
}
}, {immediate: true});
function onTaskInput(val: PartialCodeElement | undefined) {
taskObject.value = val;
function onTaskInput(val: PartialNoCodeElement | undefined) {
taskModel.value = val;
if(fieldName){
val = {
[fieldName]: val,
@@ -362,12 +363,21 @@
}
function onTaskTypeSelect() {
const value: PartialCodeElement = {
const value: PartialNoCodeElement = {
type: selectedTaskType.value ?? ""
};
onTaskInput(value);
}
const onTaskEditorClick = inject(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt?: PartialNoCodeElement) => {
const type = elt?.type;
if(isPlugin.value && type){
pluginsStore.updateDocumentation({type});
}else{
pluginsStore.updateDocumentation();
}
});
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
import type {ComputedRef, InjectionKey, Ref} from "vue"
import {TopologyClickParams} from "./utils/types"
import {NoCodeElement, TopologyClickParams} from "./utils/types"
import {Panel} from "../../utils/multiPanelTypes"
export const BLOCK_SCHEMA_PATH_INJECTION_KEY = Symbol("block-schema-path-injection-key") as InjectionKey<ComputedRef<string>>
@@ -90,4 +90,6 @@ export const FULL_SCHEMA_INJECTION_KEY = Symbol("full-schema-injection-key") as
export const SCHEMA_DEFINITIONS_INJECTION_KEY = Symbol("schema-definitions-injection-key") as InjectionKey<ComputedRef<Record<string, any>>>
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
export const ON_TASK_EDITOR_CLICK_INJECTION_KEY = Symbol("on-task-editor-click-injection-key") as InjectionKey<(elt?: Partial<NoCodeElement>) => void>;

View File

@@ -144,7 +144,13 @@
if (!cls) {
return;
}
router.push({name: "plugins/view", params: {cls: cls}})
router.push({
name: "plugins/view",
params: {
...route.params,
cls: cls
}
})
};
const isVisible = (plugin: any) => {

View File

@@ -28,7 +28,7 @@
</el-breadcrumb>
</div>
<div v-if="currentView === 'list'" class="list">
<div v-if="currentView === 'list'" class="list" ref="listRef">
<div
v-for="plugin in sortedPlugins"
:key="`${plugin.group}-${plugin.title}`"
@@ -48,7 +48,7 @@
</div>
</div>
<div v-else-if="currentView === 'group'" class="group-view">
<div v-else-if="currentView === 'group'" class="group-view" ref="groupRef">
<PluginUnified
:group="currentGroup"
:subgroup="currentSubgroup"
@@ -57,7 +57,7 @@
/>
</div>
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]">
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]" ref="docRef">
<PluginDocumentation
:plugin="currentDocumentationPlugin"
/>
@@ -72,6 +72,7 @@
import PluginUnified from "./PluginUnified.vue";
import PluginDocumentation from "./PluginDocumentation.vue";
import {usePluginsStore} from "../../stores/plugins";
import {useScrollMemory} from "../../composables/useScrollMemory";
import {capitalize, formatPluginTitle} from "../../utils/global";
interface Props {
@@ -94,6 +95,18 @@
const navigationStack = ref<NavigationItem[]>([]);
const currentDocumentationPlugin = ref<any>(null);
const currentView = ref<"list" | "group" | "documentation">("documentation");
const listRef = ref<HTMLDivElement | null>(null);
const groupRef = ref<HTMLDivElement | null>(null);
const docRef = ref<HTMLDivElement | null>(null);
const scrollKeyBase = "plugins:documentation";
const listScrollKey = computed(() => `${scrollKeyBase}:list`);
const groupScrollKey = computed(() => `${scrollKeyBase}:group`);
const docScrollKey = computed(() => `${scrollKeyBase}:documentation`);
useScrollMemory(listScrollKey, listRef);
useScrollMemory(groupScrollKey, groupRef);
useScrollMemory(docScrollKey, docRef);
const getSimpleType = (item: string) => item.split(".").pop() || item;
@@ -275,7 +288,9 @@
}
}, {immediate: true, deep: true});
onMounted(loadPluginIcons);
onMounted(async () => {
await loadPluginIcons();
});
</script>
<style scoped lang="scss">

View File

@@ -0,0 +1,49 @@
import {watch, nextTick, ref, Ref, onActivated} from "vue"
import {useScroll, useThrottleFn, useWindowScroll} from "@vueuse/core"
import {storageKeys} from "../utils/constants"
export function useScrollMemory(keyRef: Ref<string>, elementRef?: Ref<HTMLElement | null>, useWindow = false): {
saveData: (value: any, suffix?: string) => void;
loadData: <T = any>(suffix?: string, defaultValue?: T) => T | undefined;
} {
const getStorageKey = (suffix = "") => `${storageKeys.SCROLL_MEMORY_PREFIX}-${keyRef.value}${suffix}`
const saveToStorage = (value: any, suffix = "") => {
sessionStorage?.setItem(getStorageKey(suffix), JSON.stringify(value))
}
const loadFromStorage = <T = any>(suffix = "", defaultValue?: T): T | undefined => {
const saved = sessionStorage?.getItem(getStorageKey(suffix))
return saved ? JSON.parse(saved) : defaultValue
}
const saveScroll = (value: number) => saveToStorage(value)
const loadScroll = (): number => loadFromStorage("", 0) || 0
const restoreScroll = () => {
const scrollTop = loadScroll()
const applyScroll = useWindow
? () => window.scrollTo({top: scrollTop, behavior: "smooth"})
: () => { if (elementRef?.value) elementRef.value.scrollTo({top: scrollTop, behavior: "smooth"}) }
setTimeout(applyScroll, 10)
}
const throttledSave = useThrottleFn((top: number) => saveScroll(top), 100)
if (useWindow) {
const {y} = useWindowScroll({throttle: 16, onScroll: () => throttledSave(y.value)})
watch(keyRef, () => nextTick(restoreScroll), {immediate: true})
onActivated(() => nextTick(restoreScroll))
} else {
useScroll(elementRef || ref(null), {
throttle: 16,
onScroll: () => { if (elementRef?.value) throttledSave(elementRef.value.scrollTop) }
})
watch([keyRef, () => elementRef?.value], ([newKey, newElement]) => {
if (newElement && newKey) nextTick(restoreScroll)
}, {immediate: true})
onActivated(() => nextTick(restoreScroll))
}
return {saveData: saveToStorage, loadData: loadFromStorage}
}

View File

@@ -140,6 +140,49 @@ export const useDashboardStore = defineStore("dashboard", () => {
return rootSchema.value?.properties;
});
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
if(selectedChart.value?.content === yamlChart){
return {
error: chartErrors.value.length > 0 ? chartErrors.value[0] : null,
data: selectedChart.value ? {...selectedChart.value, raw: chart} : null,
raw: chart
};
}
const result: { error: string | null; data: null | {
id?: string;
name?: string;
type?: string;
chartOptions?: Record<string, any>;
dataFilters?: any[];
charts?: any[];
}; raw: any } = {
error: null,
data: null,
raw: {}
};
const errors = await validateChart(yamlChart);
if (errors.constraints) {
result.error = errors.constraints;
} else {
result.data = {...chart, content: yamlChart, raw: chart};
}
selectedChart.value = typeof result.data === "object"
? {
...result.data,
chartOptions: {
...result.data?.chartOptions,
width: 12
}
} as any
: undefined;
chartErrors.value = [result.error].filter(e => e !== null);
return result;
}
return {
dashboard,
chartErrors,
@@ -155,6 +198,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
validateChart,
chartPreview,
export: exportDashboard,
loadChart,
schema,
definitions,

View File

@@ -107,25 +107,11 @@
:deep(.alert-info) {
display: flex;
gap: 12px;
padding: 16px 16px 0 16px;
padding: .5rem !important;
background-color: var(--ks-background-info);
border: 1px solid var(--ks-border-info);
border-left-width: 5px;
border-radius: 8px;
&::before {
content: '!';
min-width: 20px;
height: 20px;
margin-top: 4px;
border-radius: 50%;
background: var(--ks-content-info);
border: 1px solid var(--ks-border-info);
color: var(--ks-content-inverse);
font: 600 13px/20px sans-serif;
text-align: center;
}
border-left-width: 0.25rem;
border-radius: 0.5rem;
p { color: var(--ks-content-info); }
}
@@ -135,7 +121,7 @@
color: var(--ks-content-info);
border: 1px solid var(--ks-border-info);
font-family: 'Courier New', Courier, monospace;
white-space: nowrap; // Prevent button text from wrapping
white-space: nowrap;
.material-design-icon {
position: absolute;

View File

@@ -40,6 +40,7 @@ export const storageKeys = {
TIMEZONE_STORAGE_KEY: "timezone",
SAVED_FILTERS_PREFIX: "saved_filters",
FILTER_ORDER_PREFIX: "filter-order",
SCROLL_MEMORY_PREFIX: "scroll",
}
export const executeFlowBehaviours = {

View File

@@ -531,7 +531,7 @@ class FlowControllerTest {
List<String> namespaces = client.toBlocking().retrieve(
HttpRequest.GET("/api/v1/main/flows/distinct-namespaces"), Argument.listOf(String.class));
assertThat(namespaces.size()).isEqualTo(11);
assertThat(namespaces.size()).isEqualTo(12);
}
@Test

View File

@@ -202,24 +202,24 @@ class KVControllerTest {
static Stream<Arguments> kvSetKeyValueArgs() {
return Stream.of(
Arguments.of("{\"hello\":\"world\"}", Map.class),
Arguments.of("[\"hello\",\"world\"]", List.class),
Arguments.of("\"hello\"", String.class),
Arguments.of("1", Integer.class),
Arguments.of("1.0", BigDecimal.class),
Arguments.of("true", Boolean.class),
Arguments.of("false", Boolean.class),
Arguments.of("2021-09-01", LocalDate.class),
Arguments.of("2021-09-01T01:02:03Z", Instant.class),
Arguments.of("\"PT5S\"", Duration.class)
Arguments.of(MediaType.TEXT_PLAIN, "{\"hello\":\"world\"}", Map.class),
Arguments.of(MediaType.TEXT_PLAIN, "[\"hello\",\"world\"]", List.class),
Arguments.of(MediaType.TEXT_PLAIN, "\"hello\"", String.class),
Arguments.of(MediaType.TEXT_PLAIN, "1", Integer.class),
Arguments.of(MediaType.TEXT_PLAIN, "1.0", BigDecimal.class),
Arguments.of(MediaType.TEXT_PLAIN, "true", Boolean.class),
Arguments.of(MediaType.TEXT_PLAIN, "false", Boolean.class),
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01", LocalDate.class),
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01T01:02:03Z", Instant.class),
Arguments.of(MediaType.TEXT_PLAIN, "\"PT5S\"", Duration.class)
);
}
@ParameterizedTest
@MethodSource("kvSetKeyValueArgs")
void setKeyValue(String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
void setKeyValue(MediaType mediaType, String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
String myDescription = "myDescription";
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).header("ttl", "PT5M").header("description", myDescription));
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).contentType(mediaType).header("ttl", "PT5M").header("description", myDescription));
KVStore kvStore = kvStore();
Class<?> valueClazz = kvStore.getValue("my-key").get().value().getClass();
@@ -294,7 +294,7 @@ class KVControllerTest {
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"")));
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"").contentType(MediaType.TEXT_PLAIN)));
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);