This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 6ee0c37447 [Improve][Zeta] Make job detail overview responsive (#12259)
6ee0c37447 is described below
commit 6ee0c374478a7c8be090e3eb33895027c5ac2ffd
Author: zhiweiniu <[email protected]>
AuthorDate: Fri Sep 11 12:58:37 2026 +0000
[Improve][Zeta] Make job detail overview responsive (#12259)
---
.../src/components/live-metrics-chart/board.tsx | 38 +++--
.../src/components/live-metrics-chart/index.tsx | 87 +++++++-----
.../seatunnel-engine-ui/src/views/jobs/detail.scss | 156 ++++++++++++++++++++-
.../seatunnel-engine-ui/src/views/jobs/detail.tsx | 143 +++++++++++--------
4 files changed, 313 insertions(+), 111 deletions(-)
diff --git
a/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/board.tsx
b/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/board.tsx
index af4d3ed7fd..a22af9c7b4 100644
---
a/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/board.tsx
+++
b/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/board.tsx
@@ -36,7 +36,7 @@ export default defineComponent({
default: 'No metrics'
},
height: {
- type: Number,
+ type: [Number, String] as PropType<number | string>,
default: 220
},
unitTitles: {
@@ -50,29 +50,41 @@ export default defineComponent({
}
},
setup(props) {
+ // A pinned metric stays visible even before realtime samples arrive.
Removing an empty
+ // group would make the layout jump and would make a successful pin look
like it was ignored.
const groups = computed(() => groupSeriesByUnit(props.series || []))
return () => {
if (!groups.value.length) {
- return <div class="text-sm text-gray-400 py-2 text-center
leading-6">{props.emptyText}</div>
+ return (
+ <div class="live-metrics-board-empty text-sm text-gray-400 py-2
text-center leading-6">
+ {props.emptyText}
+ </div>
+ )
}
- const n = groups.value.length
- const gridClass =
+ const gridClass = props.layout === 'row' ? 'grid gap-2' : 'flex flex-col
gap-3'
+ const gridStyle =
props.layout === 'row'
- ? n >= 3
- ? 'grid grid-cols-3 gap-2'
- : n === 2
- ? 'grid grid-cols-2 gap-2'
- : 'grid grid-cols-1 gap-2'
- : 'flex flex-col gap-3'
+ ? {
+ gridTemplateColumns:
+ groups.value.length === 1
+ ? 'minmax(0, calc((100% - 8px) / 2))'
+ : 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))'
+ }
+ : undefined
return (
- <div class={gridClass}>
+ <div
+ class={`${gridClass} live-metrics-board ${
+ groups.value.length === 1 ? 'live-metrics-board-single' : ''
+ }`}
+ style={gridStyle}
+ >
{groups.value.map((group) => (
- <div key={group.unit} class="min-w-0">
+ <div key={group.unit} class="live-metrics-group min-w-0">
<div class="text-xs text-gray-500 mb-1 leading-4">
{props.unitTitles[group.unit] || group.unit}
</div>
- <div class="bg-white rounded border border-gray-100
overflow-hidden">
+ <div class="live-metrics-chart-container bg-white rounded border
border-gray-100 overflow-hidden">
<LiveLineChart
series={group.series}
windowMs={props.windowMs}
diff --git
a/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/index.tsx
b/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/index.tsx
index 65a9db069f..7a068d1a8f 100644
---
a/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/index.tsx
+++
b/seatunnel-engine/seatunnel-engine-ui/src/components/live-metrics-chart/index.tsx
@@ -57,7 +57,7 @@ export default defineComponent({
default: 'No metrics'
},
height: {
- type: Number,
+ type: [Number, String] as PropType<number | string>,
default: 260
},
unit: {
@@ -92,7 +92,11 @@ export default defineComponent({
const from = now - props.windowMs
const unit = props.unit || series[0]?.unit
const single = series.length === 1
- const compact = props.height < 160
+ const compact =
+ props.height === '100%' || (typeof props.height === 'number' &&
props.height < 160)
+ const isVisiblePoint = (point: { ts: number; value: number }) =>
+ point.ts >= from && Number.isFinite(point.value)
+ const hasVisiblePoints = series.some((item) => (item.points ||
[]).some(isVisiblePoint))
const yAxis =
unit === 'ratio'
@@ -101,8 +105,6 @@ export default defineComponent({
min: 0,
max: 1,
scale: false,
- name: compact ? '' : '%',
- nameTextStyle: { color: '#999', padding: [0, 0, 0, 8], fontSize:
10 },
axisLabel: {
fontSize: compact ? 9 : 10,
margin: compact ? 4 : 8,
@@ -111,26 +113,39 @@ export default defineComponent({
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
}
: {
+ show: unit !== 'count' || hasVisiblePoints,
type: 'value' as const,
min: compact ? 0 : undefined,
+ minInterval: unit === 'count' ? 1 : undefined,
scale: !compact,
- name: compact ? '' : unit === 'duration' ? 'ms' : '',
- nameTextStyle: { color: '#999', fontSize: 10 },
axisLabel: { fontSize: compact ? 9 : 10, margin: compact ? 4 : 8
},
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
}
chart.setOption(
{
- title: single
- ? {
- show: true,
- text: series[0].name,
- left: 0,
- top: 0,
- textStyle: { color: '#666', fontSize: compact ? 11 : 12,
fontWeight: 'normal' }
- }
- : { show: false },
+ title:
+ unit === 'count' && !hasVisiblePoints
+ ? {
+ show: true,
+ text: props.emptyText,
+ left: 'center',
+ top: 'middle',
+ textStyle: {
+ color: '#9ca3af',
+ fontSize: compact ? 10 : 12,
+ fontWeight: 'normal'
+ }
+ }
+ : single && !compact
+ ? {
+ show: true,
+ text: series[0].name,
+ left: 0,
+ top: 0,
+ textStyle: { color: '#666', fontSize: compact ? 11 : 12,
fontWeight: 'normal' }
+ }
+ : { show: false },
color: COLORS,
tooltip: {
trigger: 'axis',
@@ -152,24 +167,27 @@ export default defineComponent({
return [time, ...lines].join('<br/>')
}
},
- legend: single
- ? { show: false }
- : {
- type: 'scroll',
- top: 0,
- itemWidth: compact ? 10 : 14,
- itemHeight: compact ? 8 : 10,
- textStyle: { fontSize: compact ? 10 : 11 },
- data: series.map((s) => s.name)
- },
+ legend:
+ single && !compact
+ ? { show: false }
+ : {
+ type: 'scroll',
+ top: 0,
+ itemWidth: compact ? 10 : 14,
+ itemHeight: compact ? 8 : 10,
+ textStyle: { fontSize: compact ? 10 : 11 },
+ data: series.map((s) => s.name)
+ },
grid: {
left: compact ? 28 : 48,
right: compact ? 4 : 24,
- top: compact ? (single ? 16 : 20) : single ? 28 : 36,
- bottom: compact ? 0 : 32,
+ // Reserve one line for the legend so it never overlaps the
highest Y-axis label.
+ top: compact ? 30 : single ? 28 : 36,
+ bottom: compact ? 10 : 32,
containLabel: !compact
},
xAxis: {
+ show: unit !== 'count' || hasVisiblePoints,
type: 'time',
min: from,
max: now,
@@ -207,7 +225,7 @@ export default defineComponent({
}
: undefined,
data: [...(s.points || [])]
- .filter((p) => p.ts >= from)
+ .filter(isVisiblePoint)
.sort((a, b) => a.ts - b.ts)
.map((p) => [p.ts, p.value])
}
@@ -240,12 +258,11 @@ export default defineComponent({
{ deep: true }
)
- return () => (
- <div
- ref={el}
- class="w-full"
- style={{ height: `${props.height}px`, minHeight: `${props.height}px` }}
- />
- )
+ return () => {
+ const height = typeof props.height === 'number' ? `${props.height}px` :
props.height
+ return (
+ <div ref={el} class="live-metrics-chart w-full" style={{ height,
minHeight: height }} />
+ )
+ }
}
})
diff --git a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.scss
b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.scss
index 3b0f2506f3..d9f3a25414 100644
--- a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.scss
+++ b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.scss
@@ -15,11 +15,163 @@
* limitations under the License.
*/
- .n-data-table .n-data-table-tr.focused-row {
+.job-detail-page {
+ box-sizing: border-box;
+ min-width: 0;
+ min-height: calc(100dvh - 101px);
+ container-type: inline-size;
+ padding: clamp(16px, 1.667vw, 32px) clamp(16px, 3.333vw, 80px) clamp(24px,
3.333vw, 64px);
+}
+
+.job-detail-title,
+.job-detail-meta,
+.job-detail-meta-item,
+.live-metrics-header {
+ display: flex;
+ align-items: center;
+}
+
+.job-detail-title {
+ flex-wrap: wrap;
+ gap: 8px 12px;
+}
+
+.job-detail-meta {
+ flex-wrap: wrap;
+ gap: 8px 12px;
+}
+
+.job-detail-meta-item {
+ flex: 0 0 auto;
+ gap: 12px;
+ white-space: nowrap;
+}
+
+.job-detail-meta-divider {
+ margin: 0;
+}
+
+.live-metrics-header {
+ flex-wrap: wrap;
+ gap: 4px 12px;
+}
+
+.job-detail-overview {
+ display: grid;
+ height: max(576px, calc(90dvh - 225px));
+ min-height: 0;
+ gap: 8px;
+}
+
+.job-detail-overview.has-pinned-metrics {
+ grid-template-rows: minmax(220px, 4fr) minmax(180px, 3fr) minmax(140px, 3fr);
+}
+
+.job-detail-overview.has-no-pinned-metrics {
+ grid-template-rows: minmax(220px, 5fr) minmax(72px, 1fr) minmax(140px, 4fr);
+}
+
+.job-detail-overview .dag-wrapper {
+ height: 100%;
+ min-height: 0;
+ margin-bottom: 0;
+}
+
+.job-detail-pinned-metrics {
+ position: relative;
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.job-detail-pinned-metrics .live-metrics-board,
+.job-detail-pinned-metrics .live-metrics-group {
+ min-height: 0;
+}
+
+.job-detail-pinned-metrics .live-metrics-board,
+.job-detail-pinned-metrics .live-metrics-board-empty {
+ flex: 1;
+}
+
+.job-detail-pinned-metrics .live-metrics-board-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.job-detail-pinned-metrics .live-metrics-group {
+ display: flex;
+ flex-direction: column;
+}
+
+.job-detail-pinned-metrics .live-metrics-chart-container {
+ min-height: 0;
+ flex: 1;
+}
+
+.job-detail-table {
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+}
+
+.n-data-table .n-data-table-tr.focused-row {
&,
&:hover {
td.n-data-table-td {
background-color: #e6f7ff;
}
}
- }
\ No newline at end of file
+}
+
+@container (max-width: 720px) {
+ .job-detail-overview {
+ display: block;
+ height: auto;
+ }
+
+ .job-detail-overview .dag-wrapper {
+ height: 220px;
+ margin-bottom: 8px;
+ }
+
+ .job-detail-pinned-metrics {
+ min-height: 180px;
+ margin-bottom: 8px;
+ }
+
+ .job-detail-pinned-metrics .live-metrics-chart {
+ height: 140px !important;
+ min-height: 140px !important;
+ }
+
+ .job-detail-pinned-metrics .live-metrics-board-single {
+ grid-template-columns: minmax(0, 1fr) !important;
+ }
+
+ .job-detail-table {
+ height: auto;
+ overflow: visible;
+ }
+
+ .job-detail-meta {
+ align-items: flex-start;
+ }
+
+ .job-detail-meta-divider {
+ display: none;
+ }
+
+ .live-metrics-header > :last-child {
+ flex-basis: 100%;
+ }
+
+ .job-detail-drawer {
+ width: 92% !important;
+ }
+}
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 ac8eaf0b31..1e113c0056 100644
--- a/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
+++ b/seatunnel-engine/seatunnel-engine-ui/src/views/jobs/detail.tsx
@@ -360,6 +360,8 @@ export default defineComponent({
)
})
+ const hasPinnedMetrics = computed(() => pinStore.pins.length > 0)
+
const drawerVertexChartSeries = computed(() => {
const vertex = job.jobDag?.vertexInfoMap?.find((v) => v.vertexId ===
focusedId.value)
if (!vertex) return []
@@ -557,83 +559,101 @@ export default defineComponent({
}
return () => (
- <div class="w-full bg-white px-12 pt-6 pb-12 border border-gray-100
rounded-xl">
- <div class="font-bold text-xl">
- {job.jobName}
- <NTag bordered={false} color={getColorFromStatus(job.jobStatus)}
class="ml-3">
+ <div class="job-detail-page w-full bg-white border border-gray-100
rounded-xl">
+ <div class="job-detail-title font-bold text-xl">
+ <span>{job.jobName}</span>
+ <NTag bordered={false} color={getColorFromStatus(job.jobStatus)}>
{job.jobStatus}
</NTag>
{realtimeError.value ? (
<span title={realtimeError.value}>
- <NTag bordered={false} type="warning" class="ml-3">
+ <NTag bordered={false} type="warning">
Realtime metrics unavailable
</NTag>
</span>
) : null}
</div>
- <div class="mt-3 flex items-center gap-3">
- <span>{t('detail.id')}:</span>
- <span class="font-bold">{job.jobId}</span>
- <NDivider vertical />
- <span>{t('detail.createTime')}:</span>
- <span class="font-bold">{job.createTime}</span>
- <NDivider vertical />
- <span>{t('detail.duration')}:</span>
- <span class="font-bold">{duration.value}</span>
+ <div class="job-detail-meta mt-3">
+ <div class="job-detail-meta-item">
+ <span>{t('detail.id')}:</span>
+ <span class="font-bold">{job.jobId}</span>
+ </div>
+ <NDivider class="job-detail-meta-divider" vertical />
+ <div class="job-detail-meta-item">
+ <span>{t('detail.createTime')}:</span>
+ <span class="font-bold">{job.createTime}</span>
+ </div>
+ <NDivider class="job-detail-meta-divider" vertical />
+ <div class="job-detail-meta-item">
+ <span>{t('detail.duration')}:</span>
+ <span class="font-bold">{duration.value}</span>
+ </div>
</div>
<div class="tab-wrap relative">
<NTabs v-model:value={select.value} type="line" animated>
<NTabPane name="Overview" tab={t('detail.tabs.overview')}>
- <DAG
- job={job}
- focusedId={focusedId.value}
- onNodeClick={onFocus}
- onEdgeClick={onEdgeFocus}
- realtimeEdgeStats={realtimeEdgeStats.value}
- realtimeVertexStats={realtimeVertexStats.value}
- realtimeTick={realtimeTick.value}
- />
- <div class="mt-2 mb-2 border border-gray-100 rounded-lg px-3
pt-2 pb-2 bg-gray-50">
- <div class="flex items-baseline justify-between mb-2">
- <div class="font-semibold
text-base">{t('detail.liveMetrics.pinnedTitle')}</div>
- <div class="text-xs text-gray-500">
- {t('detail.liveMetrics.pinnedHint', { limit:
LIVE_METRICS_PIN_LIMIT })}
- {pinStore.pins.length
- ? ` · ${pinStore.pins.length}/${LIVE_METRICS_PIN_LIMIT}`
- : ''}
+ <div
+ class={`job-detail-overview ${
+ hasPinnedMetrics.value ? 'has-pinned-metrics' :
'has-no-pinned-metrics'
+ }`}
+ >
+ <DAG
+ job={job}
+ focusedId={focusedId.value}
+ onNodeClick={onFocus}
+ onEdgeClick={onEdgeFocus}
+ realtimeEdgeStats={realtimeEdgeStats.value}
+ realtimeVertexStats={realtimeVertexStats.value}
+ realtimeTick={realtimeTick.value}
+ />
+ <div class="job-detail-pinned-metrics border border-gray-100
rounded-lg px-3 pt-2 pb-2 bg-gray-50">
+ <div class="live-metrics-header flex items-baseline
justify-between mb-2">
+ <div class="font-semibold
text-base">{t('detail.liveMetrics.pinnedTitle')}</div>
+ <div class="text-xs text-gray-500">
+ {t('detail.liveMetrics.pinnedHint', { limit:
LIVE_METRICS_PIN_LIMIT })}
+ {pinStore.pins.length
+ ? ` ·
${pinStore.pins.length}/${LIVE_METRICS_PIN_LIMIT}`
+ : ''}
+ </div>
</div>
+ {pinStore.pins.length ? (
+ <NSpace class="mb-2" size="small" wrap>
+ {pinStore.pins.map((p) => (
+ <NTag key={p.id} closable type="info" onClose={() =>
pinStore.unpin(p.id)}>
+ {p.name}
+ </NTag>
+ ))}
+ </NSpace>
+ ) : null}
+ <LiveMetricsBoard
+ series={pinnedSeries.value}
+ windowMs={effectiveRealtimeWindowMs(realtimeWindowMs)}
+ emptyText={
+ pinStore.pins.length
+ ? t('detail.liveMetrics.chartEmpty')
+ : t('detail.liveMetrics.emptyPinned')
+ }
+ height="100%"
+ layout="row"
+ unitTitles={{
+ ratio: t('detail.liveMetrics.unitRatio'),
+ duration: t('detail.liveMetrics.unitDuration'),
+ count: t('detail.liveMetrics.unitCount')
+ }}
+ />
+ </div>
+ <div class="job-detail-table">
+ <NDataTable
+ columns={columns}
+ data={tableData.value}
+ pagination={false}
+ scrollX={1200}
+ bordered
+ rowClassName={rowClassName}
+ rowProps={rowProps}
+ />
</div>
- {pinStore.pins.length ? (
- <NSpace class="mb-2" size="small" wrap>
- {pinStore.pins.map((p) => (
- <NTag key={p.id} closable type="info" onClose={() =>
pinStore.unpin(p.id)}>
- {p.name}
- </NTag>
- ))}
- </NSpace>
- ) : null}
- <LiveMetricsBoard
- series={pinnedSeries.value}
- windowMs={effectiveRealtimeWindowMs(realtimeWindowMs)}
- emptyText={t('detail.liveMetrics.emptyPinned')}
- height={140}
- layout="row"
- unitTitles={{
- ratio: t('detail.liveMetrics.unitRatio'),
- duration: t('detail.liveMetrics.unitDuration'),
- count: t('detail.liveMetrics.unitCount')
- }}
- />
</div>
- <NDataTable
- columns={columns}
- data={tableData.value}
- pagination={false}
- scrollX="auto"
- bordered
- rowClassName={rowClassName}
- rowProps={rowProps}
- />
</NTabPane>
<NTabPane name="Exception" tab={t('detail.tabs.exception')}>
<pre style="white-space: pre-wrap; word-wrap: break-word;
background-color: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;
max-height: 600px; font-family: monospace; line-height: 1.5;">
@@ -648,6 +668,7 @@ export default defineComponent({
</NTabPane>
</NTabs>
<NDrawer
+ class="job-detail-drawer"
show={select.value === 'Overview' && drawerShow.value}
showMask={false}
width={'40%'}