diff --git a/dolphinscheduler-ui/src/locales/en_US/project.ts b/dolphinscheduler-ui/src/locales/en_US/project.ts index 8084f192d7d2..c68c91c6ad54 100644 --- a/dolphinscheduler-ui/src/locales/en_US/project.ts +++ b/dolphinscheduler-ui/src/locales/en_US/project.ts @@ -44,6 +44,46 @@ 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 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.', + 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', @@ -543,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 34901950bdad..7f2968629004 100644 --- a/dolphinscheduler-ui/src/locales/zh_CN/project.ts +++ b/dolphinscheduler-ui/src/locales/zh_CN/project.ts @@ -44,6 +44,43 @@ 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_view_config: '查看配置', + gantt_no_config: '暂无任务配置', + gantt_time_unavailable: '暂无完整执行时间', + gantt_log_limit: '已展示前 20000 行,请下载查看完整日志。', + gantt_log_error: '日志加载失败,请点击刷新重试。', + gantt_stale: '刷新失败,当前展示上次成功获取的数据,请重试。', + gantt_load_error: '无法加载工作流实例,请检查服务连接和访问权限后重试。', + gantt_empty: '该工作流实例没有任务。', + gantt_loading: '正在加载任务执行情况…', + on_line: '上线', test: '测试', operating_environment: '运行环境', @@ -525,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 () => ( , - 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, + viewConfig: (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 - } - }) - }) - - // 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 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 ) } - - 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' - } - } - }) + useResizeObserver(axis, (entries) => { + width.value = entries[0].contentRect.width + }) + useResizeObserver(viewport, updateViewportHeight) + watch( + windowHeight, + () => { + void nextTick(updateViewportHeight) }, - series: series + { 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) + }) + const taskStates = computed(() => tasksState(t)) + const state = (row: GanttRow) => + taskStates.value[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} + +
+ + + + {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') + }} + + + +
+
+ {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/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 new file mode 100644 index 000000000000..782a7bb7f7ad --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/index.module.scss @@ -0,0 +1,407 @@ +/* + * 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; + position: relative; + overscroll-behavior: contain; +} +.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: 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; + 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; +} +.taskState { + font-size: 10px; + color: var(--gantt-muted); + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + min-width: 0; +} +.stateText { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.taskState i { + display: block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.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; + } +} 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..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,59 +15,244 @@ * limitations under the License. */ -import { defineComponent, onMounted, toRefs, watch } from 'vue' +import { computed, defineComponent, ref } 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 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' -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) + 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 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 } - - onMounted(() => { - getGantt(id, code) - }) - - watch( - () => [locale.value], - () => { - handleResetDate() - } - ) - - return { t, ...toRefs(variables) } - }, - render() { - const { t } = this - return ( + const closeConfig = () => { + configVisible.value = false + configTask.value = undefined + } + 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/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/model.ts b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts new file mode 100644 index 000000000000..0a9cff36f530 --- /dev/null +++ b/dolphinscheduler-ui/src/views/projects/workflow/instance/gantt/model.ts @@ -0,0 +1,215 @@ +/* + * 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. + 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 + ? 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 configuredDefinitions = workflow.dagData?.taskDefinitionList || [] + const configuredCodes = new Set(configuredDefinitions.map(({ code }) => code)) + const definitions = new Map( + configuredDefinitions.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 + ? 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 ( + 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)), + definition: configuredCodes.has(definition.code) + ? definition + : undefined + } + }) + 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..bff1d6b9e8d0 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,58 @@ * 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 + [key: string]: unknown +} + +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 + definition?: TaskDefinition +} 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..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 @@ -15,36 +15,105 @@ * 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 { isValidGanttIdentity } from './identity' +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 requestIdentity = identity() + if (!isValidGanttIdentity(requestIdentity)) return + const [id, projectCode] = requestIdentity + 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-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) +}) diff --git a/dolphinscheduler-ui/tests/gantt-model.test.cjs b/dolphinscheduler-ui/tests/gantt-model.test.cjs new file mode 100644 index 000000000000..19e4a2641fd8 --- /dev/null +++ b/dolphinscheduler-ui/tests/gantt-model.test.cjs @@ -0,0 +1,282 @@ +/* + * 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('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, + [ + { + 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('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 = [ + { + 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) +}) diff --git a/images/workflow-gantt.jpg b/images/workflow-gantt.jpg new file mode 100644 index 000000000000..013dbb5a51d7 Binary files /dev/null and b/images/workflow-gantt.jpg differ