Skip to content

Commit 5a4733c

Browse files
flcarreclaude
andauthored
feat(executions): enriched execution logs (JSON tree, grouping, navigation, display settings) (#16580)
* feat(executions): enriched execution logs — JSON tree, grouping, navigation, display settings Make the execution Logs tab and the global /logs page structure-aware and navigable (BHP request kestra-ee#8305; ships the JSON-in-logs ask #7978). Log line: - Inline collapsible JSON tree for structured lines (heuristic detection, parse-on-expand, raw fallback, --ks-editor-* highlight, no v-html). - Collapse consecutive near-identical lines into a "xN similar" group. - Redesigned line: level rail, metadata pills, message on its own row. - Highlight search matches (tokenized over rendered HTML, entity/ReDoS-safe). Toolbar (both pages, DS 2.0): - Sticky bar with per-icon KsButton surfaces matching the filter bar. - Display-settings popover: density (row height), font size, pretty JSON, expand-by-default, body line-clamp. - Download with a filter modal; copy with a toast. - Click-a-value popover (Filter for / out / Copy / Open page) wired to the log query; click a log's level to filter by it. Level navigation: - Execution: per-level count + jump to next/prev with scroll-into-view. - Global: server-wide per-level counts (visible even when page 1 has none); clicking a level chip filters at-or-above that level. Pure helpers (utils/logs.ts, logValueFilter.ts) covered by 19 unit tests. Closes kestra-io/kestra-ee#8305 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(design-system): address review findings in enriched-logs-v2 - logValueFilter: strip all existing comparators for a field before adding the new one, preventing self-contradictory API queries (EQUALS + NOT_EQUALS coexisting on the same field after toggling filter direction) - TaskRunDetails: clear expandedGroups when props.filter changes (log reindexing from 0 after filter shifts all group keys, leaving stale entries) - TaskRunDetails: replace O(n²) array spreads in log-index accumulators with push() calls - Logs.vue / LogsWrapper.vue: use :tooltip prop on icon-only KsButtons instead of wrapping in KsTooltip (DS convention) - LogLine.vue: replace hardcoded px values with rem equivalents Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(design-system): add Storybook stories for enriched-logs new components Cover all 4 new components introduced by the enriched-execution-logs-v2 PR: JsonTree (6 stories), LogValueActions (4 stories, incl. click-to-open play test), LogDisplaySettings (2 stories, incl. open play test), LogLevelNavigator (5 stories). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(executions): resolve type errors in enriched logs after develop merge - Annotate LogLine messageStyle computed as CSSProperties so the -webkit-box-orient value type-checks against BoxOrient - Drop unused KsIconButton import in Logs.vue - Drop unused setLevelRouteValue destructure in LogsWrapper.vue (level routing is handled by selectLevel) - Make level optional in formatLogsAsText's DownloadableLog type to match its runtime guard and the missing-field test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(executions): align LogLine router-link story with click-a-value redesign Metadata values now render via LogValueActions as popover triggers (role="button"), not router-links. The only role="link" elements are the execution and flow links built from the message, so assert 2 links at indices 0 and 1 instead of 5 at indices 3 and 4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(executions): use double quotes in logs helpers spec (eslint quotes) Addresses reviewdog eslint quotes warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(executions): rename reduce accumulator to avoid no-shadow in TaskRunDetails The level-index reduce accumulator reused the outer computed's name (currentTaskRunsLogIndicesByLevel), tripping oxlint no-shadow. Rename the local accumulator to indicesByLevel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(executions): address PR review — move JsonTree to design system, add as const - Move JsonTree.vue → KsJsonTree.vue in design-system Data components - Register KsJsonTree globally in index.ts (import, components map, export, GlobalComponents) - Update LogLine.vue to use KsJsonTree (globally registered, no local import) - Move Storybook story to design-system tests/storybook/Data/KsJsonTree.stories.ts - Delete old story from ui/tests - Add as const to DENSITY_PADDING in useLogDisplay.ts - Add as const to FILTERABLE_LOG_FIELDS in logValueFilter.ts; cast field key to keyof typeof Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed9d588 commit 5a4733c

23 files changed

Lines changed: 1962 additions & 342 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<template>
2+
<div class="json-node">
3+
<template v-if="isBranch">
4+
<button
5+
ref="toggleEl"
6+
type="button"
7+
class="toggle"
8+
:aria-expanded="expanded"
9+
:aria-label="expanded ? t('collapse') : t('expand')"
10+
@click="expanded = !expanded"
11+
>
12+
<KsIcon size="s" class="chevron" :class="{collapsed: !expanded}"><ChevronDown /></KsIcon>
13+
<span v-if="nodeKey !== undefined" class="key">{{ nodeKey }}</span>
14+
<span class="punct">{{ open }}</span>
15+
<span v-if="!expanded" class="preview">
16+
<template v-for="(entry, i) in previewEntries" :key="i">
17+
<span v-if="!isArray" class="key">{{ entry.key }}</span><span v-if="!isArray" class="punct">: </span><span class="value" :class="entry.cls">{{ entry.display }}</span><span v-if="i < previewEntries.length - 1" class="punct">, </span>
18+
</template>
19+
<span v-if="previewMore" class="punct">{{ previewEntries.length ? ", " : "" }}+{{ previewMore }}</span>
20+
<span class="punct">&nbsp;{{ close }}</span>
21+
</span>
22+
</button>
23+
<div v-if="expanded" class="children">
24+
<KsJsonTree
25+
v-for="entry in entries"
26+
:key="entry.key"
27+
:value="entry.value"
28+
:nodeKey="entry.key"
29+
:depth="depth + 1"
30+
/>
31+
</div>
32+
<span v-if="expanded" class="punct close">{{ close }}</span>
33+
</template>
34+
35+
<div v-else class="leaf">
36+
<span v-if="nodeKey !== undefined" class="key">{{ nodeKey }}</span>
37+
<span v-if="nodeKey !== undefined" class="punct">:</span>
38+
<span :class="['value', valueClass]">{{ displayValue }}</span>
39+
</div>
40+
</div>
41+
</template>
42+
43+
<script setup lang="ts">
44+
import {computed, ref, watch, onMounted, onBeforeUnmount} from "vue"
45+
import {useI18n} from "vue-i18n"
46+
import ChevronDown from "vue-material-design-icons/ChevronDown.vue"
47+
48+
const props = withDefaults(defineProps<{
49+
value: unknown,
50+
nodeKey?: string | number,
51+
depth?: number,
52+
defaultExpanded?: boolean,
53+
}>(), {depth: 0})
54+
55+
const {t} = useI18n()
56+
57+
const expanded = ref(props.defaultExpanded ?? props.depth < 1)
58+
59+
watch(() => props.defaultExpanded, (value) => {
60+
if (value !== undefined) expanded.value = value
61+
})
62+
63+
const isArray = computed(() => Array.isArray(props.value))
64+
const isBranch = computed(() => props.value !== null && typeof props.value === "object")
65+
66+
const entries = computed(() => {
67+
if (!isBranch.value) {
68+
return []
69+
}
70+
if (isArray.value) {
71+
return (props.value as unknown[]).map((value, index) => ({key: index, value}))
72+
}
73+
return Object.entries(props.value as Record<string, unknown>).map(([key, value]) => ({key, value}))
74+
})
75+
76+
const open = computed(() => (isArray.value ? "[" : "{"))
77+
const close = computed(() => (isArray.value ? "]" : "}"))
78+
79+
function shorten(value: unknown): string {
80+
if (value === null) return "null"
81+
if (Array.isArray(value)) return "[…]"
82+
if (typeof value === "object") return "{…}"
83+
if (typeof value === "string") return `"${value.length > 24 ? value.slice(0, 24) + "" : value}"`
84+
return String(value)
85+
}
86+
87+
function tokenClass(value: unknown): string {
88+
if (typeof value === "string") return "is-string"
89+
if (typeof value === "number") return "is-number"
90+
if (value !== null && typeof value === "object") return "is-branch"
91+
return "is-literal"
92+
}
93+
94+
const toggleEl = ref<HTMLElement>()
95+
const availableChars = ref(48)
96+
let resizeObserver: ResizeObserver | undefined
97+
98+
onMounted(() => {
99+
const container = toggleEl.value?.parentElement
100+
if (props.depth !== 0 || !container) {
101+
return
102+
}
103+
const charPx = (parseFloat(getComputedStyle(toggleEl.value!).fontSize) || 12) * 0.6
104+
resizeObserver = new ResizeObserver(([entry]) => {
105+
availableChars.value = Math.max(12, Math.floor(entry.contentRect.width / charPx) - 8)
106+
})
107+
resizeObserver.observe(container)
108+
})
109+
110+
onBeforeUnmount(() => resizeObserver?.disconnect())
111+
112+
const previewLimit = computed(() => {
113+
if (props.depth !== 0) {
114+
return 3
115+
}
116+
let used = 0
117+
let count = 0
118+
for (const entry of entries.value) {
119+
const len = (isArray.value ? 0 : String(entry.key).length + 2) + shorten(entry.value).length + 2
120+
if (count > 0 && used + len > availableChars.value) {
121+
break
122+
}
123+
used += len
124+
count++
125+
}
126+
return Math.max(1, count)
127+
})
128+
129+
const previewEntries = computed(() =>
130+
entries.value.slice(0, previewLimit.value).map(e => ({
131+
key: e.key,
132+
display: shorten(e.value),
133+
cls: tokenClass(e.value),
134+
})),
135+
)
136+
137+
const previewMore = computed(() => Math.max(0, entries.value.length - previewLimit.value))
138+
139+
const valueClass = computed(() => {
140+
const v = props.value
141+
if (typeof v === "string") return "is-string"
142+
if (typeof v === "number") return "is-number"
143+
return "is-literal"
144+
})
145+
146+
const displayValue = computed(() => {
147+
const v = props.value
148+
if (v === null) return "null"
149+
if (typeof v === "string") return `"${v}"`
150+
return String(v)
151+
})
152+
</script>
153+
154+
<style scoped lang="scss">
155+
.json-node {
156+
font-family: var(--ks-font-family-mono);
157+
line-height: 1.7;
158+
}
159+
160+
.toggle {
161+
background: none;
162+
border: none;
163+
padding: 0;
164+
cursor: pointer;
165+
color: inherit;
166+
font: inherit;
167+
}
168+
169+
.toggle {
170+
display: inline-flex;
171+
align-items: center;
172+
gap: var(--ks-spacing-1);
173+
line-height: 1;
174+
max-width: 100%;
175+
overflow: hidden;
176+
white-space: nowrap;
177+
border-radius: var(--ks-radius-xs);
178+
179+
&:hover {
180+
color: var(--ks-text-primary);
181+
}
182+
183+
:deep(.material-design-icon) {
184+
display: inline-flex;
185+
align-items: center;
186+
line-height: 0;
187+
}
188+
}
189+
190+
.chevron {
191+
display: inline-flex;
192+
align-items: center;
193+
justify-content: center;
194+
width: 1.3em;
195+
height: 1.3em;
196+
transition: transform 0.15s ease;
197+
198+
:deep(svg) {
199+
width: 100%;
200+
height: 100%;
201+
display: block;
202+
}
203+
204+
&.collapsed {
205+
transform: rotate(-90deg);
206+
}
207+
}
208+
209+
.children {
210+
padding-left: var(--ks-spacing-3);
211+
border-left: 1px solid var(--ks-border-subtle);
212+
margin-left: var(--ks-spacing-2);
213+
}
214+
215+
.leaf {
216+
display: inline-flex;
217+
align-items: center;
218+
gap: var(--ks-spacing-1);
219+
border-radius: var(--ks-radius-xs);
220+
padding-inline: var(--ks-spacing-1);
221+
margin-inline: calc(-1 * var(--ks-spacing-1));
222+
223+
&:hover {
224+
background: var(--ks-bg-hover);
225+
}
226+
}
227+
228+
.key {
229+
color: var(--ks-editor-property);
230+
}
231+
232+
.punct {
233+
color: var(--ks-editor-punctuation);
234+
}
235+
236+
.preview {
237+
opacity: 0.85;
238+
}
239+
240+
.value {
241+
&.is-string {
242+
color: var(--ks-editor-value);
243+
}
244+
245+
&.is-number, &.is-literal {
246+
color: var(--ks-editor-pabble);
247+
}
248+
249+
&.is-branch {
250+
color: var(--ks-editor-punctuation);
251+
}
252+
}
253+
</style>

ui/packages/design-system/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import KsTooltip from "./components/Feedback/KsTooltip.vue"
103103
import KsTopNavBar from "./components/Navigation/KsTopNavBar/KsTopNavBar.vue"
104104
import KsTaskIcon from "./components/Kestra/KsTaskIcon.vue"
105105
import KsTree from "./components/Data/KsTree.vue"
106+
import KsJsonTree from "./components/Data/KsJsonTree.vue"
106107
import KsUpload from "./components/Form/KsUpload.vue"
107108
import KsSubMenu from "./components/Navigation/KsMenu/KsSubMenu.vue"
108109
import KsPageHeader from "./components/Data/KsPageHeader.vue"
@@ -295,6 +296,7 @@ const components: Record<string, Component> = {
295296
KsTooltip,
296297
KsTopNavBar,
297298
KsTree,
299+
KsJsonTree,
298300
KsUpload,
299301
KsSubMenu,
300302
KsPageHeader,
@@ -402,6 +404,7 @@ export {
402404
KsTooltip,
403405
KsTopNavBar,
404406
KsTree,
407+
KsJsonTree,
405408
KsUpload,
406409
KsSubMenu,
407410
KsPageHeader,
@@ -528,6 +531,7 @@ declare module "vue" {
528531
KsTooltip: typeof KsTooltip
529532
KsTopNavBar: typeof KsTopNavBar
530533
KsTree: typeof KsTree
534+
KsJsonTree: typeof KsJsonTree
531535
KsUpload: typeof KsUpload
532536
}
533537
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type {Meta, StoryObj} from "@storybook/vue3-vite"
2+
import KsJsonTree from "../../../src/components/Data/KsJsonTree.vue"
3+
4+
const NESTED_OBJECT = {
5+
event: "deploy.completed",
6+
status: "success",
7+
duration: 1840,
8+
timestamp: "2026-06-04T13:33:56.680Z",
9+
meta: {
10+
namespace: "company.data",
11+
flowId: "etl-pipeline",
12+
executionId: "4Q9z27FJ26FRIhdv037HtF",
13+
},
14+
tags: ["production", "scheduled"],
15+
error: null,
16+
retried: false,
17+
}
18+
19+
const meta: Meta<typeof KsJsonTree> = {
20+
title: "Data/KsJsonTree",
21+
component: KsJsonTree,
22+
tags: ["autodocs"],
23+
argTypes: {
24+
defaultExpanded: {control: "boolean"},
25+
depth: {control: "number"},
26+
},
27+
}
28+
29+
export default meta
30+
type Story = StoryObj<typeof meta>
31+
32+
export const Object_: Story = {
33+
name: "Object",
34+
args: {value: NESTED_OBJECT, defaultExpanded: true},
35+
render: (args) => ({
36+
components: {KsJsonTree},
37+
setup() { return {args} },
38+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
39+
}),
40+
}
41+
42+
export const Array_: Story = {
43+
name: "Array",
44+
args: {value: ["production", "scheduled", "data-team", "priority-high"], defaultExpanded: true},
45+
render: (args) => ({
46+
components: {KsJsonTree},
47+
setup() { return {args} },
48+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
49+
}),
50+
}
51+
52+
export const Collapsed: Story = {
53+
args: {value: NESTED_OBJECT, defaultExpanded: false},
54+
render: (args) => ({
55+
components: {KsJsonTree},
56+
setup() { return {args} },
57+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
58+
}),
59+
}
60+
61+
export const DeeplyNested: Story = {
62+
args: {
63+
value: {
64+
level1: {
65+
level2: {
66+
level3: {level4: {value: "deep"}, array: [1, 2, 3]},
67+
sibling: true,
68+
},
69+
count: 42,
70+
},
71+
topLevel: "string",
72+
},
73+
defaultExpanded: true,
74+
},
75+
render: (args) => ({
76+
components: {KsJsonTree},
77+
setup() { return {args} },
78+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
79+
}),
80+
}
81+
82+
export const MixedTypes: Story = {
83+
args: {
84+
value: {
85+
string: "hello world",
86+
number: 3.14,
87+
boolean: true,
88+
null_: null,
89+
array: [1, "two", false, null],
90+
nested: {a: 1, b: 2},
91+
},
92+
defaultExpanded: true,
93+
},
94+
render: (args) => ({
95+
components: {KsJsonTree},
96+
setup() { return {args} },
97+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
98+
}),
99+
}
100+
101+
export const Leaf: Story = {
102+
args: {value: "a plain string value", nodeKey: "message"},
103+
render: (args) => ({
104+
components: {KsJsonTree},
105+
setup() { return {args} },
106+
template: "<ks-card style=\"font-size:13px;padding:1rem\"><KsJsonTree v-bind=\"args\" /></ks-card>",
107+
}),
108+
}

0 commit comments

Comments
 (0)