mirror of
https://github.com/kestra-io/kestra.git
synced 2026-01-07 18:02:03 -05:00
feat(assets): introducing assets ui
This commit is contained in:
committed by
Loïc Mathieu
parent
156a75c668
commit
8dff5855f2
BIN
ui/src/assets/demo/assets.png
Normal file
BIN
ui/src/assets/demo/assets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
34
ui/src/components/demo/Assets.vue
Normal file
34
ui/src/components/demo/Assets.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<TopNavBar :title="routeInfo.title" />
|
||||
<Layout
|
||||
:title="t(`demos.assets.title`)"
|
||||
:image="{
|
||||
source: img,
|
||||
alt: t(`demos.assets.title`)
|
||||
}"
|
||||
:video="{
|
||||
//TODO: replace with ASSET video
|
||||
source: 'https://www.youtube.com/embed/jMZ9Cs3xxpo',
|
||||
}"
|
||||
>
|
||||
<template #message>
|
||||
{{ $t(`demos.assets.message`) }}
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import img from "../../assets/demo/assets.png";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
|
||||
import Layout from "./Layout.vue";
|
||||
import TopNavBar from "../../components/layout/TopNavBar.vue";
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
const routeInfo = computed(() => ({title: t("demos.assets.header")}));
|
||||
|
||||
useRouteContext(routeInfo);
|
||||
</script>
|
||||
@@ -105,7 +105,7 @@
|
||||
position: relative;
|
||||
background: $base-gray-200;
|
||||
padding: .125rem 0.5rem;
|
||||
border-radius: $border-radius;
|
||||
border-radius: 1rem;
|
||||
display: inline-block;
|
||||
z-index: 2;
|
||||
margin: 0 auto;
|
||||
@@ -175,6 +175,7 @@
|
||||
line-height: 16px;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
color: var(--ks-content-secondary);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@@ -261,7 +262,7 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
font-size: 1rem;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
:elements="getElements()"
|
||||
@select="selectNode"
|
||||
:selected="selectedNodeID"
|
||||
:subtype="SUBTYPE"
|
||||
/>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
@@ -54,7 +55,7 @@
|
||||
import Empty from "../layout/empty/Empty.vue";
|
||||
|
||||
import {useDependencies} from "./composables/useDependencies";
|
||||
import {FLOW, EXECUTION, NAMESPACE} from "./utils/types";
|
||||
import {FLOW, EXECUTION, NAMESPACE, ASSET} from "./utils/types";
|
||||
|
||||
const PANEL = {size: "70%", min: "30%", max: "80%"};
|
||||
|
||||
@@ -66,13 +67,27 @@
|
||||
import SelectionRemove from "vue-material-design-icons/SelectionRemove.vue";
|
||||
import FitToScreenOutline from "vue-material-design-icons/FitToScreenOutline.vue";
|
||||
|
||||
const SUBTYPE = route.name === "flows/update" ? FLOW : route.name === "namespaces/update" ? NAMESPACE : EXECUTION;
|
||||
const props = defineProps<{
|
||||
fetchAssetDependencies?: () => Promise<{
|
||||
data: any[];
|
||||
count: number;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
const SUBTYPE = route.name === "flows/update" ? FLOW : route.name === "namespaces/update" ? NAMESPACE : route.name === "assets/update" ? ASSET : EXECUTION;
|
||||
|
||||
const container = ref(null);
|
||||
const initialNodeID: string = SUBTYPE === FLOW || SUBTYPE === NAMESPACE ? String(route.params.id) : String(route.params.flowId);
|
||||
const initialNodeID: string = SUBTYPE === FLOW || SUBTYPE === NAMESPACE || SUBTYPE === ASSET ? String(route.params.id || route.params.assetId) : String(route.params.flowId);
|
||||
const TESTING = false; // When true, bypasses API data fetching and uses mock/test data.
|
||||
|
||||
const {getElements, isLoading, isRendering, selectedNodeID, selectNode, handlers} = useDependencies(container, SUBTYPE, initialNodeID, route.params, TESTING);
|
||||
const {
|
||||
getElements,
|
||||
isLoading,
|
||||
isRendering,
|
||||
selectedNodeID,
|
||||
selectNode,
|
||||
handlers,
|
||||
} = useDependencies(container, SUBTYPE, initialNodeID, route.params, TESTING, props.fetchAssetDependencies);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -95,7 +110,7 @@
|
||||
|
||||
& .controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
bottom: 16px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,26 +9,32 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
|
||||
import {FLOW, EXECUTION, NAMESPACE, type Node} from "../utils/types";
|
||||
import {FLOW, EXECUTION, NAMESPACE, ASSET, type Node} from "../utils/types";
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node;
|
||||
subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE;
|
||||
subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE | typeof ASSET;
|
||||
}>();
|
||||
|
||||
const to = computed(() => {
|
||||
const base = {namespace: props.node.namespace};
|
||||
|
||||
if ("id" in props.node.metadata && props.node.metadata.id)
|
||||
if (props.subtype === ASSET) {
|
||||
return {
|
||||
name: "assets/update",
|
||||
params: {...base, assetId: props.node.flow},
|
||||
};
|
||||
} else if ("id" in props.node.metadata && props.node.metadata.id) {
|
||||
return {
|
||||
name: "executions/update",
|
||||
params: {...base, flowId: props.node.flow, id: props.node.metadata.id},
|
||||
};
|
||||
else
|
||||
} else {
|
||||
return {
|
||||
name: "flows/update",
|
||||
params: {...base, id: props.node.flow},
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section id="input">
|
||||
<el-input
|
||||
v-model="search"
|
||||
:placeholder="$t('dependency.search.placeholder')"
|
||||
:placeholder="$t(props.subtype === ASSET ? 'dependency.search.asset_placeholder' : 'dependency.search.placeholder')"
|
||||
clearable
|
||||
/>
|
||||
</section>
|
||||
@@ -38,10 +38,13 @@
|
||||
size="small"
|
||||
/>
|
||||
<RouterLink
|
||||
v-if="[FLOW, NAMESPACE].includes(row.data.metadata.subtype)"
|
||||
v-if="[FLOW, NAMESPACE, ASSET].includes(row.data.metadata.subtype)"
|
||||
:to="{
|
||||
name: 'flows/update',
|
||||
params: {namespace: row.data.namespace, id: row.data.flow}}"
|
||||
name: row.data.metadata.subtype === ASSET ? 'assets/update' : 'flows/update',
|
||||
params: row.data.metadata.subtype === ASSET
|
||||
? {namespace: row.data.namespace, assetId: row.data.flow}
|
||||
: {namespace: row.data.namespace, id: row.data.flow}
|
||||
}"
|
||||
>
|
||||
<el-icon :size="16">
|
||||
<OpenInNew />
|
||||
@@ -64,12 +67,13 @@
|
||||
|
||||
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
|
||||
|
||||
import {NODE, FLOW, EXECUTION, NAMESPACE, type Node} from "../utils/types";
|
||||
import {NODE, FLOW, EXECUTION, NAMESPACE, ASSET, type Node} from "../utils/types";
|
||||
|
||||
const emits = defineEmits<{ (e: "select", id: Node["id"]): void }>();
|
||||
const props = defineProps<{
|
||||
elements: cytoscape.ElementDefinition[];
|
||||
selected: Node["id"] | undefined;
|
||||
subtype?: typeof FLOW | typeof EXECUTION | typeof NAMESPACE | typeof ASSET;
|
||||
}>();
|
||||
|
||||
const focusSelectedRow = () => {
|
||||
@@ -177,6 +181,10 @@ section#row {
|
||||
& section#right {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
|
||||
:deep(a:hover .el-icon) {
|
||||
color: var(--ks-content-link-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,7 +163,7 @@ const setExecutionEdgeColors = throttle(
|
||||
* @param classes - An array of class names to remove from all elements.
|
||||
* Defaults to [`selected`, `faded`, `hovered`, `executions`].
|
||||
*/
|
||||
export function clearClasses(cy: cytoscape.Core, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE, classes: string[] = [SELECTED, FADED, HOVERED, EXECUTIONS]): void {
|
||||
export function clearClasses(cy: cytoscape.Core, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE | typeof ASSET, classes: string[] = [SELECTED, FADED, HOVERED, EXECUTIONS]): void {
|
||||
cy.elements().removeClass(classes.join(" "));
|
||||
if (subtype === EXECUTION) cy.edges().style(edgeColors());
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export function fit(cy: cytoscape.Core, padding: number = 50): void {
|
||||
* @param subtype - Determines how connected elements are highlighted (`FLOW`, `EXECUTION` or `NAMESPACE`).
|
||||
* @param id - Optional explicit ID to assign to the ref (defaults to the node’s own ID).
|
||||
*/
|
||||
function selectHandler(cy: cytoscape.Core, node: cytoscape.NodeSingular, selected: Ref<Node["id"] | undefined>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE, id?: Node["id"]): void {
|
||||
function selectHandler(cy: cytoscape.Core, node: cytoscape.NodeSingular, selected: Ref<Node["id"] | undefined>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE | typeof ASSET, id?: Node["id"]): void {
|
||||
// Clear all existing classes
|
||||
clearClasses(cy, subtype);
|
||||
|
||||
@@ -263,7 +263,17 @@ function hoverHandler(cy: cytoscape.Core): void {
|
||||
* @returns An object with element getters, loading state, rendering state, selected node ID,
|
||||
* selection helpers, and control handlers.
|
||||
*/
|
||||
export function useDependencies(container: Ref<HTMLElement | null>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE = FLOW, initialNodeID: string, params: RouteParams, isTesting = false) {
|
||||
export function useDependencies(
|
||||
container: Ref<HTMLElement | null>,
|
||||
subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE | typeof ASSET = FLOW,
|
||||
initialNodeID: string,
|
||||
params: RouteParams,
|
||||
isTesting = false,
|
||||
fetchAssetDependencies?: () => Promise<{
|
||||
data: Element[];
|
||||
count: number;
|
||||
}>
|
||||
) {
|
||||
const coreStore = useCoreStore();
|
||||
const flowStore = useFlowStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
@@ -301,7 +311,13 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
}
|
||||
};
|
||||
|
||||
const elements = ref<{ data: cytoscape.ElementDefinition[]; count: number; }>({data: [], count: 0});
|
||||
const elements = ref<{
|
||||
data: cytoscape.ElementDefinition[];
|
||||
count: number;
|
||||
}>({
|
||||
data: [],
|
||||
count: 0,
|
||||
});
|
||||
onMounted(async () => {
|
||||
if (isTesting) {
|
||||
if (!container.value) {
|
||||
@@ -313,13 +329,32 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
isLoading.value = false;
|
||||
} else {
|
||||
try {
|
||||
if (subtype === NAMESPACE) {
|
||||
const {data} = await namespacesStore.loadDependencies({namespace: params.id as string});
|
||||
if (fetchAssetDependencies) {
|
||||
const result = await fetchAssetDependencies();
|
||||
elements.value = {
|
||||
data: result.data,
|
||||
count: result.count
|
||||
};
|
||||
isLoading.value = false;
|
||||
} else if (subtype === NAMESPACE) {
|
||||
const {data} = await namespacesStore.loadDependencies({
|
||||
namespace: params.id as string,
|
||||
});
|
||||
const nodes = data.nodes ?? [];
|
||||
elements.value = {data: transformResponse(data, NAMESPACE), count: new Set(nodes.map((r: { uid: string }) => r.uid)).size};
|
||||
elements.value = {
|
||||
data: transformResponse(data, NAMESPACE),
|
||||
count: new Set(nodes.map((r: { uid: string }) => r.uid)).size,
|
||||
};
|
||||
isLoading.value = false;
|
||||
} else {
|
||||
const result = await flowStore.loadDependencies({id: (subtype === FLOW ? params.id : params.flowId) as string, namespace: params.namespace as string, subtype}, false);
|
||||
const result = await flowStore.loadDependencies(
|
||||
{
|
||||
id: (subtype === FLOW ? params.id : params.flowId) as string,
|
||||
namespace: params.namespace as string,
|
||||
subtype,
|
||||
},
|
||||
false
|
||||
);
|
||||
elements.value = {data: result.data ?? [], count: result.count};
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -448,8 +483,16 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
selectedNodeID,
|
||||
selectNode,
|
||||
handlers: {
|
||||
zoomIn: () => cy.zoom({level: cy.zoom() + 0.1, renderedPosition: cy.getElementById(selectedNodeID.value!).renderedPosition()}),
|
||||
zoomOut: () => cy.zoom({level: cy.zoom() - 0.1, renderedPosition: cy.getElementById(selectedNodeID.value!).renderedPosition()}),
|
||||
zoomIn: () =>
|
||||
cy.zoom({
|
||||
level: cy.zoom() + 0.1,
|
||||
renderedPosition: cy.getElementById(selectedNodeID.value!).renderedPosition(),
|
||||
}),
|
||||
zoomOut: () =>
|
||||
cy.zoom({
|
||||
level: cy.zoom() - 0.1,
|
||||
renderedPosition: cy.getElementById(selectedNodeID.value!).renderedPosition(),
|
||||
}),
|
||||
clearSelection: () => {
|
||||
clearClasses(cy, subtype);
|
||||
selectedNodeID.value = undefined;
|
||||
@@ -468,9 +511,23 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
* @param subtype - The node subtype, either `FLOW`, `EXECUTION`, or `NAMESPACE`.
|
||||
* @returns An array of cytoscape elements with correctly typed nodes and edges.
|
||||
*/
|
||||
export function transformResponse(response: {nodes: { uid: string; namespace: string; id: string }[]; edges: { source: string; target: string }[]; }, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE): Element[] {
|
||||
const nodes: Node[] = response.nodes.map((node) => ({id: node.uid, type: NODE, flow: node.id, namespace: node.namespace, metadata: {subtype}}));
|
||||
const edges: Edge[] = response.edges.map((edge) => ({id: uuid(), type: EDGE, source: edge.source, target: edge.target}));
|
||||
export function transformResponse(response: {nodes: { uid: string; namespace: string; id: string }[]; edges: { source: string; target: string }[];}, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE): Element[] {
|
||||
const nodes: Node[] = response.nodes.map((node) => ({
|
||||
id: node.uid,
|
||||
type: NODE,
|
||||
flow: node.id,
|
||||
namespace: node.namespace,
|
||||
metadata: {subtype},
|
||||
}));
|
||||
const edges: Edge[] = response.edges.map((edge) => ({
|
||||
id: uuid(),
|
||||
type: EDGE,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}));
|
||||
|
||||
return [...nodes.map((node) => ({data: node}) as Element), ...edges.map((edge) => ({data: edge}) as Element)];
|
||||
return [
|
||||
...nodes.map((node) => ({data: node}) as Element),
|
||||
...edges.map((edge) => ({data: edge}) as Element),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const EDGE = "EDGE" as const;
|
||||
export const FLOW = "FLOW" as const;
|
||||
export const EXECUTION = "EXECUTION" as const;
|
||||
export const NAMESPACE = "NAMESPACE" as const;
|
||||
export const ASSET = "ASSET" as const;
|
||||
|
||||
type Flow = {
|
||||
subtype: typeof FLOW;
|
||||
@@ -19,12 +20,16 @@ type Namespace = {
|
||||
subtype: typeof NAMESPACE;
|
||||
};
|
||||
|
||||
type Asset = {
|
||||
subtype: typeof ASSET;
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
id: string;
|
||||
type: "NODE";
|
||||
flow: string;
|
||||
namespace: string;
|
||||
metadata: Flow | Execution | Namespace;
|
||||
metadata: Flow | Execution | Namespace | Asset;
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
font-size: 12px;
|
||||
color: var(--ks-content-primary);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.value {
|
||||
font-weight: 700;
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
);
|
||||
|
||||
const isKVPairFilter = computed(() =>
|
||||
props.filterKey?.valueType === "key-value" || (props.filterKey?.key === "labels" && KV_COMPARATORS.includes(state.selectedComparator))
|
||||
props.filterKey?.valueType === "key-value"
|
||||
);
|
||||
|
||||
const valueComponent = computed(() => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
COMPARATOR_LABELS,
|
||||
Comparators,
|
||||
TEXT_COMPARATORS,
|
||||
KV_COMPARATORS
|
||||
} from "../utils/filterTypes";
|
||||
import {usePreAppliedFilters} from "./usePreAppliedFilters";
|
||||
import {useDefaultFilter} from "./useDefaultFilter";
|
||||
@@ -67,11 +66,11 @@ export function useFilters(
|
||||
};
|
||||
|
||||
const clearLegacyParams = (query: Record<string, any>) => {
|
||||
configuration.keys?.forEach(({key}) => {
|
||||
configuration.keys?.forEach(({key, valueType}) => {
|
||||
delete query[key];
|
||||
if (key === "details") {
|
||||
if (valueType === "key-value") {
|
||||
Object.keys(query).forEach(queryKey => {
|
||||
if (queryKey.startsWith("details.")) delete query[queryKey];
|
||||
if (queryKey.startsWith(`${key}.`)) delete query[queryKey];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -85,10 +84,10 @@ export function useFilters(
|
||||
*/
|
||||
const buildLegacyQuery = (query: Record<string, any>) => {
|
||||
getUniqueFilters(appliedFilters.value.filter(isValidFilter)).forEach(filter => {
|
||||
if (filter.key === "details") {
|
||||
if (configuration.keys?.find(k => k.key === filter.key)?.valueType === "key-value") {
|
||||
(filter.value as string[]).forEach(item => {
|
||||
const [k, v] = item.split(":");
|
||||
query[`details.${k}`] = v;
|
||||
query[`${filter.key}.${k}`] = v;
|
||||
});
|
||||
} else if (Array.isArray(filter.value)) {
|
||||
filter.value.forEach(item =>
|
||||
@@ -108,8 +107,6 @@ export function useFilters(
|
||||
const query = {...route.query};
|
||||
clearFilterQueryParams(query);
|
||||
|
||||
delete query.page;
|
||||
|
||||
if (legacyQuery) {
|
||||
clearLegacyParams(query);
|
||||
buildLegacyQuery(query);
|
||||
@@ -119,6 +116,15 @@ export function useFilters(
|
||||
}
|
||||
|
||||
updateSearchQuery(query);
|
||||
|
||||
if (
|
||||
(appliedFilters.value.some(f => Array.isArray(f.value) && f.value.length > 0)
|
||||
|| searchQuery.value.trim())
|
||||
&& parseInt(String(query.page ?? "1")) > 1
|
||||
) {
|
||||
delete query.page;
|
||||
}
|
||||
|
||||
router.push({query});
|
||||
};
|
||||
|
||||
@@ -145,14 +151,13 @@ export function useFilters(
|
||||
value: string | string[]
|
||||
): AppliedFilter => {
|
||||
const comparator = (config?.comparators?.[0] as Comparators) ?? Comparators.EQUALS;
|
||||
const valueLabel = Array.isArray(value)
|
||||
? key === "details" && value.length > 1
|
||||
? `${value[0]} +${value.length - 1}`
|
||||
return createAppliedFilter(key, config, comparator, value,
|
||||
config?.valueType === "key-value" && Array.isArray(value)
|
||||
? value.length > 1 ? `${value[0]} +${value.length - 1}` : value[0] ?? ""
|
||||
: Array.isArray(value)
|
||||
? value.join(", ")
|
||||
: value[0]
|
||||
: (value as string);
|
||||
return createAppliedFilter(key, config, comparator, value, valueLabel, "EQUALS");
|
||||
: value as string
|
||||
, "EQUALS");
|
||||
};
|
||||
|
||||
const createTimeRangeFilter = (
|
||||
@@ -161,14 +166,13 @@ export function useFilters(
|
||||
endDate: Date,
|
||||
comparator = Comparators.EQUALS
|
||||
): AppliedFilter => {
|
||||
const valueLabel = `${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`;
|
||||
return {
|
||||
...createAppliedFilter(
|
||||
"timeRange",
|
||||
config,
|
||||
comparator,
|
||||
{startDate, endDate},
|
||||
valueLabel,
|
||||
`${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`,
|
||||
keyOfComparator(comparator)
|
||||
),
|
||||
comparatorLabel: "Is Between"
|
||||
@@ -181,34 +185,36 @@ export function useFilters(
|
||||
*/
|
||||
const parseLegacyFilters = (): AppliedFilter[] => {
|
||||
const filtersMap = new Map<string, AppliedFilter>();
|
||||
const details: string[] = [];
|
||||
const keyValueFilters: Record<string, string[]> = {};
|
||||
|
||||
Object.entries(route.query).forEach(([key, value]) => {
|
||||
if (["q", "search", "filters[q][EQUALS]"].includes(key)) return;
|
||||
|
||||
if (key.startsWith("details.")) {
|
||||
details.push(`${key.split(".")[1]}:${value}`);
|
||||
const kvConfig = configuration.keys?.find(k => key.startsWith(`${k.key}.`) && k.valueType === "key-value");
|
||||
if (kvConfig) {
|
||||
if (!keyValueFilters[kvConfig.key]) keyValueFilters[kvConfig.key] = [];
|
||||
keyValueFilters[kvConfig.key].push(`${key.split(".")[1]}:${value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = configuration.keys?.find(k => k.key === key);
|
||||
if (!config) return;
|
||||
|
||||
const processedValue = Array.isArray(value)
|
||||
? (value as string[]).filter(v => v !== null)
|
||||
: config?.valueType === "multi-select"
|
||||
? ((value as string) ?? "").split(",")
|
||||
: ((value as string) ?? "");
|
||||
|
||||
filtersMap.set(key, createFilter(key, config, processedValue));
|
||||
filtersMap.set(key, createFilter(key, config,
|
||||
Array.isArray(value)
|
||||
? (value as string[]).filter(v => v !== null)
|
||||
: config?.valueType === "multi-select"
|
||||
? ((value as string) ?? "").split(",")
|
||||
: ((value as string) ?? "")
|
||||
));
|
||||
});
|
||||
|
||||
if (details.length > 0) {
|
||||
const config = configuration.keys?.find(k => k.key === "details");
|
||||
Object.entries(keyValueFilters).forEach(([key, values]) => {
|
||||
const config = configuration.keys?.find(k => k.key === key);
|
||||
if (config) {
|
||||
filtersMap.set("details", createFilter("details", config, details));
|
||||
filtersMap.set(key, createFilter(key, config, values));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (route.query.startDate && route.query.endDate) {
|
||||
const timeRangeConfig = configuration.keys?.find(k => k.key === "timeRange");
|
||||
@@ -227,13 +233,10 @@ export function useFilters(
|
||||
return Array.from(filtersMap.values());
|
||||
};
|
||||
|
||||
const isKVFilter = (field: string, comparator: Comparators) =>
|
||||
field === "details" || (field === "labels" && KV_COMPARATORS.includes(comparator));
|
||||
|
||||
const processFieldValue = (config: any, params: any[], field: string, comparator: Comparators) => {
|
||||
const processFieldValue = (config: any, params: any[], _field: string, comparator: Comparators) => {
|
||||
const isTextOp = TEXT_COMPARATORS.includes(comparator);
|
||||
|
||||
if (isKVFilter(field, comparator)) {
|
||||
if (config?.valueType === "key-value") {
|
||||
const combinedValue = params.map(p => p?.value as string);
|
||||
return {
|
||||
value: combinedValue,
|
||||
@@ -253,10 +256,9 @@ export function useFilters(
|
||||
};
|
||||
}
|
||||
|
||||
const param = params[0];
|
||||
let value = Array.isArray(param?.value)
|
||||
? param.value[0]
|
||||
: (param?.value as string);
|
||||
let value = Array.isArray(params[0]?.value)
|
||||
? params[0].value[0]
|
||||
: (params[0]?.value as string);
|
||||
|
||||
if (config?.valueType === "date" && typeof value === "string") {
|
||||
value = new Date(value);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => {
|
||||
label: t("filter.labels_execution.label"),
|
||||
description: t("filter.labels_execution.description"),
|
||||
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
|
||||
valueType: "text",
|
||||
valueType: "key-value",
|
||||
},
|
||||
{
|
||||
key: "triggerExecutionId",
|
||||
|
||||
@@ -74,7 +74,7 @@ export const useFlowExecutionFilter = (): ComputedRef<FilterConfiguration> => {
|
||||
label: t("filter.labels_execution.label"),
|
||||
description: t("filter.labels_execution.description"),
|
||||
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
|
||||
valueType: "text",
|
||||
valueType: "key-value",
|
||||
},
|
||||
{
|
||||
key: "triggerExecutionId",
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useFlowFilter = (): ComputedRef<FilterConfiguration> => {
|
||||
label: t("filter.labels_flow.label"),
|
||||
description: t("filter.labels_flow.description"),
|
||||
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
|
||||
valueType: "text",
|
||||
valueType: "key-value",
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export const decodeSearchParams = (query: LocationQuery) =>
|
||||
|
||||
const [, field, operation, subKey] = match;
|
||||
|
||||
if (field === "labels" && subKey) {
|
||||
if (subKey) {
|
||||
return {
|
||||
field,
|
||||
value: `${subKey}:${decodeURIComponentSafely(value)}`,
|
||||
@@ -57,30 +57,19 @@ export const encodeFiltersToQuery = (filters: Filter[], keyOfComparator: (compar
|
||||
query[`filters[${key}][${comparatorKey}]`] = value?.toString() ?? "";
|
||||
}
|
||||
return query;
|
||||
case "labels":
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((label: string) => {
|
||||
const [k, v] = label.split(":", 2);
|
||||
if (k && v) query[`filters[labels][${comparatorKey}][${k}]`] = v;
|
||||
});
|
||||
} else if (typeof value === "string") {
|
||||
const [k, v] = value.split(":", 2);
|
||||
if (k && v) {
|
||||
query[`filters[labels][${comparatorKey}][${k}]`] = v;
|
||||
} else {
|
||||
query[`filters[${key}][${comparatorKey}]`] = value;
|
||||
}
|
||||
}
|
||||
return query;
|
||||
default: {
|
||||
const processedValue = Array.isArray(value)
|
||||
? value.join(",")
|
||||
: typeof value === "object" && "startDate" in value
|
||||
? `${value.startDate.toISOString()},${value.endDate.toISOString()}`
|
||||
if (Array.isArray(value) && value.some(v => typeof v === "string" && v.includes(":"))) {
|
||||
value.forEach((item: string) => {
|
||||
const [k, v] = item.split(":", 2);
|
||||
if (k && v) query[`filters[${key}][${comparatorKey}][${k}]`] = v;
|
||||
});
|
||||
} else {
|
||||
query[`filters[${key}][${comparatorKey}]`] = Array.isArray(value)
|
||||
? value.join(",")
|
||||
: value instanceof Date
|
||||
? value.toISOString()
|
||||
: value;
|
||||
query[`filters[${key}][${comparatorKey}]`] = processedValue?.toString() ?? "";
|
||||
: value?.toString() ?? "";
|
||||
}
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,16 @@
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ labels: Label[]; readOnly?: boolean }>(),
|
||||
{labels: () => [], readOnly: false},
|
||||
defineProps<{
|
||||
labels?: Label[];
|
||||
readOnly?: boolean;
|
||||
filterType?: "labels" | "metadata";
|
||||
}>(),
|
||||
{
|
||||
labels: () => [],
|
||||
readOnly: false,
|
||||
filterType: "labels",
|
||||
},
|
||||
);
|
||||
|
||||
import {decodeSearchParams} from "../../components/filter/utils/helpers";
|
||||
@@ -48,7 +56,7 @@
|
||||
};
|
||||
|
||||
const updateLabel = (label: Label) => {
|
||||
const getKey = (key: string) => `filters[labels][EQUALS][${key}]`;
|
||||
const getKey = (key: string) => `filters[${props.filterType}][EQUALS][${key}]`;
|
||||
|
||||
if (isChecked(label)) {
|
||||
const replacementQuery = {...route.query};
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
@click="onStarClick"
|
||||
/>
|
||||
</h1>
|
||||
<div class="description">
|
||||
<slot name="description">
|
||||
{{ longDescription }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,15 +82,20 @@
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
breadcrumb?: { label: string; link?: RouterLinkTo; disabled?: boolean }[];
|
||||
longDescription?: string;
|
||||
breadcrumb?: {
|
||||
label: string;
|
||||
link?: RouterLinkTo;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
beta?: boolean;
|
||||
}>();
|
||||
|
||||
const logsStore = useLogsStore();
|
||||
const bookmarksStore = useBookmarksStore();
|
||||
const flowStore = useFlowStore();
|
||||
const route = useRoute();
|
||||
const logsStore = useLogsStore();
|
||||
const flowStore = useFlowStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const bookmarksStore = useBookmarksStore();
|
||||
|
||||
|
||||
const shouldDisplayDeleteButton = computed(() => {
|
||||
@@ -182,6 +192,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
margin-top: -0.5rem;
|
||||
color: var(--ks-content-secondary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
border: none;
|
||||
color: var(--ks-content-tertiary);
|
||||
|
||||
BIN
ui/src/components/layout/empty/assets/visuals/assets.png
Normal file
BIN
ui/src/components/layout/empty/assets/visuals/assets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -8,6 +8,7 @@ import plugins from "./assets/visuals/plugins.png";
|
||||
import triggers from "./assets/visuals/triggers.png";
|
||||
import versionPlugin from "./assets/visuals/versionPlugin.png";
|
||||
import panels from "./assets/visuals/panels.png";
|
||||
import assets from "./assets/visuals/assets.png";
|
||||
|
||||
export const images: Record<string, string> = {
|
||||
announcements,
|
||||
@@ -18,8 +19,10 @@ export const images: Record<string, string> = {
|
||||
"dependencies.FLOW": dependencies,
|
||||
"dependencies.EXECUTION": dependencies,
|
||||
"dependencies.NAMESPACE": dependencies,
|
||||
"dependencies.ASSET": dependencies,
|
||||
plugins,
|
||||
triggers,
|
||||
versionPlugin,
|
||||
panels,
|
||||
assets
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import ContentCopy from "vue-material-design-icons/ContentCopy.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 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";
|
||||
@@ -145,8 +145,19 @@ export function useLeftMenu() {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
// TODO: To add Assets entry here in future release
|
||||
// Uncomment PackageVariantClosed on line 25 and use as the icon
|
||||
{
|
||||
title: t("demos.assets.label"),
|
||||
routes: routeStartWith("assets"),
|
||||
href: {
|
||||
name: "assets/list"
|
||||
},
|
||||
icon: {
|
||||
element: PackageVariantClosed,
|
||||
},
|
||||
attributes: {
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("namespaces"),
|
||||
routes: routeStartWith("namespaces"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import DemoAuditLogs from "../components/demo/AuditLogs.vue"
|
||||
import DemoInstance from "../components/demo/Instance.vue"
|
||||
import DemoApps from "../components/demo/Apps.vue"
|
||||
import DemoTests from "../components/demo/Tests.vue"
|
||||
import DemoAssets from "../components/demo/Assets.vue"
|
||||
import {applyDefaultFilters} from "../components/filter/composables/useDefaultFilter";
|
||||
|
||||
export default [
|
||||
@@ -123,6 +124,7 @@ export default [
|
||||
//Demo Pages
|
||||
{name: "apps/list", path: "/:tenant?/apps", component: DemoApps},
|
||||
{name: "tests/list", path: "/:tenant?/tests", component: DemoTests},
|
||||
{name: "assets/list", path: "/:tenant?/assets", component: DemoAssets},
|
||||
{name: "admin/iam", path: "/:tenant?/admin/iam", component: DemoIAM},
|
||||
{name: "admin/tenants/list", path: "/:tenant?/admin/tenants", component: DemoTenants},
|
||||
{name: "admin/auditlogs/list", path: "/:tenant?/admin/auditlogs", component: DemoAuditLogs},
|
||||
|
||||
@@ -896,6 +896,12 @@
|
||||
"title": "Ensure Reliability with Every Change",
|
||||
"message": "Verify the logic of your flows in isolation, detect regressions early, and maintain confidence in your automations as they change and grow."
|
||||
},
|
||||
"assets": {
|
||||
"label": "Assets",
|
||||
"header": "Assets Metadata and Observability",
|
||||
"title": "Bring every dataset, service, and dependency into view.",
|
||||
"message": "Assets connect observability, lineage, and ownership metadata so platform teams can troubleshoot faster and deploy with confidence."
|
||||
},
|
||||
"IAM": {
|
||||
"title": "Manage Users through IAM with SSO, SCIM and RBAC",
|
||||
"message": "Kestra Enterprise Edition has built-in IAM capabilities with single sign-on (SSO), SCIM directory sync, and role-based access control (RBAC), integrating with multiple identity providers and letting you assign fine-grained permissions for users and service accounts."
|
||||
@@ -1370,6 +1376,10 @@
|
||||
"title": "You have no Tests yet!",
|
||||
"content": "Add tests to validate quality and avoid regressions in your flows."
|
||||
},
|
||||
"assets": {
|
||||
"title": "You have no Assets yet!",
|
||||
"content": "Add assets to track and manage your data assets, services, and infrastructure."
|
||||
},
|
||||
"concurrency_executions": {
|
||||
"title": "No ongoing Executions for this Flow.",
|
||||
"content": "Read more about <strong><a href=\"https://kestra.io/docs/workflow-components/execution\" target=\"_blank\">Executions</a></strong> in our documentation."
|
||||
@@ -1390,6 +1400,10 @@
|
||||
"NAMESPACE": {
|
||||
"title": "There are currently no dependencies.",
|
||||
"content": "Read more about <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> in our documentation."
|
||||
},
|
||||
"ASSET": {
|
||||
"title": "There are currently no dependencies.",
|
||||
"content": "This asset has no upstream or downstream dependencies with flows or other assets."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
@@ -1430,7 +1444,8 @@
|
||||
"flows": "No Flows Found",
|
||||
"kv_pairs": "No Key-Value pairs Found",
|
||||
"secrets": "No Secrets Found",
|
||||
"templates": "No Templates Found"
|
||||
"templates": "No Templates Found",
|
||||
"assets": "No Assets Found"
|
||||
},
|
||||
"duplicate-pair": "{label} \"{key}\" is duplicated, first key ignored.",
|
||||
"dashboards": {
|
||||
@@ -1575,6 +1590,7 @@
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search by flow or namespace...",
|
||||
"asset_placeholder": "Search by asset or flow or namespace...",
|
||||
"no_results": "No results found for {term}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ export const storageKeys = {
|
||||
DISPLAY_KV_COLUMNS: "displayKvColumns",
|
||||
DISPLAY_SECRETS_COLUMNS: "displaySecretsColumns",
|
||||
DISPLAY_TRIGGERS_COLUMNS: "displayTriggersColumns",
|
||||
DISPLAY_ASSETS_COLUMNS: "displayAssetsColumns",
|
||||
DISPLAY_ASSET_EXECUTIONS_COLUMNS: "displayAssetExecutionsColumns",
|
||||
SELECTED_TENANT: "selectedTenant",
|
||||
EXECUTE_FLOW_BEHAVIOUR: "executeFlowBehaviour",
|
||||
SHOW_CHART: "showChart",
|
||||
|
||||
Reference in New Issue
Block a user