From 9d266c564e2175d01555ff921ad65f32f8194c6f Mon Sep 17 00:00:00 2001 From: stev Date: Wed, 16 Sep 2026 17:31:33 +0800 Subject: [PATCH 1/5] [Improvement-18647][UI] Improve workflow Gantt inspection Keep short tasks visible, add live task summaries, and separate log access from selectable task names. --- .../src/locales/en_US/project.ts | 38 ++ .../src/locales/zh_CN/project.ts | 35 ++ .../instance/gantt/components/gantt-chart.tsx | 434 ++++++++++-------- .../workflow/instance/gantt/index.module.scss | 388 ++++++++++++++++ .../workflow/instance/gantt/index.tsx | 237 ++++++++-- .../projects/workflow/instance/gantt/model.ts | 209 +++++++++ .../projects/workflow/instance/gantt/type.ts | 63 ++- .../workflow/instance/gantt/use-gantt.ts | 122 +++-- .../workflow/instance/gantt/use-logs.ts | 75 +++ .../tests/gantt-model.test.cjs | 212 +++++++++ 10 files changed, 1530 insertions(+), 283 deletions(-) create mode 100644 dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss create mode 100644 dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts create mode 100644 dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-logs.ts create mode 100644 dolphinscheduler-ui/tests/gantt-model.test.cjs diff --git a/dolphinscheduler-ui/src/locales/en_US/project.ts b/dolphinscheduler-ui/src/locales/en_US/project.ts index 8084f192d7d2..bb38a3018cba 100644 --- a/dolphinscheduler-ui/src/locales/en_US/project.ts +++ b/dolphinscheduler-ui/src/locales/en_US/project.ts @@ -44,6 +44,44 @@ export default { total_items: 'Total Item is' }, workflow: { + gantt_help: 'Timeline help', + gantt_timeline: 'Task execution timeline', + gantt_instance: 'Workflow instance', + gantt_live: 'Running', + gantt_finished: 'Finished', + gantt_total: 'Total tasks', + gantt_submitted: 'Submitted', + gantt_pending: 'Not submitted', + gantt_waiting: 'Queued / waiting', + gantt_running: 'Running', + gantt_success: 'Succeeded', + gantt_failed: 'Failed', + gantt_stopped: 'Paused / stopped', + gantt_auto_refresh: 'Refresh every 5s while running', + gantt_updated: 'Updated', + gantt_duration: 'Duration', + gantt_elapsed: 'Workflow elapsed', + gantt_task: 'Task / state', + gantt_duration_share: 'Duration / share', + gantt_start: 'Start', + gantt_end: 'End', + gantt_share: 'Share of workflow elapsed', + gantt_axis_note: + 'Time is relative to workflow start. Select task names to copy; use the log icon to view logs.', + gantt_share_note: + 'Share = task duration / workflow elapsed. Parallel tasks may sum to more than 100%.', + gantt_no_log: 'No log available yet', + gantt_time_unavailable: 'Execution time unavailable', + gantt_log_limit: + 'Showing the first 20,000 lines. Download for the complete log.', + gantt_log_error: 'Unable to load logs. Click refresh to retry.', + gantt_stale: + 'Refresh failed. Showing the last successful snapshot; please retry.', + gantt_load_error: + 'Unable to load workflow. Check the connection and permissions, then retry.', + gantt_empty: 'This workflow instance has no tasks.', + gantt_loading: 'Loading task execution…', + on_line: 'Online', test: 'Test', operating_environment: 'Operating Environment', diff --git a/dolphinscheduler-ui/src/locales/zh_CN/project.ts b/dolphinscheduler-ui/src/locales/zh_CN/project.ts index 34901950bdad..1a95e71c27c6 100644 --- a/dolphinscheduler-ui/src/locales/zh_CN/project.ts +++ b/dolphinscheduler-ui/src/locales/zh_CN/project.ts @@ -44,6 +44,41 @@ export default { total_items: '总条目为' }, workflow: { + gantt_help: '时间轴说明', + gantt_timeline: '任务执行时间轴', + gantt_instance: '工作流实例', + gantt_live: '运行中', + gantt_finished: '已结束', + gantt_total: '任务总数', + gantt_submitted: '累计已提交', + gantt_pending: '待提交', + gantt_waiting: '排队 / 等待', + gantt_running: '执行中', + gantt_success: '成功', + gantt_failed: '失败', + gantt_stopped: '暂停 / 停止', + gantt_auto_refresh: '运行中每 5 秒刷新', + gantt_updated: '更新于', + gantt_duration: '耗时', + gantt_elapsed: '工作流耗时', + gantt_task: '任务名称 / 状态', + gantt_duration_share: '耗时 / 占比', + gantt_start: '开始', + gantt_end: '结束', + gantt_share: '占工作流耗时', + gantt_axis_note: + '从工作流开始计时;任务名称可复制,点击旁边的日志图标查看日志。', + gantt_share_note: + '占比 = 任务耗时 / 工作流耗时,并行任务占比之和可能超过 100%。', + gantt_no_log: '暂无日志', + gantt_time_unavailable: '暂无完整执行时间', + gantt_log_limit: '已展示前 20000 行,请下载查看完整日志。', + gantt_log_error: '日志加载失败,请点击刷新重试。', + gantt_stale: '刷新失败,当前展示上次成功获取的数据,请重试。', + gantt_load_error: '无法加载工作流实例,请检查服务连接和访问权限后重试。', + gantt_empty: '该工作流实例没有任务。', + gantt_loading: '正在加载任务执行情况…', + on_line: '上线', test: '测试', operating_environment: '运行环境', diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx index 6a7a245d04d7..6582654253e8 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx @@ -15,208 +15,252 @@ * limitations under the License. */ -import _ from 'lodash' -import { defineComponent, ref, PropType } from 'vue' -import * as echarts from 'echarts' -import type { Ref } from 'vue' +import { computed, defineComponent, PropType, ref } from 'vue' +import { useResizeObserver } from '@vueuse/core' import { useI18n } from 'vue-i18n' -import initChart from '@/components/chart' +import { NButton, NIcon, NTooltip, useThemeVars } from 'naive-ui' +import { FileTextOutlined, InfoCircleOutlined } from '@vicons/antd' import { tasksState } from '@/common/common' -import type { ISeriesData, ITaskState } from '../type' +import type { ITaskState } from '@/common/types' +import { useTimezoneStore } from '@/store/timezone/timezone' +import { formatDuration } from '../model' +import type { GanttModel } from '../model' +import type { GanttRow } from '../type' +import styles from '../index.module.scss' -const props = { - height: { - type: [String, Number] as PropType, - default: window.innerHeight - 174 - }, - width: { - type: [String, Number] as PropType, - default: '100%' - }, - seriesData: { - type: Array as PropType>, - default: () => [] - }, - taskList: { - type: Array as PropType>, - default: [] - } -} - -const GanttChart = defineComponent({ +export default defineComponent({ name: 'GanttChart', - props, - setup(props) { - const graphChartRef: Ref = ref(null) + props: { model: { type: Object as PropType, required: true } }, + emits: { viewLog: (ignoredRow: GanttRow) => true }, + setup(props, { emit }) { const { t } = useI18n() - - const state = tasksState(t) - - const data: ISeriesData = {} - Object.keys(state).forEach((key) => (data[key] = [])) - const series = Object.keys(state).map((key) => ({ - id: key, - type: 'custom', - name: state[key as ITaskState].desc, - renderItem: renderItem, - itemStyle: { - opacity: 0.8, - color: state[key as ITaskState].color, - color0: state[key as ITaskState].color - }, - encode: { - x: [1, 2], - y: 0 - }, - data: data[key] - })) - - // format series data - let minTime = Number.MAX_VALUE - let maxTime = 0 - props.seriesData.forEach(function (task, index) { - const start = Math.floor(task.startDate[0] / 1000) * 1000 - const end = Math.floor(task.endDate[0] / 1000) * 1000 - minTime = minTime < start ? minTime : start - maxTime = maxTime > end ? maxTime : end - data[task.status].push({ - name: task.taskName, - value: [index, start, end, end - start], - itemStyle: { - color: state[task.status as ITaskState].color - } - }) + const timezone = useTimezoneStore() + const theme = useThemeVars() + const axis = ref() + const width = ref(600) + useResizeObserver(axis, (entries) => { + width.value = entries[0].contentRect.width }) - - // customer render - function renderItem(params: any, api: any) { - const taskIndex = api.value(0) - const start = api.coord([api.value(1), taskIndex]) - const end = api.coord([api.value(2), taskIndex]) - const height = api.size([0, 1])[1] * 0.6 - const rectShape = echarts.graphic.clipRectByRect( - { - x: start[0], - y: start[1] - height / 2, - width: _.max([end[0] - start[0], 1]) || 1, - height: height - }, - { - x: params.coordSys.x, - y: params.coordSys.y, - width: params.coordSys.width, - height: params.coordSys.height - } - ) - return ( - rectShape && { - type: 'rect', - transition: ['shape'], - shape: rectShape, - style: api.style() - } - ) - } - - const option = { - title: { - text: t('project.workflow.task_state'), - textStyle: { - fontWeight: 'normal', - fontSize: 14 - }, - left: 50 - }, - tooltip: { - formatter: function (params: any) { - const taskName = params.data.name - const data = props.seriesData.filter( - (item) => item.taskName === taskName - ) - let str = `taskName : ${taskName}
` - str += `status : ${state[data[0].status as ITaskState].desc} (${ - data[0].status - })
` - str += `startTime : ${data[0].isoStart}
` - str += `endTime : ${data[0].isoEnd}
` - str += `duration : ${data[0].duration}
` - return str - } - }, - legend: { - left: 150, - padding: [5, 5, 5, 5] - }, - dataZoom: [ - { - type: 'slider', - xAxisIndex: 0, - filterMode: 'weakFilter', - height: 20, - bottom: 0, - start: 0, - end: 100, - handleSize: '80%', - showDetail: false, - top: '85%' - }, - { - type: 'inside', - filterMode: 'weakFilter' - } - ], - grid: { - height: '70%', - top: 80 - }, - xAxis: { - type: 'time', - min: minTime, - max: maxTime - minTime > 5000 ? maxTime + 1000 : minTime + 5000, - position: 'top', - axisTick: { show: true }, - splitLine: { show: false }, - axisLabel: { - formatter: '{HH}:{mm}:{ss}', - showMinLabel: true, - showMaxLabel: true, - hideOverlap: true - } - }, - yAxis: { - axisTick: { show: false }, - splitLine: { show: false }, - axisLine: { show: false }, - data: props.taskList.map((item: string) => { - return { - value: item, - textStyle: { - width: 130, - overflow: 'truncate' - } - } - }) - }, - series: series + const ticks = computed(() => { + const count = Math.max(1, Math.floor(width.value / 110)) + return Array.from({ length: count + 1 }, (_, index) => index / count) + }) + const state = (row: GanttRow) => + tasksState(t)[row.state as ITaskState]?.desc || + (row.state === 'NOT_SUBMITTED' + ? t('project.workflow.gantt_pending') + : row.state) + const color = (row: GanttRow) => { + if (['SUCCESS', 'FORCED_SUCCESS'].includes(row.state)) + return theme.value.successColor + if (row.state === 'FAILURE') return theme.value.errorColor + if (row.state === 'RUNNING_EXECUTION') return theme.value.infoColor + if (['KILL', 'STOP', 'PAUSE'].includes(row.state)) + return theme.value.warningColor + return theme.value.textColor3 } - - initChart(graphChartRef, option) - - return { graphChartRef } - }, - render() { - const { height, width } = this - - return ( -
+ const date = (time: number | null) => + time === null + ? '—' + : new Date(time).toLocaleString(undefined, { + timeZone: timezone.getTimezone, + hour12: false + }) + const details = (row: GanttRow) => ( +
+ {row.name} +
{state(row)}
+
+ {t('project.workflow.gantt_start')}: {date(row.start)} +
+
+ {t('project.workflow.gantt_end')}:{' '} + {row.state === 'RUNNING_EXECUTION' + ? t('project.workflow.executing') + : date(row.end)} +
+
+ {t('project.workflow.gantt_duration')}: {formatDuration(row.duration)} +
+
+ {t('project.workflow.gantt_share')}: {row.percent.toFixed(2)}% +
+
+ ) + return () => ( +
+
+
+ {t('project.workflow.gantt_timeline')} + + {{ + trigger: () => ( + + + + + + ), + default: () => ( +
+
{t('project.workflow.gantt_axis_note')}
+
{t('project.workflow.gantt_share_note')}
+
+ ) + }} +
+
+ + {t('project.workflow.gantt_elapsed')}{' '} + {formatDuration(props.model.duration)} + +
+
+
+
+ {t('project.workflow.gantt_task')} + {t('project.workflow.gantt_duration_share')} +
+
+ {ticks.value.map((fraction, index) => ( + + + {formatDuration(fraction * props.model.axisDuration)} + + {Math.round(fraction * 100)}% + + ))} +
+ {props.model.rows.map((row, index) => ( +
+
+ + {String(index + 1).padStart(2, '0')} + +
+
+ + {row.name} + + + {{ + trigger: () => ( + emit('viewLog', row)} + > + + + + + ), + default: () => + row.logAvailable + ? t('project.task.view_log') + : t('project.workflow.gantt_no_log') + }} + +
+ + + {state(row)} + {row.taskType && ` · ${row.taskType}`} + +
+
+ {formatDuration(row.duration)} + + {row.duration === null + ? '—' + : `${row.percent.toFixed(2)}%`} + +
+
+
+ {row.start !== null && row.duration !== null ? ( + + {{ + trigger: () => ( + + {(row.duration! / props.model.axisDuration) * + width.value > + 95 && {formatDuration(row.duration)}} + + ), + default: () => details(row) + }} + + ) : ( + + {row.start === null + ? state(row) + : t('project.workflow.gantt_time_unavailable')} + + )} +
+
+ ))} +
+
+
) } }) - -export default GanttChart diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss new file mode 100644 index 000000000000..1ccc8c227b7d --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss @@ -0,0 +1,388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.page { + padding: 6px 8px; + color: var(--gantt-text); + container-type: inline-size; + min-width: 0; +} +.heading, +.toolbar, +.controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} +.heading { + margin-bottom: 12px; +} +.heading h3 { + font-size: 15px; + font-weight: 600; + margin: 0 0 2px; +} +.muted { + color: var(--gantt-muted); + font-size: 11px; +} +.autoRefresh { + display: flex; + gap: 6px; + align-items: center; + font-size: 11px; +} +.stats { + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 12px; + background: color-mix(in srgb, var(--gantt-hover) 55%, var(--gantt-surface)); + border-radius: 6px; +} +.statOverview { + display: flex; + gap: 22px; + padding-right: 16px; + border-right: 1px solid var(--gantt-border); + flex-shrink: 0; +} +.statPrimary { + display: flex; + flex-direction: column; + gap: 3px; +} +.statPrimary > span { + font-size: 11px; + color: var(--gantt-muted); + white-space: nowrap; +} +.statPrimary strong { + color: var(--stat-color); + font: 600 24px/1.1 ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-numeric: tabular-nums; +} +.statStates { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px 10px; + flex: 1; + min-width: 0; +} +.stat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 3px 7px; + border-radius: 4px; + background: color-mix(in srgb, var(--stat-color) 9%, transparent); +} +.stat > span { + color: var(--gantt-text); + font-size: 11px; + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.stat i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--stat-color); + flex-shrink: 0; +} +.stat strong { + color: var(--stat-color); + font: 600 15px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-numeric: tabular-nums; +} +.statEmpty { + background: transparent; +} +.statEmpty > span, +.statEmpty strong { + color: var(--gantt-muted); +} +@container (max-width: 1000px) { + .statStates { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@container (max-width: 480px) { + .stats { + gap: 8px; + padding: 8px; + } + .statOverview { + gap: 12px; + padding-right: 8px; + } + .statStates { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + } +} +.metadata { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--gantt-muted); + font-size: 11px; +} +.updatedAt { + border-left: 1px solid var(--gantt-border); + padding-left: 8px; + font-variant-numeric: tabular-nums; +} +.timelineTitle { + display: flex; + gap: 7px; + align-items: center; +} +.timelineTitle :global(.n-button) { + color: var(--gantt-muted); +} +.elapsed { + color: var(--gantt-muted); + font-size: 11px; +} +.elapsed strong { + margin-left: 5px; + color: var(--gantt-text); + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.help { + max-width: 320px; + font-size: 12px; + line-height: 1.8; +} +.chart { + --gantt-min-bar-width: 5px; + --gantt-guide: color-mix(in srgb, var(--gantt-border) 35%, transparent); + border: 1px solid var(--gantt-border); + border-radius: 4px; + overflow: hidden; + min-width: 0; +} +.toolbar { + padding: 8px 10px; + border-bottom: 1px solid var(--gantt-border); + font-size: 12px; +} +.controls { + justify-content: flex-end; + gap: 8px; +} +.viewport { + overflow-x: hidden; + overflow-y: auto; + max-height: max(220px, calc(100vh - 300px)); + position: relative; +} +.grid { + display: grid; + position: relative; + width: 100%; + grid-template-columns: clamp(160px, 40%, 280px) minmax(0, 1fr); +} +.row { + display: contents; +} +.nameCell { + z-index: 2; + background: var(--gantt-surface); + display: flex; + align-items: center; + gap: 7px; + height: 42px; + padding: 0 10px; + min-width: 0; + box-sizing: border-box; + border-right: 1px solid var(--gantt-guide); +} +.corner { + border-bottom: 1px solid var(--gantt-border); + position: sticky; + top: 0; + z-index: 4; + height: 40px; + font-size: 11px; + color: var(--gantt-muted); + justify-content: space-between; + background: var(--gantt-hover); +} +.axis { + position: sticky; + top: 0; + height: 40px; + z-index: 3; + background: var(--gantt-surface); + border-bottom: 1px solid var(--gantt-border); + box-sizing: border-box; + min-width: 0; +} +.tick { + position: absolute; + top: 5px; + display: flex; + flex-direction: column; + gap: 2px; + align-items: center; + font: 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--gantt-text); + white-space: nowrap; + padding: 0 5px; +} +.tick small { + color: var(--gantt-muted); + font-size: 10px; +} +.index { + color: var(--gantt-muted); + font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; +} +.taskInfo { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; +} +.taskTitle { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} +.taskName { + color: var(--gantt-text); + cursor: text; + user-select: text; + -webkit-user-select: text; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.logButton { + flex-shrink: 0; + color: var(--gantt-primary); +} +.taskState { + font-size: 10px; + color: var(--gantt-muted); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.taskState i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + margin-right: 4px; +} +.metrics { + text-align: right; + display: flex; + flex-direction: column; + gap: 2px; + font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; +} +.metrics small { + color: var(--gantt-muted); + font-size: 10px; +} +.lane { + position: relative; + height: 42px; + min-width: 0; + overflow: hidden; + box-sizing: border-box; + background-image: linear-gradient( + to right, + var(--gantt-guide) 1px, + transparent 1px + ); +} +.row:hover .lane, +.row:hover .nameCell { + background-color: var(--gantt-hover); +} +.bar { + position: absolute; + top: 12px; + height: 18px; + line-height: 18px; + text-align: center; + box-sizing: border-box; + min-width: var(--gantt-min-bar-width); + max-width: 100%; + border-radius: 3px; + color: white; + font-size: 10px; + overflow: hidden; +} +.running { + background-image: repeating-linear-gradient( + 120deg, + transparent, + transparent 8px, + rgba(255, 255, 255, 0.18) 8px, + rgba(255, 255, 255, 0.18) 16px + ); +} +.noBar { + position: absolute; + left: 10px; + top: 12px; + font-size: 11px; + color: var(--gantt-muted); +} +.tooltip { + font-size: 12px; + line-height: 1.8; +} +.empty { + padding: 40px 12px; + text-align: center; + color: var(--gantt-muted); +} +.alert { + margin-bottom: 10px; +} +.viewport:focus-visible { + outline: 2px solid var(--gantt-primary); + outline-offset: -2px; +} +@container (max-width: 560px) { + .nameCell { + padding: 0 6px; + gap: 4px; + } + .index { + display: none; + } +} diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx index 9f68e37c10b9..6df521a9d2b9 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx @@ -15,59 +15,210 @@ * limitations under the License. */ -import { defineComponent, onMounted, toRefs, watch } from 'vue' +import { defineComponent } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' +import { + NAlert, + NButton, + NEmpty, + NSpin, + NSwitch, + NTag, + useThemeVars +} from 'naive-ui' import Card from '@/components/card' +import LogModal from '@/components/log-modal' import GanttChart from './components/gantt-chart' import { useGantt } from './use-gantt' +import { useGanttLogs } from './use-logs' +import { isWorkflowActive } from './model' +import styles from './index.module.scss' -const workflowRelation = defineComponent({ - name: 'workflow-relation', +export default defineComponent({ + name: 'WorkflowInstanceGantt', setup() { - const { t, locale } = useI18n() + const { t } = useI18n() const route = useRoute() - - const { variables, getGantt } = useGantt() - - const id = Number(route.params.id) - const code = Number(route.params.projectCode) - - const handleResetDate = () => { - variables.seriesData = [] - variables.taskList = [] - getGantt(id, code) - } - - onMounted(() => { - getGantt(id, code) - }) - - watch( - () => [locale.value], - () => { - handleResetDate() - } - ) - - return { t, ...toRefs(variables) } - }, - render() { - const { t } = this - return ( + const theme = useThemeVars() + const { workflow, model, loading, error, autoRefresh, updatedAt, refresh } = + useGantt(() => [ + Number(route.params.id), + Number(route.params.projectCode) + ]) + const logs = useGanttLogs(t) + const statistics = [ + 'total', + 'submitted', + 'pending', + 'waiting', + 'running', + 'success', + 'failed', + 'stopped' + ] as const + const statColor = (key: string) => + ({ + total: theme.value.textColor1, + submitted: '#7c6bc4', + pending: theme.value.textColor3, + waiting: theme.value.warningColor, + running: theme.value.infoColor, + success: theme.value.successColor, + failed: theme.value.errorColor, + stopped: '#b7815b' + }[key]) + return () => ( - {{ - default: () => - this.seriesData.length > 0 && ( - - ) - }} +
+
+
+

+ {workflow.value?.name || t('project.workflow.gantt_timeline')} +

+
+ + {t('project.workflow.gantt_instance')} #{route.params.id} + + {updatedAt.value && ( + + {t('project.workflow.gantt_updated')}{' '} + {new Date(updatedAt.value).toLocaleTimeString()} + + )} +
+
+
+ {workflow.value && ( + + {t( + `project.workflow.gantt_${ + isWorkflowActive(workflow.value.state) + ? 'live' + : 'finished' + }` + )} + + )} + + refresh()} + > + {t('project.task.refresh')} + +
+
+ {error.value && ( + + {t( + workflow.value + ? 'project.workflow.gantt_stale' + : 'project.workflow.gantt_load_error' + )} + + )} + + {workflow.value && ( + <> +
+
+ {statistics.slice(0, 2).map((key) => ( +
+ {t(`project.workflow.gantt_${key}`)} + {model.value.stats[key]} +
+ ))} +
+
+ {statistics.slice(2).map((key) => ( +
+ + + {t(`project.workflow.gantt_${key}`)} + + {model.value.stats[key]} +
+ ))} +
+
+ {model.value.rows.length ? ( + + ) : ( + + )} + + )} + {!workflow.value && ( +
+ {error.value ? ( + refresh()}> + {t('project.task.refresh')} + + ) : ( + t('project.workflow.gantt_loading') + )} +
+ )} +
+ {logs.visible.value && ( + + )} +
) } }) - -export default workflowRelation diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts new file mode 100644 index 000000000000..ef5dca6adb6b --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + DateValue, + GanttRow, + IGanttRes, + TaskInstance, + WorkflowInstance +} from './type' + +const terminalWorkflowStates = new Set(['SUCCESS', 'FAILURE', 'STOP', 'PAUSE']) +const terminalTaskStates = new Set([ + 'SUCCESS', + 'FORCED_SUCCESS', + 'FAILURE', + 'KILL', + 'STOP', + 'PAUSE' +]) +export const isWorkflowActive = (state: string) => + !terminalWorkflowStates.has(state) + +function timestamp(value?: DateValue): number | null { + if (value === null || value === undefined || value === '') return null + // API Jackson dates are UTC unless a timezone is explicitly included. + const normalized = typeof value === 'string' ? value.replace(' ', 'T') : '' + const parsed = + typeof value === 'number' + ? value + : Date.parse( + /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized) + ? normalized + : `${normalized}Z` + ) + return Number.isFinite(parsed) ? parsed : null +} + +export function buildGanttModel( + workflow: WorkflowInstance, + tasks: TaskInstance[], + gantt?: IGanttRes, + now = Date.now() +) { + // Calibrate formatted server dates against the epoch timestamps returned by view-gantt. + const reference = gantt?.tasks.find( + (task) => task.isoStart && Number.isFinite(task.startDate?.[0]) + ) + const formatted = timestamp(reference?.isoStart) + const offset = + reference && formatted !== null + ? Math.floor(reference.startDate[0] / 1000) * 1000 - formatted + : 0 + const date = (value?: DateValue) => { + const parsed = timestamp(value) + return parsed === null + ? null + : parsed + (typeof value === 'number' ? 0 : offset) + } + const latest = new Map() + for (const task of tasks) { + const previous = latest.get(task.taskCode) + if ( + !previous || + (previous.flag === 'NO' && task.flag !== 'NO') || + (previous.flag === task.flag && task.id > previous.id) || + (previous.flag !== 'YES' && task.flag === 'YES') + ) + latest.set(task.taskCode, task) + } + const definitions = new Map( + (workflow.dagData?.taskDefinitionList || []).map((task) => [ + task.code, + task + ]) + ) + latest.forEach((task) => { + if (!definitions.has(task.taskCode)) + definitions.set(task.taskCode, { + code: task.taskCode, + name: task.name, + taskType: task.taskType + }) + }) + const nameCounts = new Map() + latest.forEach((task) => + nameCounts.set(task.name, (nameCounts.get(task.name) || 0) + 1) + ) + const order = new Map( + (gantt?.taskNames || []).map((code, index) => [code, index]) + ) + const rows: GanttRow[] = [...definitions.values()] + .sort( + (a, b) => + (order.get(a.code) ?? Infinity) - (order.get(b.code) ?? Infinity) + ) + .map((definition) => { + const task = latest.get(definition.code) + let start = date(task?.startTime) + let end = date(task?.endTime) + const precise = + task && nameCounts.get(task.name) === 1 + ? gantt?.tasks.find( + (item) => + item.taskName === task.name && + (!item.status || item.status === task.state) + ) + : undefined + // The gantt endpoint has no instance ID. Only use precision for an unambiguous matching attempt. + if ( + start !== null && + precise && + Math.abs(precise.startDate[0] - start) < 1000 + ) { + start = precise.startDate[0] + if (end !== null && Number.isFinite(precise.endDate[0])) + end = precise.endDate[0] + } + if ( + start !== null && + end === null && + task && + !terminalTaskStates.has(task.state) + ) + end = now + if (start !== null && end !== null) end = Math.max(start, end) + return { + code: definition.code, + name: task?.name || definition.name, + taskType: task?.taskType || definition.taskType || '', + id: task?.id, + state: task?.state || 'NOT_SUBMITTED', + start, + end, + duration: start !== null && end !== null ? end - start : null, + percent: 0, + logAvailable: Boolean(task?.id && (task.logPath || task.startTime)) + } + }) + const starts = rows.flatMap((row) => (row.start === null ? [] : [row.start])) + const workflowStart = date(workflow.startTime) + const start = Math.min(...starts, workflowStart ?? Infinity) + const safeStart = Number.isFinite(start) ? start : now + const ends = rows.flatMap((row) => (row.end === null ? [] : [row.end])) + const end = Math.max( + safeStart, + ...ends, + date(workflow.endTime) ?? + (isWorkflowActive(workflow.state) ? now : safeStart) + ) + const duration = end - safeStart + rows.forEach((row) => { + row.percent = + duration > 0 && row.duration !== null + ? (row.duration / duration) * 100 + : 0 + }) + const count = (...states: string[]) => + rows.filter((row) => states.includes(row.state)).length + const stats = { + total: rows.length, + submitted: rows.filter((row) => row.id !== undefined).length, + pending: count('NOT_SUBMITTED'), + running: count('RUNNING_EXECUTION'), + success: count('SUCCESS', 'FORCED_SUCCESS'), + failed: count('FAILURE'), + stopped: count('PAUSE', 'STOP', 'KILL'), + waiting: rows.filter( + (row) => + row.id !== undefined && + row.state !== 'RUNNING_EXECUTION' && + !terminalTaskStates.has(row.state) + ).length + } + return { + rows, + stats, + start: safeStart, + end, + duration, + axisDuration: duration > 0 ? duration : 1000 + } +} + +export type GanttModel = ReturnType + +export function formatDuration(milliseconds: number | null): string { + if (milliseconds === null) return '—' + if (milliseconds < 1000) return `${Math.round(milliseconds)} ms` + if (milliseconds < 60000) + return `${Number((milliseconds / 1000).toFixed(2))} s` + const seconds = Math.floor(milliseconds / 1000) + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` +} diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts index 1a9cc9d7910d..111b8118eb76 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts @@ -14,27 +14,56 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ITaskState } from '@/common/types' -interface ITask { - taskName: string - startDate: Array - endDate: Array - isoStart: string - isoEnd: string - status: string - duration: string +export type DateValue = string | number | null + +export interface TaskDefinition { + code: number + name: string + taskType?: string +} + +export interface TaskInstance { + id: number + taskCode: number + name: string + state: string + flag?: string + taskType?: string + startTime?: DateValue + endTime?: DateValue + submitTime?: DateValue + logPath?: string } -interface IGanttRes { - height: number - taskNames: Array - taskStatus: Object - tasks: Array +export interface WorkflowInstance { + name?: string + state: string + startTime?: DateValue + endTime?: DateValue + dagData?: { taskDefinitionList?: TaskDefinition[] } } -interface ISeriesData { - [taskState: string]: Array +export interface IGanttRes { + taskNames: number[] + tasks: { + taskName: string + startDate: number[] + endDate: number[] + isoStart?: string + status?: string + }[] } -export { ITask, IGanttRes, ISeriesData, ITaskState } +export interface GanttRow { + code: number + name: string + taskType: string + id?: number + state: string + start: number | null + end: number | null + duration: number | null + percent: number + logAvailable: boolean +} diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts index ab7b28c0ca89..fd1816b32dac 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts @@ -15,36 +15,102 @@ * limitations under the License. */ -import { reactive } from 'vue' -import { useAsyncState } from '@vueuse/core' -import { viewGanttTree } from '@/service/modules/workflow-instances' -import { IGanttRes } from './type' +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { + queryWorkflowInstanceById, + queryTaskListByWorkflowId, + viewGanttTree +} from '@/service/modules/workflow-instances' +import { buildGanttModel, isWorkflowActive } from './model' +import type { IGanttRes, TaskInstance, WorkflowInstance } from './type' -export function useGantt() { - const variables = reactive({ - seriesData: [], - taskList: [] as Array - }) - - const formatGantt = (obj: IGanttRes) => { - variables.seriesData = [] - variables.taskList = [] +export function useGantt(identity: () => [number, number]) { + const workflow = ref() + const tasks = ref([]) + const gantt = ref() + const now = ref(Date.now()) + const loading = ref(false) + const error = ref(false) + const autoRefresh = ref(true) + const updatedAt = ref() + let generation = 0 + let disposed = false + let timer: ReturnType + let clock: ReturnType - variables.seriesData = obj.tasks.map((item) => { - variables.taskList.push(item.taskName) - return item - }) as any + const refresh = async () => { + if (loading.value || disposed) return + const requestGeneration = generation + const [id, projectCode] = identity() + loading.value = true + try { + const [instance, taskResponse, ganttResponse] = await Promise.all([ + queryWorkflowInstanceById(id, projectCode), + queryTaskListByWorkflowId(id, projectCode), + viewGanttTree(id, projectCode) + ]) + if (disposed || requestGeneration !== generation) return + workflow.value = { + ...instance, + state: taskResponse.workflowInstanceState || instance.state + } + tasks.value = taskResponse.taskList || [] + gantt.value = ganttResponse + updatedAt.value = Date.now() + now.value = updatedAt.value + error.value = false + } catch { + if (!disposed && requestGeneration === generation) error.value = true + } finally { + if (!disposed && requestGeneration === generation) loading.value = false + } } - - const getGantt = (id: number, code: number) => { - const { state } = useAsyncState( - viewGanttTree(id, code).then((res: IGanttRes) => { - formatGantt(res) - }), - {} + watch( + identity, + () => { + generation++ + workflow.value = undefined + tasks.value = [] + gantt.value = undefined + updatedAt.value = undefined + loading.value = false + error.value = false + void refresh() + }, + { immediate: true } + ) + onMounted(() => { + timer = setInterval(() => { + if ( + autoRefresh.value && + !document.hidden && + (!workflow.value || isWorkflowActive(workflow.value.state)) + ) + void refresh() + }, 5000) + clock = setInterval(() => { + if ( + autoRefresh.value && + !error.value && + workflow.value && + isWorkflowActive(workflow.value.state) + ) + now.value = Date.now() + }, 1000) + }) + onBeforeUnmount(() => { + disposed = true + generation++ + clearInterval(timer) + clearInterval(clock) + }) + const model = computed(() => + buildGanttModel( + workflow.value || { state: 'SUBMITTED_SUCCESS' }, + tasks.value, + gantt.value, + now.value ) - return state - } - - return { variables, getGantt } + ) + return { workflow, model, loading, error, autoRefresh, updatedAt, refresh } } diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-logs.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-logs.ts new file mode 100644 index 000000000000..0155c6743ae6 --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-logs.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { onBeforeUnmount, ref } from 'vue' +import { queryLog } from '@/service/modules/log' +import { downloadLog } from '@/service/modules/task-instances' +import type { GanttRow } from './type' + +export function useGanttLogs(t: (key: string) => string) { + const selected = ref() + const visible = ref(false) + const text = ref('') + const loading = ref(false) + let generation = 0 + const close = () => { + generation++ + visible.value = false + loading.value = false + } + const refresh = async () => { + const id = selected.value?.id + if (!id) return + const request = ++generation + text.value = '' + loading.value = true + let skipLineNum = 0 + try { + // Bound the live preview; the existing download action returns the complete log. + while (skipLineNum < 20000) { + const result = await queryLog({ + taskInstanceId: id, + limit: 1000, + skipLineNum + }) + if (request !== generation) return + text.value += result.message || '' + if (!result.message || !result.lineNum) break + skipLineNum += result.lineNum + } + if (skipLineNum >= 20000) + text.value += `\n${t('project.workflow.gantt_log_limit')}` + if (!text.value) text.value = t('project.workflow.gantt_no_log') + } catch { + if (request === generation) + text.value += `\n${t('project.workflow.gantt_log_error')}` + } finally { + if (request === generation) loading.value = false + } + } + const open = (row: GanttRow) => { + if (!row.logAvailable) return + selected.value = row + visible.value = true + void refresh() + } + const download = () => { + if (selected.value?.id) downloadLog(selected.value.id) + } + onBeforeUnmount(close) + return { selected, visible, text, loading, open, close, refresh, download } +} diff --git a/dolphinscheduler-ui/tests/gantt-model.test.cjs b/dolphinscheduler-ui/tests/gantt-model.test.cjs new file mode 100644 index 000000000000..3b77a9789227 --- /dev/null +++ b/dolphinscheduler-ui/tests/gantt-model.test.cjs @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('node:assert/strict') +const { test } = require('node:test') +const fs = require('node:fs') +const path = require('node:path') +const ts = require('typescript') +const filename = path.resolve( + __dirname, + '../src/views/projects/workflow/instance/gantt/model.ts' +) +const model = { exports: {} } +if (fs.existsSync(filename)) { + const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } + }).outputText + new Function('module', 'exports', 'require', source)( + model, + model.exports, + require + ) +} +const build = (...args) => { + assert.equal( + typeof model.exports.buildGanttModel, + 'function', + 'gantt data normalization must be implemented' + ) + return model.exports.buildGanttModel(...args) +} +const workflow = { + name: 'pipeline', + state: 'SUCCESS', + startTime: 1000, + endTime: 11000, + dagData: { + taskDefinitionList: [ + { code: 1, name: 'extract' }, + { code: 2, name: 'load' }, + { code: 3, name: 'pending' } + ] + } +} + +test('counts definition nodes including unsubmitted tasks and deduplicates retry attempts', () => { + const result = build( + workflow, + [ + { + id: 1, + taskCode: 1, + name: 'extract', + state: 'FAILURE', + flag: 'NO', + startTime: 1000, + endTime: 2000 + }, + { + id: 3, + taskCode: 1, + name: 'extract', + state: 'SUCCESS', + startTime: 2000, + endTime: 5000 + }, + { + id: 4, + taskCode: 2, + name: 'load', + state: 'RUNNING_EXECUTION', + startTime: 5000 + } + ], + undefined, + 10000 + ) + assert.equal(result.stats.total, 3) + assert.equal(result.stats.submitted, 2) + assert.equal(result.stats.pending, 1) + assert.equal(result.stats.success, 1) + assert.equal(result.stats.failed, 0) + assert.equal(result.rows[0].id, 3) + assert.equal(result.rows[2].start, null) +}) + +test('uses millisecond gantt timestamps and workflow elapsed duration for percentages', () => { + const result = build( + workflow, + [ + { + id: 1, + taskCode: 1, + name: 'extract', + state: 'SUCCESS', + startTime: 1000, + endTime: 1000 + } + ], + { + tasks: [{ taskName: 'extract', startDate: [1100], endDate: [1287] }], + taskNames: [1, 2, 3] + }, + 20000 + ) + assert.equal(result.rows[0].duration, 187) + assert.equal(result.rows[0].percent, 1.87) + assert.equal(result.duration, 10000) +}) + +test('extends running bars and workflow bounds with current time but never stretches completed bars', () => { + const running = { ...workflow, state: 'RUNNING_EXECUTION', endTime: null } + const tasks = [ + { + id: 1, + taskCode: 1, + name: 'extract', + state: 'SUCCESS', + startTime: 1000, + endTime: 3000 + }, + { + id: 2, + taskCode: 2, + name: 'load', + state: 'RUNNING_EXECUTION', + startTime: 3000 + } + ] + const before = build(running, tasks, undefined, 6000) + const after = build(running, tasks, undefined, 11000) + assert.equal(before.duration, 5000) + assert.equal(after.duration, 10000) + assert.equal(after.rows[0].duration, 2000) + assert.equal(after.rows[1].duration, 8000) + assert.equal(after.stats.running, 1) +}) + +test('missing dates, empty workflows, and zero-duration tasks produce finite ranges', () => { + const empty = build({ state: 'SUBMITTED_SUCCESS' }, [], undefined, 1000) + assert.equal(empty.stats.total, 0) + assert.ok(empty.axisDuration > 0) + const result = build( + workflow, + [{ id: 1, taskCode: 1, name: 'extract', state: 'SUBMITTED_SUCCESS' }], + undefined, + 12000 + ) + assert.equal(result.rows[0].start, null) + assert.equal(result.rows[0].duration, null) + assert.equal(result.stats.waiting, 1) +}) + +test('parallel task percentages are wall-clock shares, not normalized to sum to 100', () => { + const result = build( + workflow, + [1, 2].map((code) => ({ + id: code, + taskCode: code, + name: String(code), + state: 'SUCCESS', + startTime: 1000, + endTime: 11000 + })), + undefined, + 12000 + ) + assert.equal(result.rows[0].percent, 100) + assert.equal(result.rows[1].percent, 100) +}) + +test('queued workflows use the server UTC clock even before any task has started', () => { + const result = build( + { state: 'RUNNING_EXECUTION', startTime: '2026-09-16 08:00:00' }, + [], + undefined, + Date.parse('2026-09-16T08:00:05Z') + ) + assert.equal(result.duration, 5000) +}) + +test('a subsecond workflow fills the percentage axis without an artificial one-second floor', () => { + const result = build({ state: 'SUCCESS', startTime: 1000, endTime: 1500 }, [ + { + id: 1, + taskCode: 1, + name: 'short', + state: 'SUCCESS', + startTime: 1000, + endTime: 1500 + } + ]) + assert.equal(result.axisDuration, 500) + assert.equal(result.rows[0].percent, 100) +}) From c8d0dc7818e1359decd576669d66c5e76dfad9f6 Mon Sep 17 00:00:00 2001 From: stev Date: Wed, 16 Sep 2026 18:59:55 +0800 Subject: [PATCH 2/5] [Fix-18647][UI] Stabilize workflow Gantt navigation Constrain task scrolling to the remaining content area and skip requests after the route identity becomes invalid. --- .../instance/gantt/components/gantt-chart.tsx | 40 ++++++++++++++- .../workflow/instance/gantt/identity.ts | 29 +++++++++++ .../workflow/instance/gantt/index.module.scss | 2 +- .../workflow/instance/gantt/layout.ts | 27 ++++++++++ .../workflow/instance/gantt/use-gantt.ts | 5 +- .../tests/gantt-identity.test.cjs | 50 +++++++++++++++++++ .../tests/gantt-layout.test.cjs | 46 +++++++++++++++++ 7 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/identity.ts create mode 100644 dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/layout.ts create mode 100644 dolphinscheduler-ui/tests/gantt-identity.test.cjs create mode 100644 dolphinscheduler-ui/tests/gantt-layout.test.cjs diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx index 6582654253e8..a15bd5f04e1b 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx @@ -15,8 +15,8 @@ * limitations under the License. */ -import { computed, defineComponent, PropType, ref } from 'vue' -import { useResizeObserver } from '@vueuse/core' +import { computed, defineComponent, nextTick, PropType, ref, watch } from 'vue' +import { useResizeObserver, useWindowSize } from '@vueuse/core' import { useI18n } from 'vue-i18n' import { NButton, NIcon, NTooltip, useThemeVars } from 'naive-ui' import { FileTextOutlined, InfoCircleOutlined } from '@vicons/antd' @@ -24,6 +24,7 @@ import { tasksState } from '@/common/common' import type { ITaskState } from '@/common/types' import { useTimezoneStore } from '@/store/timezone/timezone' import { formatDuration } from '../model' +import { availableGanttHeight } from '../layout' import type { GanttModel } from '../model' import type { GanttRow } from '../type' import styles from '../index.module.scss' @@ -37,10 +38,38 @@ export default defineComponent({ const timezone = useTimezoneStore() const theme = useThemeVars() const axis = ref() + const viewport = ref() const width = ref(600) + const viewportHeight = ref() + const { height: windowHeight } = useWindowSize() + const updateViewportHeight = () => { + const element = viewport.value + if (!element) return + const container = element.closest('.n-scrollbar-container') + const containerBottom = + container?.getBoundingClientRect().bottom ?? window.innerHeight + viewportHeight.value = availableGanttHeight( + element.getBoundingClientRect().top, + containerBottom + ) + } useResizeObserver(axis, (entries) => { width.value = entries[0].contentRect.width }) + useResizeObserver(viewport, updateViewportHeight) + watch( + windowHeight, + () => { + void nextTick(updateViewportHeight) + }, + { immediate: true } + ) + watch( + () => props.model.rows.length, + () => { + void nextTick(updateViewportHeight) + } + ) const ticks = computed(() => { const count = Math.max(1, Math.floor(width.value / 110)) return Array.from({ length: count + 1 }, (_, index) => index / count) @@ -120,7 +149,14 @@ export default defineComponent({
diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/identity.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/identity.ts new file mode 100644 index 000000000000..6bbef8025145 --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/identity.ts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type GanttIdentity = [number, number] + +export function isValidGanttIdentity([id, projectCode]: GanttIdentity) { + return ( + Number.isFinite(id) && + id > 0 && + Number.isFinite(projectCode) && + projectCode > 0 + ) +} diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss index 1ccc8c227b7d..cf26c1dd5548 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss @@ -198,8 +198,8 @@ .viewport { overflow-x: hidden; overflow-y: auto; - max-height: max(220px, calc(100vh - 300px)); position: relative; + overscroll-behavior: contain; } .grid { display: grid; diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/layout.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/layout.ts new file mode 100644 index 000000000000..655efacfbf72 --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/layout.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const BOTTOM_GAP = 16 + +export function availableGanttHeight( + viewportTop: number, + containerBottom: number +) { + return Math.max(0, Math.floor(containerBottom - viewportTop - BOTTOM_GAP)) +} diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts index fd1816b32dac..f80ae8210aa6 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/use-gantt.ts @@ -22,6 +22,7 @@ import { viewGanttTree } from '@/service/modules/workflow-instances' import { buildGanttModel, isWorkflowActive } from './model' +import { isValidGanttIdentity } from './identity' import type { IGanttRes, TaskInstance, WorkflowInstance } from './type' export function useGantt(identity: () => [number, number]) { @@ -41,7 +42,9 @@ export function useGantt(identity: () => [number, number]) { const refresh = async () => { if (loading.value || disposed) return const requestGeneration = generation - const [id, projectCode] = identity() + const requestIdentity = identity() + if (!isValidGanttIdentity(requestIdentity)) return + const [id, projectCode] = requestIdentity loading.value = true try { const [instance, taskResponse, ganttResponse] = await Promise.all([ diff --git a/dolphinscheduler-ui/tests/gantt-identity.test.cjs b/dolphinscheduler-ui/tests/gantt-identity.test.cjs new file mode 100644 index 000000000000..1bd7cb10af19 --- /dev/null +++ b/dolphinscheduler-ui/tests/gantt-identity.test.cjs @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const test = require('node:test') +const ts = require('typescript') + +const filename = join( + __dirname, + '../src/views/projects/workflow/instance/gantt/identity.ts' +) +const identity = { exports: {} } +const source = ts.transpileModule(readFileSync(filename, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } +}).outputText +new Function('module', 'exports', 'require', source)( + identity, + identity.exports, + require +) + +test('leaving the gantt route invalidates its request identity', () => { + const valid = identity.exports.isValidGanttIdentity + + assert.equal(valid([396396, 11364899434912]), true) + assert.equal(valid([Number.NaN, 11364899434912]), false) + assert.equal(valid([396396, Number.NaN]), false) + assert.equal(valid([0, 11364899434912]), false) +}) diff --git a/dolphinscheduler-ui/tests/gantt-layout.test.cjs b/dolphinscheduler-ui/tests/gantt-layout.test.cjs new file mode 100644 index 000000000000..e5bfeaa64e39 --- /dev/null +++ b/dolphinscheduler-ui/tests/gantt-layout.test.cjs @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const test = require('node:test') +const ts = require('typescript') + +const filename = join( + __dirname, + '../src/views/projects/workflow/instance/gantt/layout.ts' +) +const layout = { exports: {} } +const source = ts.transpileModule(readFileSync(filename, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } +}).outputText +new Function('module', 'exports', 'require', source)( + layout, + layout.exports, + require +) + +test('only the gantt task viewport scrolls when the task list is tall', () => { + assert.equal(layout.exports.availableGanttHeight(327.4, 704), 360) + assert.equal(layout.exports.availableGanttHeight(500, 480), 0) +}) From 002a5229a5500bdbb402c92c05ccc4636db9eee6 Mon Sep 17 00:00:00 2001 From: stev Date: Wed, 16 Sep 2026 19:06:21 +0800 Subject: [PATCH 3/5] [Improvement-18647][UI] Add workflow Gantt screenshot --- images/workflow-gantt.jpg | Bin 0 -> 65308 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/workflow-gantt.jpg diff --git a/images/workflow-gantt.jpg b/images/workflow-gantt.jpg new file mode 100644 index 0000000000000000000000000000000000000000..013dbb5a51d73f9b745252e5f6a201c718229274 GIT binary patch literal 65308 zcmeEubyO8?+wVq7X+(0fX;4zSQ4uNW?r!#G(+!FsU6PWDba!_n-Q6A1AR(w{0H5dm zp7(s`j}z;xv)1`wF|&u6x$o=x#Wgec%F>0RaUQ1vTAcdIox8 za%NWMC#Rxgo%6;?%zKM_Yi@2QSSq5m2f}^i1+RxBcngK$>1I$upzJ(7x^Iy9wnOs6}zzQ z2LEV+RwW)wH~qqf2{RMNbH*a#kdNqlPT~X`0#;)L-&jwt@PI!YbFEY& z0nv7jc%Kk&BPGfYyz6y7C%V^uzrcZ7(ma?MZ5q_(t4t613OyG#32{1$A4)zKEg;J~ z(9rPDnu-Vhyj4P8Mv}ht&J4@R82gS}OHcBE5b7IHJztzR{bv&&5#dx99V|)+Oz3PU zV8PfTGE+RHOT*sK>NjXTYJt%zKz`H^=RTBrLX4CQ#gIvC)uxmKTdw5h52Ydwi)bpI z(2ebgZQsTCeM3#yu`mtZWZf4tMI}*5*{}4ok#sO9T*e>aP58_FhBViz9Int6?{wvF z;11w}4|)q{4LU+L2G3ehJZN^SuMCWbM8%ZS3I0ew!<41IK%Z}opHrAyw;q=nx9>+? zT>BV`o?^kS$5&HVC_LlljFtC|e>O?4QAE{Gj(D=QIRm>uk7p`t+d0f6;W(7rutNh7BZDk)X$3thGtY8n* z9S-}Y3ax{W+PWv_QAIuYl4iV@nHk2$+DoUG&aBVxHOEh2T2im1zVL)C*JkE*9JQah zNIb-cNlJSbUgkGcO1~WTi9i$)Oe&2~02TE@kiFZOLG;fDzJ$>Gts4oh5N0FkPQ|5Z z=Le*lLMby#56@vuk~*}naL1Iok^1`|g)-UH5TY7@@ijY-WQ<%=#(BhwonCAir3CRN z&H0wh*C+aXG}xGf&+jn^YPPZqC9m~`^Dy^)v$m}$Dz-~5a~dI-y`|t0NkTG7D}%wf z)T?<}yPsOx8&zx-{qGP{m_zYtIF>cjY$T&AKD`#)=@bS0tlNEjX&OTvq!JnSdNV&xtw zSwoYYK6k)ye5asC_{?xxZ}}A!!DHN3czAkme;SLu72YFC7%4d;GjLqQ@F#G^ zaYGjr@<8Rlom5*_xFjNtJyIVyo&seMKHvFM2x-ym4v_~?%?XrplyYk!`0u*qV|YgOjs;s5<w36R?`WB-#3r}8&NS8RTI%3Ua*$`HloBT%v z#g?oIXz`z!MQPY2jyd@DYPc&03MHoN4Ez2lpymRCxZ&VOk7CBPZ)EBc+73dV!cr

8B1IHN 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%0AlUG0N-^n*uijGA*Z#Qan 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 x1J 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 literal 0 HcmV?d00001 From 734ccd956ded5d85506bda90077f13e84eab9d5d Mon Sep 17 00:00:00 2001 From: stev Date: Fri, 18 Sep 2026 18:05:41 +0800 Subject: [PATCH 4/5] [Improvement-18647][UI] Index Gantt tasks for live updates --- .../instance/gantt/components/gantt-chart.tsx | 3 +- .../projects/workflow/instance/gantt/model.ts | 20 ++++---- .../tests/gantt-model.test.cjs | 47 +++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx index a15bd5f04e1b..b0aa36c299a6 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/components/gantt-chart.tsx @@ -74,8 +74,9 @@ export default defineComponent({ const count = Math.max(1, Math.floor(width.value / 110)) return Array.from({ length: count + 1 }, (_, index) => index / count) }) + const taskStates = computed(() => tasksState(t)) const state = (row: GanttRow) => - tasksState(t)[row.state as ITaskState]?.desc || + taskStates.value[row.state as ITaskState]?.desc || (row.state === 'NOT_SUBMITTED' ? t('project.workflow.gantt_pending') : row.state) diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts index ef5dca6adb6b..605c6918dfd8 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts @@ -57,9 +57,15 @@ export function buildGanttModel( now = Date.now() ) { // Calibrate formatted server dates against the epoch timestamps returned by view-gantt. - const reference = gantt?.tasks.find( - (task) => task.isoStart && Number.isFinite(task.startDate?.[0]) - ) + let reference: IGanttRes['tasks'][number] | undefined + const ganttTasksByName = new Map() + for (const task of gantt?.tasks || []) { + if (!reference && task.isoStart && Number.isFinite(task.startDate?.[0])) + reference = task + const matches = ganttTasksByName.get(task.taskName) + if (matches) matches.push(task) + else ganttTasksByName.set(task.taskName, [task]) + } const formatted = timestamp(reference?.isoStart) const offset = reference && formatted !== null @@ -114,11 +120,9 @@ export function buildGanttModel( let end = date(task?.endTime) const precise = task && nameCounts.get(task.name) === 1 - ? gantt?.tasks.find( - (item) => - item.taskName === task.name && - (!item.status || item.status === task.state) - ) + ? ganttTasksByName + .get(task.name) + ?.find((item) => !item.status || item.status === task.state) : undefined // The gantt endpoint has no instance ID. Only use precision for an unambiguous matching attempt. if ( diff --git a/dolphinscheduler-ui/tests/gantt-model.test.cjs b/dolphinscheduler-ui/tests/gantt-model.test.cjs index 3b77a9789227..71739d049004 100644 --- a/dolphinscheduler-ui/tests/gantt-model.test.cjs +++ b/dolphinscheduler-ui/tests/gantt-model.test.cjs @@ -125,6 +125,53 @@ test('uses millisecond gantt timestamps and workflow elapsed duration for percen assert.equal(result.duration, 10000) }) +test('indexes gantt tasks once when building large workflows', () => { + const taskCount = 100 + const definitions = Array.from({ length: taskCount }, (_, index) => ({ + code: index + 1, + name: `task-${index + 1}` + })) + const instances = definitions.map(({ code, name }) => ({ + id: code, + taskCode: code, + name, + state: 'SUCCESS', + startTime: code * 1000, + endTime: code * 1000 + 500 + })) + let taskReads = 0 + const ganttTasks = new Proxy( + definitions.map(({ code, name }) => ({ + taskName: name, + startDate: [code * 1000], + endDate: [code * 1000 + 500], + ...(code === 1 ? { isoStart: '1970-01-01T00:00:01Z' } : {}) + })), + { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) taskReads++ + return Reflect.get(target, property, receiver) + } + } + ) + + build( + { + state: 'SUCCESS', + startTime: 1000, + endTime: taskCount * 1000 + 500, + dagData: { taskDefinitionList: definitions } + }, + instances, + { taskNames: definitions.map(({ code }) => code), tasks: ganttTasks } + ) + + assert.ok( + taskReads <= taskCount * 3, + `expected a linear scan, read ${taskReads} entries for ${taskCount} tasks` + ) +}) + test('extends running bars and workflow bounds with current time but never stretches completed bars', () => { const running = { ...workflow, state: 'RUNNING_EXECUTION', endTime: null } const tasks = [ From 6c03c892d74103368b03929a5c35ade398ccadeb Mon Sep 17 00:00:00 2001 From: stev Date: Fri, 18 Sep 2026 18:34:35 +0800 Subject: [PATCH 5/5] feat(ui): view task configuration from gantt --- .../src/locales/en_US/project.ts | 5 +- .../src/locales/zh_CN/project.ts | 5 +- .../task/components/node/detail-modal.tsx | 14 ++- .../instance/gantt/components/gantt-chart.tsx | 91 +++++++++++++------ .../workflow/instance/gantt/index.module.scss | 45 ++++++--- .../workflow/instance/gantt/index.tsx | 38 +++++++- .../projects/workflow/instance/gantt/model.ts | 12 ++- .../projects/workflow/instance/gantt/type.ts | 2 + .../tests/gantt-model.test.cjs | 23 +++++ 9 files changed, 181 insertions(+), 54 deletions(-) diff --git a/dolphinscheduler-ui/src/locales/en_US/project.ts b/dolphinscheduler-ui/src/locales/en_US/project.ts index bb38a3018cba..c68c91c6ad54 100644 --- a/dolphinscheduler-ui/src/locales/en_US/project.ts +++ b/dolphinscheduler-ui/src/locales/en_US/project.ts @@ -67,10 +67,12 @@ export default { gantt_end: 'End', gantt_share: 'Share of workflow elapsed', gantt_axis_note: - 'Time is relative to workflow start. Select task names to copy; use the log icon to view logs.', + 'Time is relative to workflow start. Select task names to copy; use the icons to view logs or configuration.', gantt_share_note: 'Share = task duration / workflow elapsed. Parallel tasks may sum to more than 100%.', gantt_no_log: 'No log available yet', + gantt_view_config: 'View configuration', + gantt_no_config: 'Configuration unavailable', gantt_time_unavailable: 'Execution time unavailable', gantt_log_limit: 'Showing the first 20,000 lines. Download for the complete log.', @@ -581,7 +583,6 @@ export default { http_condition_tips: 'Please Enter Http Condition', timeout_settings: 'Timeout Settings', connect_timeout: 'Connect Timeout', - keep_alive: 'TCP Keepalive', ms: 'ms', status_code_default: 'Default response code 200', status_code_custom: 'Custom response code', diff --git a/dolphinscheduler-ui/src/locales/zh_CN/project.ts b/dolphinscheduler-ui/src/locales/zh_CN/project.ts index 1a95e71c27c6..7f2968629004 100644 --- a/dolphinscheduler-ui/src/locales/zh_CN/project.ts +++ b/dolphinscheduler-ui/src/locales/zh_CN/project.ts @@ -67,10 +67,12 @@ export default { gantt_end: '结束', gantt_share: '占工作流耗时', gantt_axis_note: - '从工作流开始计时;任务名称可复制,点击旁边的日志图标查看日志。', + '从工作流开始计时;任务名称可复制,点击状态信息右侧图标查看日志或任务配置。', gantt_share_note: '占比 = 任务耗时 / 工作流耗时,并行任务占比之和可能超过 100%。', gantt_no_log: '暂无日志', + gantt_view_config: '查看配置', + gantt_no_config: '暂无任务配置', gantt_time_unavailable: '暂无完整执行时间', gantt_log_limit: '已展示前 20000 行,请下载查看完整日志。', gantt_log_error: '日志加载失败,请点击刷新重试。', @@ -560,7 +562,6 @@ export default { http_condition_tips: '请填写校验内容', timeout_settings: '超时设置', connect_timeout: '连接超时', - keep_alive: '启用 TCP 保活', ms: '毫秒', status_code_default: '默认响应码200', status_code_custom: '自定义响应码', diff --git a/dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx b/dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx index e784947d5247..a4d1f401d5e5 100644 --- a/dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx +++ b/dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx @@ -86,6 +86,10 @@ const props = { saving: { type: Boolean, default: false + }, + confirmShow: { + type: Boolean, + default: true } } @@ -241,7 +245,7 @@ const NodeDetailModal = defineComponent({ ) onMounted(() => { - initProjectPreferences(props.projectCode) + if (!props.readonly) initProjectPreferences(props.projectCode) }) watch( @@ -252,7 +256,7 @@ const NodeDetailModal = defineComponent({ taskStore.init() const nodeData = formatModel(props.data) await nextTick() - restructureNodeData(nodeData) + if (!props.readonly) restructureNodeData(nodeData) detailRef.value.value.setValues(nodeData) } ) @@ -260,12 +264,18 @@ const NodeDetailModal = defineComponent({ return () => ( , required: true } }, - emits: { viewLog: (ignoredRow: GanttRow) => true }, + emits: { + viewLog: (ignoredRow: GanttRow) => true, + viewConfig: (ignoredRow: GanttRow) => true + }, setup(props, { emit }) { const { t } = useI18n() const timezone = useTimezoneStore() @@ -203,35 +210,63 @@ export default defineComponent({ {row.name} - - {{ - trigger: () => ( - emit('viewLog', row)} - > - - - - - ), - default: () => - row.logAvailable - ? t('project.task.view_log') - : t('project.workflow.gantt_no_log') - }} -

- {state(row)} - {row.taskType && ` · ${row.taskType}`} + + {state(row)} + {row.taskType && ` · ${row.taskType}`} + + + + {{ + trigger: () => ( + emit('viewLog', row)} + > + + + + + ), + default: () => + row.logAvailable + ? t('project.task.view_log') + : t('project.workflow.gantt_no_log') + }} + + + {{ + trigger: () => ( + emit('viewConfig', row)} + > + + + + + ), + default: () => + row.definition + ? t('project.workflow.gantt_view_config') + : t('project.workflow.gantt_no_config') + }} + +
diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss index cf26c1dd5548..782a7bb7f7ad 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss @@ -261,7 +261,26 @@ } .index { color: var(--gantt-muted); - font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; + font: 9px/14px ui-monospace, SFMono-Regular, Menlo, monospace; + height: 14px; + width: 24px; + padding: 0 4px; + border-radius: 7px; + text-align: center; + box-sizing: border-box; + background: color-mix(in srgb, var(--gantt-muted) 10%, transparent); + flex-shrink: 0; +} +.taskActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; +} +.actionButton { + width: 18px; + height: 18px; + color: var(--gantt-primary); } .taskInfo { min-width: 0; @@ -286,23 +305,26 @@ text-overflow: ellipsis; white-space: nowrap; } -.logButton { - flex-shrink: 0; - color: var(--gantt-primary); -} .taskState { font-size: 10px; color: var(--gantt-muted); + display: flex; + align-items: center; + gap: 4px; white-space: nowrap; - text-overflow: ellipsis; + min-width: 0; +} +.stateText { overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } .taskState i { - display: inline-block; - width: 5px; - height: 5px; + display: block; + width: 7px; + height: 7px; border-radius: 50%; - margin-right: 4px; + flex-shrink: 0; } .metrics { text-align: right; @@ -382,7 +404,4 @@ padding: 0 6px; gap: 4px; } - .index { - display: none; - } } diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx index 6df521a9d2b9..822b58008b8b 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { defineComponent } from 'vue' +import { computed, defineComponent, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' import { @@ -29,10 +29,12 @@ import { } from 'naive-ui' import Card from '@/components/card' import LogModal from '@/components/log-modal' +import TaskModal from '@/views/projects/task/components/node/detail-modal' import GanttChart from './components/gantt-chart' import { useGantt } from './use-gantt' import { useGanttLogs } from './use-logs' import { isWorkflowActive } from './model' +import type { GanttRow } from './type' import styles from './index.module.scss' export default defineComponent({ @@ -47,6 +49,18 @@ export default defineComponent({ Number(route.params.projectCode) ]) const logs = useGanttLogs(t) + const configVisible = ref(false) + const configTask = ref() + const workflowDefinition = computed(() => workflow.value?.dagData as any) + const openConfig = (row: GanttRow) => { + if (!row.definition) return + configTask.value = row.definition + configVisible.value = true + } + const closeConfig = () => { + configVisible.value = false + configTask.value = undefined + } const statistics = [ 'total', 'submitted', @@ -184,7 +198,11 @@ export default defineComponent({
{model.value.rows.length ? ( - + ) : ( )} + ) diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts index 605c6918dfd8..0a9cff36f530 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts @@ -88,11 +88,10 @@ export function buildGanttModel( ) latest.set(task.taskCode, task) } + const configuredDefinitions = workflow.dagData?.taskDefinitionList || [] + const configuredCodes = new Set(configuredDefinitions.map(({ code }) => code)) const definitions = new Map( - (workflow.dagData?.taskDefinitionList || []).map((task) => [ - task.code, - task - ]) + configuredDefinitions.map((task) => [task.code, task]) ) latest.forEach((task) => { if (!definitions.has(task.taskCode)) @@ -152,7 +151,10 @@ export function buildGanttModel( end, duration: start !== null && end !== null ? end - start : null, percent: 0, - logAvailable: Boolean(task?.id && (task.logPath || task.startTime)) + logAvailable: Boolean(task?.id && (task.logPath || task.startTime)), + definition: configuredCodes.has(definition.code) + ? definition + : undefined } }) const starts = rows.flatMap((row) => (row.start === null ? [] : [row.start])) diff --git a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts index 111b8118eb76..bff1d6b9e8d0 100644 --- a/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/type.ts @@ -21,6 +21,7 @@ export interface TaskDefinition { code: number name: string taskType?: string + [key: string]: unknown } export interface TaskInstance { @@ -66,4 +67,5 @@ export interface GanttRow { duration: number | null percent: number logAvailable: boolean + definition?: TaskDefinition } diff --git a/dolphinscheduler-ui/tests/gantt-model.test.cjs b/dolphinscheduler-ui/tests/gantt-model.test.cjs index 71739d049004..19e4a2641fd8 100644 --- a/dolphinscheduler-ui/tests/gantt-model.test.cjs +++ b/dolphinscheduler-ui/tests/gantt-model.test.cjs @@ -101,6 +101,29 @@ test('counts definition nodes including unsubmitted tasks and deduplicates retry assert.equal(result.rows[2].start, null) }) +test('keeps the workflow instance version task definition for read-only viewing', () => { + const instanceVersion = { + code: 1, + name: 'extract', + taskType: 'SQL', + version: 7, + taskParams: { sql: 'select 1' } + } + const result = build( + { + ...workflow, + dagData: { taskDefinitionList: [instanceVersion] } + }, + [], + undefined, + 12000 + ) + + assert.equal(result.rows[0].definition, instanceVersion) + assert.equal(result.rows[0].definition.version, 7) + assert.equal(result.rows[0].definition.taskParams.sql, 'select 1') +}) + test('uses millisecond gantt timestamps and workflow elapsed duration for percentages', () => { const result = build( workflow,