diff --git a/ui/packages/design-system/src/components/Data/KsJsonTree.vue b/ui/packages/design-system/src/components/Data/KsJsonTree.vue new file mode 100644 index 00000000000..0d07304010b --- /dev/null +++ b/ui/packages/design-system/src/components/Data/KsJsonTree.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/ui/packages/design-system/src/index.ts b/ui/packages/design-system/src/index.ts index 611ecbf6b34..6a03c6d3781 100644 --- a/ui/packages/design-system/src/index.ts +++ b/ui/packages/design-system/src/index.ts @@ -103,6 +103,7 @@ import KsTooltip from "./components/Feedback/KsTooltip.vue" import KsTopNavBar from "./components/Navigation/KsTopNavBar/KsTopNavBar.vue" import KsTaskIcon from "./components/Kestra/KsTaskIcon.vue" import KsTree from "./components/Data/KsTree.vue" +import KsJsonTree from "./components/Data/KsJsonTree.vue" import KsUpload from "./components/Form/KsUpload.vue" import KsSubMenu from "./components/Navigation/KsMenu/KsSubMenu.vue" import KsPageHeader from "./components/Data/KsPageHeader.vue" @@ -295,6 +296,7 @@ const components: Record = { KsTooltip, KsTopNavBar, KsTree, + KsJsonTree, KsUpload, KsSubMenu, KsPageHeader, @@ -402,6 +404,7 @@ export { KsTooltip, KsTopNavBar, KsTree, + KsJsonTree, KsUpload, KsSubMenu, KsPageHeader, @@ -528,6 +531,7 @@ declare module "vue" { KsTooltip: typeof KsTooltip KsTopNavBar: typeof KsTopNavBar KsTree: typeof KsTree + KsJsonTree: typeof KsJsonTree KsUpload: typeof KsUpload } } diff --git a/ui/packages/design-system/tests/storybook/Data/KsJsonTree.stories.ts b/ui/packages/design-system/tests/storybook/Data/KsJsonTree.stories.ts new file mode 100644 index 00000000000..be5fdc197cf --- /dev/null +++ b/ui/packages/design-system/tests/storybook/Data/KsJsonTree.stories.ts @@ -0,0 +1,108 @@ +import type {Meta, StoryObj} from "@storybook/vue3-vite" +import KsJsonTree from "../../../src/components/Data/KsJsonTree.vue" + +const NESTED_OBJECT = { + event: "deploy.completed", + status: "success", + duration: 1840, + timestamp: "2026-06-04T13:33:56.680Z", + meta: { + namespace: "company.data", + flowId: "etl-pipeline", + executionId: "4Q9z27FJ26FRIhdv037HtF", + }, + tags: ["production", "scheduled"], + error: null, + retried: false, +} + +const meta: Meta = { + title: "Data/KsJsonTree", + component: KsJsonTree, + tags: ["autodocs"], + argTypes: { + defaultExpanded: {control: "boolean"}, + depth: {control: "number"}, + }, +} + +export default meta +type Story = StoryObj + +export const Object_: Story = { + name: "Object", + args: {value: NESTED_OBJECT, defaultExpanded: true}, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} + +export const Array_: Story = { + name: "Array", + args: {value: ["production", "scheduled", "data-team", "priority-high"], defaultExpanded: true}, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} + +export const Collapsed: Story = { + args: {value: NESTED_OBJECT, defaultExpanded: false}, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} + +export const DeeplyNested: Story = { + args: { + value: { + level1: { + level2: { + level3: {level4: {value: "deep"}, array: [1, 2, 3]}, + sibling: true, + }, + count: 42, + }, + topLevel: "string", + }, + defaultExpanded: true, + }, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} + +export const MixedTypes: Story = { + args: { + value: { + string: "hello world", + number: 3.14, + boolean: true, + null_: null, + array: [1, "two", false, null], + nested: {a: 1, b: 2}, + }, + defaultExpanded: true, + }, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} + +export const Leaf: Story = { + args: {value: "a plain string value", nodeKey: "message"}, + render: (args) => ({ + components: {KsJsonTree}, + setup() { return {args} }, + template: "", + }), +} diff --git a/ui/src/components/executions/Logs.vue b/ui/src/components/executions/Logs.vue index 9208fbb6007..ea9fe880b2f 100644 --- a/ui/src/components/executions/Logs.vue +++ b/ui/src/components/executions/Logs.vue @@ -17,48 +17,36 @@ :levelLabel="t('filter.level_log_executions.label')" @update:level="(value) => setLevelRouteValue({value, direction: 'min'})" /> - - - - - - +
+
+ + {{ logDisplayButtonText }} - - - - + + {{ !raw_view ? t('logs_view.raw') : t('logs_view.compact') }} - - - - - - - - - - - - - - - - +
+
+ + + + + +
+
+ + diff --git a/ui/src/components/logs/LogLevelNavigator.vue b/ui/src/components/logs/LogLevelNavigator.vue index 7b27d1eff77..65bc53c7017 100644 --- a/ui/src/components/logs/LogLevelNavigator.vue +++ b/ui/src/components/logs/LogLevelNavigator.vue @@ -1,10 +1,19 @@ @@ -41,15 +38,36 @@ + + +

{{ t('download_logs_description') }}

+ + +
@@ -101,6 +139,13 @@ import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw" import {useLogsStore} from "../../stores/logs" import useRouteContext from "../../composables/useRouteContext" + import * as Utils from "../../utils/utils" + import {useToast} from "../../utils/toast" + import Download from "vue-material-design-icons/Download.vue" + import ContentCopy from "vue-material-design-icons/ContentCopy.vue" + import LogDisplaySettings from "./LogDisplaySettings.vue" + import LogLevelNavigator from "./LogLevelNavigator.vue" + import {buildValueFilterQuery} from "./logValueFilter" const props = withDefaults(defineProps<{ logLevel?: string; @@ -124,6 +169,7 @@ const route = useRoute() const router = useRouter() const {t} = useI18n() + const toast = useToast() const logsStore = useLogsStore() const logFilter = useLogFilter() const {VALUES} = useValues("logs") @@ -158,7 +204,6 @@ const { effectiveValue: effectiveLogLevel, syncFromAppliedFilters: syncLevelFromAppliedFilters, - setRouteValue: setLevelRouteValue, } = useRouteFilterPolicy({ enabled: () => !props.filters && hasLevelFilterUI.value, explicitValue: () => props.logLevel ? {value: props.logLevel, direction: "min"} : undefined, @@ -182,6 +227,11 @@ ) }, }) + const searchTerm = computed(() => { + const key = Object.keys(route.query).find((k) => k.startsWith("filters[q]")) + return key ? String(route.query[key] ?? "") : "" + }) + const selectedTimeRange = computed(() => { if (route.query.timeRange) { return route.query.timeRange as string @@ -270,6 +320,92 @@ }) } + const downloadOpen = ref(false) + const downloadLevel = ref(undefined) + const downloadTimeRange = ref(undefined) + const downloading = ref(false) + + const openDownload = () => { + downloadLevel.value = effectiveLogLevel.value?.value + downloadTimeRange.value = selectedTimeRange.value ?? undefined + downloadOpen.value = true + } + + const downloadLogs = () => { + const { + page: _p, size: _s, sort: _so, logsPage: _lp, logsSize: _ls, + level: _l, startDate: _sd, endDate: _ed, ...routeFilters + } = route.query + const params: Record = props.filters ? {...props.filters} : {...routeFilters} + + if (isFlowEdit.value) { + params["filters[namespace][EQUALS]"] = routeNamespace.value + params["filters[flowId][EQUALS]"] = flowId.value + } else if (isNamespaceEdit.value) { + params["filters[namespace][EQUALS]"] = routeNamespace.value + } + + Object.keys(params) + .filter((k) => k.startsWith("filters[level]")) + .forEach((k) => delete params[k]) + if (downloadLevel.value) { + params["filters[level][GREATER_THAN_OR_EQUAL_TO]"] = downloadLevel.value + } + + if (downloadTimeRange.value) { + params.startDate = moment() + .subtract(moment.duration(downloadTimeRange.value).as("milliseconds")) + .toISOString(true) + params.endDate = moment().toISOString(true) + } else { + if (startDate.value) params.startDate = startDate.value + if (endDate.value) params.endDate = endDate.value + } + params.sort = "timestamp:desc" + + downloading.value = true + logsStore.downloadLogs(params) + .then(() => (downloadOpen.value = false)) + .finally(() => (downloading.value = false)) + } + + const LEVEL_ORDER = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"] + const serverLevelCounts = ref>({}) + const presentLevels = computed(() => LEVEL_ORDER.filter((level) => (serverLevelCounts.value[level] ?? 0) > 0)) + + let lastCountedKey = "" + const refreshLevelCounts = () => { + if (!loadInit.value || lastCountedKey === filterQueryKey.value) return + const key = filterQueryKey.value + lastCountedKey = key + logsStore.levelCounts(loadQuery({})).then((counts) => { + if (key === filterQueryKey.value) serverLevelCounts.value = counts + }) + } + + const selectLevel = (level: string) => { + const query: Record = {...route.query} + Object.keys(query) + .filter((key) => key.startsWith("filters[level]")) + .forEach((key) => delete query[key]) + query["filters[level][GREATER_THAN_OR_EQUAL_TO]"] = level + query[pageKey] = "1" + router.push({query}) + } + + const onValueFilter = ({field, value, negate}: {field: string; value: string; negate: boolean}) => { + const query = buildValueFilterQuery(route.query, field, value, negate, pageKey) + if (query) router.push({query}) + } + + const copyAllLogs = () => { + const text = (logsStore.logs ?? []) + .map((l: any) => `${(l.level ?? "").padEnd(5)} ${l.timestamp} ${(l.message ?? "").replace(/\s+$/, "")}`) + .join("\n") + Utils.copy(text) + toast.success(t("logs_copied")) + } + const onFilterRouteSync = (filters: AppliedFilter[]) => { if (props.filters || !hasLevelFilterUI.value) { return @@ -291,6 +427,7 @@ } const onLoaded = () => { + refreshLevelCounts() if (!pinToBottom.value) return pinToBottom.value = false const main = document.querySelector("main") @@ -332,10 +469,49 @@