This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 4dfe8bd768 [Bug] [UI] Fix NaN display in job detail Overview table
when tablePaths does not match metrics keys (#10965)
4dfe8bd768 is described below
commit 4dfe8bd768a051b5065d83ca7cbb75a0a9de6642
Author: Zhen Zhang <[email protected]>
AuthorDate: Mon Jun 8 00:03:03 2026 -0700
[Bug] [UI] Fix NaN display in job detail Overview table when tablePaths
does not match metrics keys (#10965)
---
.../seatunnel-engine-ui/src/service/job/index.ts | 4 +-
.../src/tests/detail-metrics.spec.ts | 137 +++++++++++++++++++++
.../seatunnel-engine-ui/src/tests/detail.spec.ts | 112 +++++++++++++++++
.../src/views/jobs/detail-metrics.ts | 83 +++++++++++++
.../seatunnel-engine-ui/src/views/jobs/detail.tsx | 117 +++++++++---------
5 files changed, 391 insertions(+), 62 deletions(-)
diff --git a/seatunnel-engine/seatunnel-engine-ui/src/service/job/index.ts
b/seatunnel-engine/seatunnel-engine-ui/src/service/job/index.ts
index f494cac1a2..70273342a6 100644
--- a/seatunnel-engine/seatunnel-engine-ui/src/service/job/index.ts
+++ b/seatunnel-engine/seatunnel-engine-ui/src/service/job/index.ts
@@ -16,7 +16,7 @@
*/
import { get } from '@/service/service'
-import type {Job, JobPage} from './types'
+import type { Job, JobPage } from './types'
export const getRunningJobs = (page: number, rows: number) =>
get<JobPage>('/running-jobs', {page: page, rows: rows})
export const getFinishedJobs = (page: number, rows: number) =>
get<JobPage>(`/finished-jobs`, {page: page, rows: rows})
@@ -28,4 +28,4 @@ export const JobsService = {
getFinishedJobs,
getJobInfo,
getRunningJobInfo
-}
+}
\ No newline at end of file
diff --git
a/seatunnel-engine/seatunnel-engine-ui/src/tests/detail-metrics.spec.ts
b/seatunnel-engine/seatunnel-engine-ui/src/tests/detail-metrics.spec.ts
new file mode 100644
index 0000000000..cd9d48576e
--- /dev/null
+++ b/seatunnel-engine/seatunnel-engine-ui/src/tests/detail-metrics.spec.ts
@@ -0,0 +1,137 @@
+/*
+ * 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 { describe, expect, test } from 'vitest'
+import {
+ collectVertexMetrics,
+ extractVertexIdentifier,
+ readVertexMetricValue
+} from '@/views/jobs/detail-metrics'
+import type { Vertex } from '@/service/job/types'
+
+describe('detail metrics helpers', () => {
+ const sourceVertex = {
+ vertexId: 1,
+ type: 'source',
+ vertexName: 'pipeline-1 [Source[0]]',
+ tablePaths: ['fake.user_table']
+ } as Vertex
+
+ const sinkVertex = {
+ vertexId: 2,
+ type: 'sink',
+ vertexName: 'pipeline-1 [Sink[1]]',
+ tablePaths: ['fake.user_table']
+ } as Vertex
+
+ test('extracts the vertex identifier from the vertex name', () => {
+ expect(extractVertexIdentifier(sourceVertex.vertexName)).toBe('Source[0]')
+ expect(extractVertexIdentifier(sinkVertex.vertexName)).toBe('Sink[1]')
+ })
+
+ test('prefers prefixed metric keys over raw table names', () => {
+ const metricMap = {
+ 'Source[0].fake.user_table': '10',
+ 'Source[1].fake.user_table': '20',
+ 'fake.user_table': '999'
+ }
+ expect(readVertexMetricValue(metricMap, sourceVertex,
'fake.user_table')).toBe(10)
+ })
+
+ test('falls back to raw table names when prefixed keys are unavailable', ()
=> {
+ const metricMap = { 'fake.user_table': '15' }
+ expect(readVertexMetricValue(metricMap, sourceVertex,
'fake.user_table')).toBe(15)
+ })
+
+ test('collects only metrics that belong to the focused vertex', () => {
+ const metricMap = {
+ 'Sink[0].fake.user_table': '5',
+ 'Sink[1].fake.user_table': '8'
+ }
+ expect(collectVertexMetrics('TableSinkWriteCount', metricMap,
sinkVertex)).toEqual({
+ 'TableSinkWriteCount.fake.user_table': '8'
+ })
+ })
+
+ test('returns 0 when metricMap is undefined', () => {
+ expect(readVertexMetricValue(undefined, sourceVertex,
'fake.user_table')).toBe(0)
+ })
+
+ test('handles vertexName without identifier', () => {
+ const vertexWithoutIdentifier = {
+ vertexId: 1,
+ type: 'source',
+ vertexName: 'simple-source-name',
+ tablePaths: ['fake.user_table']
+ } as Vertex
+
+ const metricMap = { 'fake.user_table': '15' }
+ expect(readVertexMetricValue(metricMap, vertexWithoutIdentifier,
'fake.user_table')).toBe(15)
+ })
+
+ test('returns 0 when no matching key found', () => {
+ const metricMap = { 'other.table': '100' }
+ expect(readVertexMetricValue(metricMap, sourceVertex,
'fake.user_table')).toBe(0)
+ })
+
+ test('handles non-numeric metric values', () => {
+ const metricMap = { 'fake.user_table': 'invalid' }
+ expect(readVertexMetricValue(metricMap, sourceVertex,
'fake.user_table')).toBe(0)
+ })
+
+ test('handles undefined vertexName', () => {
+ const vertexWithUndefinedName = {
+ vertexId: 1,
+ type: 'source',
+ vertexName: undefined,
+ tablePaths: ['fake.user_table']
+ } as unknown as Vertex
+
+ expect(extractVertexIdentifier(undefined)).toBeUndefined()
+ const metricMap = { 'fake.user_table': '15' }
+ expect(readVertexMetricValue(metricMap, vertexWithUndefinedName,
'fake.user_table')).toBe(15)
+ })
+
+ test('handles empty tablePaths', () => {
+ const vertexWithEmptyPaths = {
+ vertexId: 1,
+ type: 'source',
+ vertexName: 'pipeline-1 [Source[0]]',
+ tablePaths: []
+ } as Vertex
+
+ const metricMap = { 'Source[0].fake.user_table': '10' }
+ expect(collectVertexMetrics('TableSourceReceivedBytes', metricMap,
vertexWithEmptyPaths)).toEqual({})
+ })
+
+ test('handles metric value of zero', () => {
+ const metricMap = { 'Source[0].fake.user_table': '0' }
+ expect(readVertexMetricValue(metricMap, sourceVertex,
'fake.user_table')).toBe(0)
+ })
+
+ test('suffix match returns correct vertex when multiple suffixes exist', ()
=> {
+ const metricMap = {
+ 'Source[0].fake': '10',
+ 'Source[1].fake': '20'
+ }
+ expect(readVertexMetricValue(metricMap, sourceVertex, 'fake')).toBe(10)
+ })
+
+ test('handles empty metricMap', () => {
+ expect(readVertexMetricValue({}, sourceVertex, 'fake.user_table')).toBe(0)
+ })
+})
\ No newline at end of file
diff --git a/seatunnel-engine/seatunnel-engine-ui/src/tests/detail.spec.ts
b/seatunnel-engine/seatunnel-engine-ui/src/tests/detail.spec.ts
new file mode 100644
index 0000000000..ce35da2405
--- /dev/null
+++ b/seatunnel-engine/seatunnel-engine-ui/src/tests/detail.spec.ts
@@ -0,0 +1,112 @@
+/*
+ * 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 { describe, test, expect, vi, beforeEach } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createApp } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import i18n from '@/locales'
+import detail from '@/views/jobs/detail'
+import { getJobInfo } from '@/service/job'
+import type { Job } from '@/service/job/types'
+
+vi.mock('@/service/job', () => ({
+ getJobInfo: vi.fn(),
+ getRunningJobInfo: vi.fn()
+}))
+
+vi.mock('vue-router', () => ({
+ useRoute: () => ({ params: { jobId: '123456789' } }),
+ useRouter: () => ({ push: vi.fn() })
+}))
+
+vi.mock('@/components/directed-acyclic-graph', () => ({
+ default: { template: '<div></div>' }
+}))
+
+describe('detail', () => {
+ const app = createApp({})
+ beforeEach(() => {
+ const pinia = createPinia()
+ app.use(pinia)
+ setActivePinia(createPinia())
+ })
+
+ test('should not display NaN when tablePaths does not match metrics keys',
async () => {
+ const mockJob = {
+ jobId: '123456789',
+ jobName: 'Oracle-CDC-Test',
+ jobStatus: 'FINISHED',
+ errorMsg: '',
+ createTime: '2026-05-21 10:00:00',
+ finishTime: '2026-05-21 11:00:00',
+ metrics: {
+ SourceReceivedBytes: '119028',
+ SourceReceivedBytesPerSeconds: '1024',
+ SourceReceivedCount: '141',
+ SourceReceivedQPS: '10',
+ SinkWriteBytes: '98304',
+ SinkWriteBytesPerSeconds: '512',
+ SinkWriteCount: '138',
+ SinkWriteQPS: '9',
+ TableSourceReceivedBytes: { 'Source[0].fake': '119028' },
+ TableSourceReceivedCount: { 'Source[0].fake': '141' },
+ TableSourceReceivedQPS: { 'Source[0].fake': '10' },
+ TableSourceReceivedBytesPerSeconds: { 'Source[0].fake': '1024' },
+ TableSinkWriteBytes: { 'Sink[0].fake': '98304' },
+ TableSinkWriteCount: { 'Sink[0].fake': '138' },
+ TableSinkWriteQPS: { 'Sink[0].fake': '9' },
+ TableSinkWriteBytesPerSeconds: { 'Sink[0].fake': '512' }
+ },
+ jobDag: {
+ jobId: '123456789',
+ pipelineEdges: {},
+ vertexInfoMap: [
+ {
+ vertexId: 1,
+ type: 'source',
+ vertexName: 'pipeline-1 [Source[0]-FakeSource]',
+ tablePaths: ['fake']
+ },
+ {
+ vertexId: 2,
+ type: 'sink',
+ vertexName: 'pipeline-1 [Sink[0]-FakeSink]',
+ tablePaths: ['fake']
+ }
+ ]
+ },
+ pluginJarsUrls: []
+ } as unknown as Job
+
+ vi.mocked(getJobInfo).mockResolvedValue(mockJob)
+
+ const wrapper = mount(detail, {
+ global: {
+ plugins: [i18n]
+ }
+ })
+
+ await flushPromises()
+
+
+ expect(wrapper.text()).toContain('Oracle-CDC-Test')
+
+
+ expect(wrapper.text()).not.toContain('NaN')
+ })
+})
\ No newline at end of file
diff --git
a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail-metrics.ts
b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail-metrics.ts
new file mode 100644
index 0000000000..4bb228e025
--- /dev/null
+++ b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail-metrics.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 { Vertex } from '@/service/job/types'
+
+const VERTEX_IDENTIFIER_PATTERN = /((?:Sink|Source|Transform)\[\d+\])/
+
+type MetricMap = Record<string, string> | undefined
+type MetricVertex = Pick<Vertex, 'vertexName' | 'tablePaths'>
+
+export const extractVertexIdentifier = (vertexName?: string): string |
undefined => {
+ return vertexName?.match(VERTEX_IDENTIFIER_PATTERN)?.[1]
+}
+
+const resolveMetricKey = (
+ metricMap: MetricMap,
+ vertex: MetricVertex,
+ path: string
+): string | undefined => {
+ if (!metricMap) return undefined
+
+ const identifier = extractVertexIdentifier(vertex.vertexName)
+
+ if (identifier) {
+ const prefixedKey = `${identifier}.${path}`
+ if (metricMap[prefixedKey] !== undefined) return prefixedKey
+ }
+
+ if (metricMap[path] !== undefined) return path
+
+ const suffix = `.${path}`
+ const suffixedKeys = Object.keys(metricMap).filter((key) =>
key.endsWith(suffix))
+
+ if (identifier) {
+ const sameVertexKey = suffixedKeys.find((key) =>
key.startsWith(`${identifier}.`))
+ if (sameVertexKey) return sameVertexKey
+ }
+
+ return suffixedKeys.length === 1 ? suffixedKeys[0] : undefined
+}
+
+export const readVertexMetricValue = (
+ metricMap: MetricMap,
+ vertex: MetricVertex,
+ path: string
+): number => {
+ if (!metricMap) return 0
+ const metricKey = resolveMetricKey(metricMap, vertex, path)
+ if (!metricKey) return 0
+ const value = Number(metricMap[metricKey])
+ return Number.isFinite(value) ? value : 0
+}
+
+export const collectVertexMetrics = (
+ metricName: string,
+ metricMap: MetricMap,
+ vertex: MetricVertex
+): Record<string, string> => {
+ const metrics: Record<string, string> = {}
+ if (!metricMap) return metrics
+
+ vertex.tablePaths.forEach((path) => {
+ const metricKey = resolveMetricKey(metricMap, vertex, path)
+ if (metricKey !== undefined) {
+ metrics[`${metricName}.${path}`] = metricMap[metricKey]
+ }
+ })
+ return metrics
+}
\ No newline at end of file
diff --git a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
index f8f14b101f..a0a7f00401 100644
--- a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
+++ b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
@@ -37,6 +37,7 @@ import { getColorFromStatus } from '@/utils/getTypeFromStatus'
import './detail.scss'
import Configuration from '@/components/configuration'
import JobLog from '@/components/job-log'
+import { readVertexMetricValue, collectVertexMetrics } from './detail-metrics'
export default defineComponent({
setup() {
@@ -91,32 +92,40 @@ export default defineComponent({
const tableData = computed(() => {
return job.jobDag?.vertexInfoMap?.filter((v) => v.type !== 'transform')
|| []
})
- const sourceCell = (
- row: Vertex,
- key:
- | 'TableSourceReceivedBytes'
- | 'TableSourceReceivedCount'
- | 'TableSourceReceivedQPS'
- | 'TableSourceReceivedBytesPerSeconds'
- ) => {
- if (row.type === 'source') {
- return row.tablePaths.reduce((s, path) => s +
Number(job.metrics?.[key][path]), 0)
- }
- return 0
- }
- const sinkCell = (
- row: Vertex,
- key:
- | 'TableSinkWriteBytes'
- | 'TableSinkWriteCount'
- | 'TableSinkWriteQPS'
- | 'TableSinkWriteBytesPerSeconds'
- ) => {
- if (row.type === 'sink') {
- return row.tablePaths.reduce((s, path) => s +
Number(job.metrics?.[key][path]), 0)
- }
- return 0
- }
+const sourceCell = (
+ row: Vertex,
+ key:
+ | 'TableSourceReceivedBytes'
+ | 'TableSourceReceivedCount'
+ | 'TableSourceReceivedQPS'
+ | 'TableSourceReceivedBytesPerSeconds'
+) => {
+ if (row.type === 'source') {
+ return row.tablePaths.reduce(
+ (s, path) => s + readVertexMetricValue(job.metrics?.[key], row, path),
+ 0
+ )
+ }
+ return 0
+}
+
+const sinkCell = (
+ row: Vertex,
+ key:
+ | 'TableSinkWriteBytes'
+ | 'TableSinkWriteCount'
+ | 'TableSinkWriteQPS'
+ | 'TableSinkWriteBytesPerSeconds'
+) => {
+ if (row.type === 'sink') {
+ return row.tablePaths.reduce(
+ (s, path) => s + readVertexMetricValue(job.metrics?.[key], row, path),
+ 0
+ )
+ }
+ return 0
+}
+
const columns: DataTableColumns<Vertex> = [
{
title: 'Name',
@@ -179,40 +188,28 @@ export default defineComponent({
drawerShow.value = false
}
const focusedVertex = computed(() => {
- const vertex = job.jobDag?.vertexInfoMap?.find((v) => v.vertexId ===
focusedId.value)
- const metrics = {} as any
- if (vertex?.type === 'source') {
- Object.keys(job.metrics?.TableSourceReceivedBytes || {}).forEach((key)
=> {
- metrics[`TableSourceReceivedBytes.${key}`] =
job.metrics?.TableSourceReceivedBytes[key]
- })
- Object.keys(job.metrics?.TableSourceReceivedCount || {}).forEach((key)
=> {
- metrics[`TableSourceReceivedCount.${key}`] =
job.metrics?.TableSourceReceivedCount[key]
- })
- Object.keys(job.metrics?.TableSourceReceivedQPS || {}).forEach((key)
=> {
- metrics[`TableSourceReceivedQPS.${key}`] =
job.metrics?.TableSourceReceivedQPS[key]
- })
- Object.keys(job.metrics?.TableSourceReceivedBytesPerSeconds ||
{}).forEach((key) => {
- metrics[`TableSourceReceivedBytesPerSeconds.${key}`] =
- job.metrics?.TableSourceReceivedBytesPerSeconds[key]
- })
- }
- if (vertex?.type === 'sink') {
- Object.keys(job.metrics?.TableSinkWriteBytes || {}).forEach((key) => {
- metrics[`TableSinkWriteBytes.${key}`] =
job.metrics?.TableSinkWriteBytes[key]
- })
- Object.keys(job.metrics?.TableSinkWriteCount || {}).forEach((key) => {
- metrics[`TableSinkWriteCount.${key}`] =
job.metrics?.TableSinkWriteCount[key]
- })
- Object.keys(job.metrics?.TableSinkWriteQPS || {}).forEach((key) => {
- metrics[`TableSinkWriteQPS.${key}`] =
job.metrics?.TableSinkWriteQPS[key]
- })
- Object.keys(job.metrics?.TableSinkWriteBytesPerSeconds ||
{}).forEach((key) => {
- metrics[`TableSinkWriteBytesPerSeconds.${key}`] =
- job.metrics?.TableSinkWriteBytesPerSeconds[key]
- })
- }
- return Object.assign({}, vertex, metrics)
- })
+ const vertex = job.jobDag?.vertexInfoMap?.find((v) => v.vertexId ===
focusedId.value)
+ const metrics = {} as any
+ if (vertex?.type === 'source') {
+ Object.assign(
+ metrics,
+ collectVertexMetrics('TableSourceReceivedBytes',
job.metrics?.TableSourceReceivedBytes, vertex),
+ collectVertexMetrics('TableSourceReceivedCount',
job.metrics?.TableSourceReceivedCount, vertex),
+ collectVertexMetrics('TableSourceReceivedQPS',
job.metrics?.TableSourceReceivedQPS, vertex),
+ collectVertexMetrics('TableSourceReceivedBytesPerSeconds',
job.metrics?.TableSourceReceivedBytesPerSeconds, vertex),
+ )
+ }
+ if (vertex?.type === 'sink') {
+ Object.assign(
+ metrics,
+ collectVertexMetrics('TableSinkWriteBytes',
job.metrics?.TableSinkWriteBytes, vertex),
+ collectVertexMetrics('TableSinkWriteCount',
job.metrics?.TableSinkWriteCount, vertex),
+ collectVertexMetrics('TableSinkWriteQPS',
job.metrics?.TableSinkWriteQPS, vertex),
+ collectVertexMetrics('TableSinkWriteBytesPerSeconds',
job.metrics?.TableSinkWriteBytesPerSeconds, vertex),
+ )
+ }
+ return Object.assign({}, vertex, metrics)
+})
const rowClassName = (row: Vertex) => {
if (row.vertexId === focusedId.value) {
return 'focused-row'