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'

Reply via email to