This is an automated email from the ASF dual-hosted git repository.
ferenc-csaky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 12c944d5def [FLINK-39566][runtime-web] Add checkpoint duration Gantt
view
12c944d5def is described below
commit 12c944d5def4edebe93e68f942922fca4b0d7b19
Author: Purushottam Sinha <[email protected]>
AuthorDate: Fri Nov 6 19:10:17 2026 +0530
[FLINK-39566][runtime-web] Add checkpoint duration Gantt view
Generated-by: Claude Code (Claude Opus 5.5)
---
.../docs/ops/monitoring/checkpoint_monitoring.md | 16 +-
.../docs/ops/monitoring/checkpoint_monitoring.md | 14 +-
.../src/app/interfaces/job-checkpoint.ts | 2 +-
.../job/checkpoints/job-checkpoints.component.html | 12 +-
.../checkpoints/job-checkpoints.component.spec.ts | 18 +-
.../job/checkpoints/job-checkpoints.component.ts | 4 +-
.../timeline/checkpoint-timeline-rows.spec.ts | 190 +++++
.../timeline/checkpoint-timeline-rows.ts | 267 ++++++
.../timeline/checkpoint-timeline.testing.ts | 70 ++
.../job-checkpoints-timeline.component.html | 202 +++++
.../job-checkpoints-timeline.component.less | 201 +++++
.../job-checkpoints-timeline.component.spec.ts | 672 +++++++++++++++
.../timeline/job-checkpoints-timeline.component.ts | 913 +++++++++++++++++++++
.../job/timeline/job-timeline.component.spec.ts | 20 +-
.../pages/job/timeline/job-timeline.component.ts | 24 +-
.../src/app/services/job.service.spec.ts | 41 +-
.../web-dashboard/src/app/services/job.service.ts | 39 +-
.../web-dashboard/src/app/utils/public-api.ts | 1 +
.../web-dashboard/src/app/utils/time-axis.spec.ts | 43 +
.../web-dashboard/src/app/utils/time-axis.ts | 42 +
20 files changed, 2735 insertions(+), 56 deletions(-)
diff --git a/docs/content.zh/docs/ops/monitoring/checkpoint_monitoring.md
b/docs/content.zh/docs/ops/monitoring/checkpoint_monitoring.md
index 419151b6bcf..dfadfe3aed2 100644
--- a/docs/content.zh/docs/ops/monitoring/checkpoint_monitoring.md
+++ b/docs/content.zh/docs/ops/monitoring/checkpoint_monitoring.md
@@ -31,7 +31,7 @@ under the License.
## 概览(Overview)
-Flink 的 Web 界面提供了`选项卡/标签(tab)`来监视作业的 checkpoint
信息。作业终止后,这些统计信息仍然可用。有四个不同的选项卡可显示有关 checkpoint
的信息:概览(Overview),历史记录(History),摘要信息(Summary)和配置信息(Configuration)。以下各节将依次介绍这些内容。
+Flink 的 Web 界面提供了`选项卡/标签(tab)`来监视作业的 checkpoint
信息。作业终止后,这些统计信息仍然可用。有五个不同的选项卡可显示有关 checkpoint
的信息:概览(Overview),时间线(Timeline),历史记录(History),摘要信息(Summary)和配置信息(Configuration)。以下各节将依次介绍这些内容。
<a name="monitoring"></a>
@@ -56,6 +56,20 @@ Flink 的 Web 界面提供了`选项卡/标签(tab)`来监视作业的 check
- Restore from Checkpoint:从 checkpoint 恢复。
- Restore from Savepoint:从 savepoint 恢复。
+<a name="timeline-tab"></a>
+
+### Timeline Tab
+
+The timeline tab shows where the time of a checkpoint went, subtask by subtask.
+
+- **Recent Checkpoints**: One bar per checkpoint in the
[history](#history-size-configuration), colored by status (completed,
savepoint, in progress, failed). The bar height is the end to end duration.
Hover over a bar for its status, duration and acknowledged subtasks.
+- **Per-subtask timeline**: One row per subtask, in operator then subtask
order, plotted against wall-clock time from the moment the checkpoint was
triggered. Each row stacks the subtask's phases: start delay, alignment, sync
and async (see [History Tab](#history-tab) for what they mean). With unaligned
checkpoints, only the alignment before the switch to unaligned is drawn: the
switch happens at the first barrier, or once
`execution.checkpointing.aligned-checkpoint-timeout` has passed s [...]
+ - Subtasks that started their snapshot but did not finish it before the
checkpoint failed are outlined as *aborted*.
+ - Subtasks that reported no statistics get a hatched bar spanning the
checkpoint: *pending* while the checkpoint is in progress, *no ack* once it has
failed.
+ - Subtasks whose phases all took 0 ms, for example tasks that have
already finished, are marked with a thin line at the trigger time.
+
+By default the per-subtask timeline follows the newest checkpoint. Click a bar
in *Recent Checkpoints* to pin that checkpoint instead, and click it again or
click *Follow newest* to resume following. While the tab is open, it refreshes
on its own at the checkpoint interval, bounded between 1.5 and 15 seconds.
+
<a name="history-tab"></a>
### 历史记录(History)选项卡
diff --git a/docs/content/docs/ops/monitoring/checkpoint_monitoring.md
b/docs/content/docs/ops/monitoring/checkpoint_monitoring.md
index b961ae8ce78..a84e6d2a021 100644
--- a/docs/content/docs/ops/monitoring/checkpoint_monitoring.md
+++ b/docs/content/docs/ops/monitoring/checkpoint_monitoring.md
@@ -29,7 +29,7 @@ under the License.
## Overview
-Flink's web interface provides a tab to monitor the checkpoints of jobs. These
stats are also available after the job has terminated. There are four different
tabs to display information about your checkpoints: Overview, History, Summary,
and Configuration. The following sections will cover all of these in turn.
+Flink's web interface provides a tab to monitor the checkpoints of jobs. These
stats are also available after the job has terminated. There are five different
tabs to display information about your checkpoints: Overview, Timeline,
History, Summary, and Configuration. The following sections will cover all of
these in turn.
## Monitoring
@@ -50,6 +50,18 @@ The overview tabs lists the following statistics. Note that
these statistics don
- Restore from Checkpoint: We restored from a regular periodic
checkpoint.
- Restore from Savepoint: We restored from a savepoint.
+### Timeline Tab
+
+The timeline tab shows where the time of a checkpoint went, subtask by subtask.
+
+- **Recent Checkpoints**: One bar per checkpoint in the
[history](#history-size-configuration), colored by status (completed,
savepoint, in progress, failed). The bar height is the end to end duration.
Hover over a bar for its status, duration and acknowledged subtasks.
+- **Per-subtask timeline**: One row per subtask, in operator then subtask
order, plotted against wall-clock time from the moment the checkpoint was
triggered. Each row stacks the subtask's phases: start delay, alignment, sync
and async (see [History Tab](#history-tab) for what they mean). With unaligned
checkpoints, only the alignment before the switch to unaligned is drawn: the
switch happens at the first barrier, or once
`execution.checkpointing.aligned-checkpoint-timeout` has passed s [...]
+ - Subtasks that started their snapshot but did not finish it before the
checkpoint failed are outlined as *aborted*.
+ - Subtasks that reported no statistics get a hatched bar spanning the
checkpoint: *pending* while the checkpoint is in progress, *no ack* once it has
failed.
+ - Subtasks whose phases all took 0 ms, for example tasks that have
already finished, are marked with a thin line at the trigger time.
+
+By default the per-subtask timeline follows the newest checkpoint. Click a bar
in *Recent Checkpoints* to pin that checkpoint instead, and click it again or
click *Follow newest* to resume following. While the tab is open, it refreshes
on its own at the checkpoint interval, bounded between 1.5 and 15 seconds.
+
### History Tab
The checkpoint history keeps statistics about recently triggered checkpoints,
including those that are currently in progress.
diff --git
a/flink-runtime-web/web-dashboard/src/app/interfaces/job-checkpoint.ts
b/flink-runtime-web/web-dashboard/src/app/interfaces/job-checkpoint.ts
index 5979a7d0cd0..27a7835cf06 100644
--- a/flink-runtime-web/web-dashboard/src/app/interfaces/job-checkpoint.ts
+++ b/flink-runtime-web/web-dashboard/src/app/interfaces/job-checkpoint.ts
@@ -57,8 +57,8 @@ export interface Checkpoint {
is_savepoint: boolean;
external_path: string;
};
- history: CheckpointHistory;
};
+ history: CheckpointHistory[];
}
export interface CheckpointHistory {
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.html
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.html
index 692b9cbff0b..49518fd7f3e 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.html
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.html
@@ -222,11 +222,19 @@
</tbody>
</nz-table>
</nz-tab>
+ <nz-tab nzTitle="Timeline">
+ <flink-job-checkpoints-timeline
+ [jobId]="jobDetail?.jid"
+ [vertices]="jobDetail?.vertices || []"
+ [history]="checkPointStats?.history || []"
+ [config]="checkPointConfig"
+ ></flink-job-checkpoints-timeline>
+ </nz-tab>
<nz-tab nzTitle="History">
<nz-table
class="no-border small"
[nzSize]="'small'"
- [nzData]="checkPointStats['history'] || []"
+ [nzData]="checkPointStats?.history || []"
[nzFrontPagination]="false"
[nzShowPagination]="false"
>
@@ -256,7 +264,7 @@
</tr>
</thead>
<tbody>
- <ng-container *ngFor="let checkpoint of checkPointStats['history'];
trackBy: trackById">
+ <ng-container *ngFor="let checkpoint of checkPointStats?.history;
trackBy: trackById">
<tr>
<td nzShowExpand [(nzExpand)]="checkpoint.expand"></td>
<td>{{ checkpoint['id'] }}</td>
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
index 7872f607f33..4d33e2522be 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
@@ -16,6 +16,7 @@
* limitations under the License.
*/
+import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
@@ -26,6 +27,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { JobCheckpointsComponent } from './job-checkpoints.component';
import { JobLocalService } from '../job-local.service';
+import { JobCheckpointsTimelineComponent } from
'./timeline/job-checkpoints-timeline.component';
const mockStats = {
counts: { restored: 0, total: 3, in_progress: 0, completed: 3, failed: 0 },
@@ -49,6 +51,15 @@ const mockConfig = {
state_changelog_enabled: false
};
+// The real timeline draws with G2 on a canvas, which jsdom does not provide.
+@Component({ selector: 'flink-job-checkpoints-timeline', template: '' })
+class JobCheckpointsTimelineStubComponent {
+ @Input() jobId?: string;
+ @Input() vertices: unknown[] = [];
+ @Input() history: unknown[] = [];
+ @Input() config?: unknown;
+}
+
describe('JobCheckpointsComponent', () => {
let fixture: ComponentFixture<JobCheckpointsComponent>;
let element: HTMLElement;
@@ -65,7 +76,12 @@ describe('JobCheckpointsComponent', () => {
{ provide: JobService, useValue: { loadCheckpointStats,
loadCheckpointConfig } },
{ provide: JobLocalService, useValue: { jobDetailChanges: () => of({
jid: 'job-1' }) } }
]
- }).compileComponents();
+ })
+ .overrideComponent(JobCheckpointsComponent, {
+ remove: { imports: [JobCheckpointsTimelineComponent] },
+ add: { imports: [JobCheckpointsTimelineStubComponent] }
+ })
+ .compileComponents();
fixture = TestBed.createComponent(JobCheckpointsComponent);
element = fixture.nativeElement as HTMLElement;
});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.ts
index 279f50efa9b..8566a1cc09c 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.ts
@@ -26,6 +26,7 @@ import { HumanizeBytesPipe } from
'@flink-runtime-web/components/humanize-bytes.
import { HumanizeDurationPipe } from
'@flink-runtime-web/components/humanize-duration.pipe';
import { CheckpointConfig, CheckpointHistory, Checkpoint, JobDetailCorrect }
from '@flink-runtime-web/interfaces';
import { JobCheckpointsDetailComponent } from
'@flink-runtime-web/pages/job/checkpoints/detail/job-checkpoints-detail.component';
+import { JobCheckpointsTimelineComponent } from
'@flink-runtime-web/pages/job/checkpoints/timeline/job-checkpoints-timeline.component';
import { JobService } from '@flink-runtime-web/services';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCollapseModule } from 'ng-zorro-antd/collapse';
@@ -59,7 +60,8 @@ import { JobLocalService } from '../job-local.service';
NzButtonModule,
NzEmptyModule,
NzCollapseModule,
- NzTooltipModule
+ NzTooltipModule,
+ JobCheckpointsTimelineComponent
]
})
export class JobCheckpointsComponent implements OnInit, OnDestroy {
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.spec.ts
new file mode 100644
index 00000000000..352ab73411a
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.spec.ts
@@ -0,0 +1,190 @@
+/*
+ * 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 { CheckpointConfig, CheckpointHistory, CheckpointSubTask, VerticesItem
} from '@flink-runtime-web/interfaces';
+import { describe, expect, it } from 'vitest';
+
+import { buildTimelineRows, TimelineCheckpoint, TimelineRows, toStripDatum }
from './checkpoint-timeline-rows';
+import { completed, details, noStats, TRIGGERED, vertex } from
'./checkpoint-timeline.testing';
+
+const checkpoint = (rawStatus: string, duration: number, isSavepoint = false):
TimelineCheckpoint => ({
+ duration,
+ rawStatus,
+ isSavepoint,
+ triggered: TRIGGERED
+});
+
+const build = (
+ datum: TimelineCheckpoint,
+ vertices: VerticesItem[],
+ perVertex: Record<string, CheckpointSubTask | null>,
+ config?: CheckpointConfig
+): TimelineRows => buildTimelineRows(datum, vertices, perVertex, config);
+
+const unalignedConfig = (alignedCheckpointTimeout: number, mode =
'exactly_once'): CheckpointConfig =>
+ ({ mode, unaligned_checkpoints: true, aligned_checkpoint_timeout:
alignedCheckpointTimeout }) as CheckpointConfig;
+
+const SUBTASK_PHASES = { start_delay: 2, alignment: 5, sync: 1, async: 8 };
+
+const segments = (rows: TimelineRows['rows'], row: string): Array<[string,
string, [number, number], number]> =>
+ rows.filter(r => r.row === row).map(r => [r.phase, r.phaseLabel, r.range,
r.totalDuration]);
+
+describe('buildTimelineRows', () => {
+ const agg = [vertex('a', 'Agg')];
+
+ it('orders rows by operator, in job graph order, then by subtask index', ()
=> {
+ // Chosen so that every other order differs: the response order, by
operator name, by subtask index alone,
+ // and by duration either way.
+ const { rowOrder } = build(checkpoint('COMPLETED', 60), [vertex('s',
'Source'), vertex('a', 'Agg')], {
+ s: details(completed(1, { async: 1 }), completed(0, { async: 30 })),
+ a: details(completed(2, { async: 5 }), completed(0, { async: 50 }),
completed(1, { async: 20 }))
+ });
+
+ expect(rowOrder).toEqual(['Source #0', 'Source #1', 'Agg #0', 'Agg #1',
'Agg #2']);
+ });
+
+ it('keeps a row with an instant marker for subtasks whose phases all rounded
to 0ms', () => {
+ const { instants, rowOrder } = build(checkpoint('COMPLETED', 15),
[vertex('s', 'Source')], {
+ s: details(completed(0, {}), completed(1, { start_delay: 1 }))
+ });
+
+ expect(rowOrder).toEqual(['Source #0', 'Source #1']);
+ expect(instants).toEqual([
+ expect.objectContaining({ row: 'Source #0', aborted: false, range:
[TRIGGERED, TRIGGERED] })
+ ]);
+ });
+
+ it('draws a no-ack bar spanning a failed checkpoint for subtasks without
stats', () => {
+ const { unacked, rowOrder } = build(checkpoint('FAILED', 10), agg, {
+ a: details(completed(0, { start_delay: 1, async: 3 }, true), noStats(1),
completed(2, { async: 2 }))
+ });
+
+ expect(unacked).toEqual([
+ expect.objectContaining({ row: 'Agg #1', label: 'no ack', range:
[TRIGGERED, TRIGGERED + 10] })
+ ]);
+ expect(rowOrder).toEqual(['Agg #0', 'Agg #1', 'Agg #2']);
+ });
+
+ it('marks subtasks without stats as pending up to the latest known progress
while in progress', () => {
+ const { unacked } = build(checkpoint('IN_PROGRESS', 0), agg, {
+ a: details(completed(0, { start_delay: 1, async: 4 }), noStats(1))
+ });
+
+ expect(unacked).toEqual([expect.objectContaining({ label: 'pending',
range: [TRIGGERED, TRIGGERED + 5] })]);
+ });
+
+ it('uses the raw status for savepoints', () => {
+ const { unacked } = build(checkpoint('FAILED', 10, true), agg, { a:
details(noStats(0)) });
+
+ expect(unacked.map(u => u.label)).toEqual(['no ack']);
+ });
+
+ it('ignores missing stats on completed checkpoints', () => {
+ const { unacked, rowOrder } = build(checkpoint('COMPLETED', 10), agg, {
+ a: details(completed(0, { async: 2 }), noStats(1))
+ });
+
+ expect(unacked).toEqual([]);
+ expect(rowOrder).toEqual(['Agg #0']);
+ });
+
+ it('keeps row keys unique when two operator names shorten to the same
label', () => {
+ const name = `${'x'.repeat(70)}`;
+ const { rowOrder } = build(
+ checkpoint('COMPLETED', 10),
+ [vertex('aaaa1', `${name}1`), vertex('bbbb2', `${name}2`)],
+ {
+ aaaa1: details(completed(0, { async: 1 })),
+ bbbb2: details(completed(0, { async: 1 }))
+ }
+ );
+
+ expect(new Set(rowOrder).size).toBe(2);
+ expect(rowOrder[1]).toContain('[bbbb]');
+ });
+
+ // Long.MAX_VALUE (2 ** 63 once parsed) means no timeout, i.e. unaligned
from the first barrier, like 0.
+ it.each([0, 2 ** 63])('draws no alignment before sync for checkpoints
unaligned from the start (timeout %s)', t => {
+ const { rows } = build(
+ checkpoint('COMPLETED', 20),
+ agg,
+ { a: details(completed(0, SUBTASK_PHASES)) },
+ unalignedConfig(t)
+ );
+
+ expect(segments(rows, 'Agg #0')).toEqual([
+ ['start_delay', 'start_delay', [TRIGGERED, TRIGGERED + 2], 11],
+ ['sync', 'sync', [TRIGGERED + 2, TRIGGERED + 3], 11],
+ ['async', 'async (incl. 5ms alignment)', [TRIGGERED + 3, TRIGGERED +
11], 11]
+ ]);
+ });
+
+ it('draws the alignment up to the aligned checkpoint timeout before sync',
() => {
+ const { rows } = build(
+ checkpoint('COMPLETED', 40_000),
+ agg,
+ {
+ a: details(
+ completed(0, { start_delay: 5_000, alignment: 31_000, sync: 10,
async: 8_000 }),
+ completed(1, SUBTASK_PHASES)
+ )
+ },
+ unalignedConfig(30_000)
+ );
+
+ expect(segments(rows, 'Agg #0')).toEqual([
+ ['start_delay', 'start_delay', [TRIGGERED, TRIGGERED + 5_000], 38_010],
+ ['alignment', 'alignment', [TRIGGERED + 5_000, TRIGGERED + 30_000],
38_010],
+ ['sync', 'sync', [TRIGGERED + 30_000, TRIGGERED + 30_010], 38_010],
+ ['async', 'async (incl. 6000ms alignment)', [TRIGGERED + 30_010,
TRIGGERED + 38_010], 38_010]
+ ]);
+ // Aligned within the timeout, so it never switched.
+ expect(segments(rows, 'Agg #1').map(([, label]) =>
label)).toEqual(['start_delay', 'alignment', 'sync', 'async']);
+ });
+
+ it.each([
+ ['savepoints', unalignedConfig(0), true],
+ ['at-least-once checkpoints', unalignedConfig(0, 'at_least_once'), false],
+ ['jobs without unaligned checkpoints', { ...unalignedConfig(0),
unaligned_checkpoints: false }, false]
+ ])('draws the whole alignment before sync for %s', (_, config, isSavepoint)
=> {
+ const { rows } = build(
+ checkpoint('COMPLETED', 20, isSavepoint),
+ agg,
+ { a: details(completed(0, SUBTASK_PHASES)) },
+ config
+ );
+
+ expect(segments(rows, 'Agg #0')).toEqual([
+ ['start_delay', 'start_delay', [TRIGGERED, TRIGGERED + 2], 16],
+ ['alignment', 'alignment', [TRIGGERED + 2, TRIGGERED + 7], 16],
+ ['sync', 'sync', [TRIGGERED + 7, TRIGGERED + 8], 16],
+ ['async', 'async', [TRIGGERED + 8, TRIGGERED + 16], 16]
+ ]);
+ });
+});
+
+describe('toStripDatum', () => {
+ const savepoint = (status: string): CheckpointHistory =>
+ ({ id: 7, status, is_savepoint: true, num_acknowledged_subtasks: 1,
num_subtasks: 4 }) as CheckpointHistory;
+
+ it('draws a failed savepoint as failed, and an in-progress one as a
savepoint', () => {
+ expect(toStripDatum(savepoint('FAILED')).status).toBe('FAILED');
+ expect(toStripDatum(savepoint('IN_PROGRESS')).status).toBe('SAVEPOINT');
+ expect(toStripDatum(savepoint('IN_PROGRESS')).acked).toBe('1/4');
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.ts
new file mode 100644
index 00000000000..0653f393759
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline-rows.ts
@@ -0,0 +1,267 @@
+/*
+ * 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 {
+ CheckpointConfig,
+ CheckpointHistory,
+ CheckpointSubTask,
+ CompletedSubTaskCheckpointStatistics,
+ SubTaskCheckpointStatisticsItem,
+ VerticesItem
+} from '@flink-runtime-web/interfaces';
+
+export type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// One bar of the recent-checkpoints strip, and the checkpoint the timeline
below it shows.
+export interface StripDatum {
+ id: number;
+ idLabel: string;
+ duration: number;
+ status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+ rawStatus: string;
+ isSavepoint: boolean;
+ triggered: number;
+ acked: string;
+ size: number;
+}
+
+export interface TimelineDatum {
+ row: string;
+ operatorFull: string;
+ subtask: number;
+ phase: PhaseKey;
+ phaseLabel: string;
+ range: [number, number];
+ durationMs: number;
+ aborted: boolean;
+ stateSize: number;
+ checkpointedSize: number;
+ totalDuration: number;
+}
+
+// A subtask that reported no stats for the checkpoint, drawn as one hatched
bar spanning it.
+export interface UnackedDatum {
+ row: string;
+ operatorFull: string;
+ subtask: number;
+ label: 'no ack' | 'pending';
+ range: [number, number];
+}
+
+// A subtask whose phases all rounded to 0ms, drawn as a hairline at trigger
time so its row isn't empty.
+export interface InstantDatum {
+ row: string;
+ operatorFull: string;
+ subtask: number;
+ aborted: boolean;
+ stateSize: number;
+ checkpointedSize: number;
+ range: [number, number];
+}
+
+export interface TimelineRows {
+ rows: TimelineDatum[];
+ unacked: UnackedDatum[];
+ instants: InstantDatum[];
+ rowOrder: string[];
+}
+
+// What building the rows reads from a strip bar.
+export type TimelineCheckpoint = Pick<StripDatum, 'duration' | 'rawStatus' |
'isSavepoint' | 'triggered'>;
+
+const OPERATOR_NAME_MAX = 60;
+
+export function toStripDatum(h: CheckpointHistory): StripDatum {
+ // FAILED first, so a failed savepoint shows as failed rather than as a
savepoint.
+ const status: StripDatum['status'] =
+ h.status === 'FAILED'
+ ? 'FAILED'
+ : h.is_savepoint
+ ? 'SAVEPOINT'
+ : h.status === 'IN_PROGRESS'
+ ? 'IN_PROGRESS'
+ : 'COMPLETED';
+ return {
+ id: h.id,
+ idLabel: String(h.id),
+ duration: Math.max(h.end_to_end_duration || 0, 0),
+ status,
+ rawStatus: h.status,
+ isSavepoint: h.is_savepoint,
+ triggered: h.trigger_timestamp,
+ acked: `${h.num_acknowledged_subtasks}/${h.num_subtasks}`,
+ size: h.state_size
+ };
+}
+
+// One row per subtask, in operator then subtask order, with a segment per
checkpoint phase.
+export function buildTimelineRows(
+ datum: TimelineCheckpoint,
+ vertices: VerticesItem[],
+ perVertex: Record<string, CheckpointSubTask | null>,
+ config?: CheckpointConfig
+): TimelineRows {
+ const base = datum.triggered;
+ // A completed checkpoint was acked by every running subtask, so missing
stats there are not a failure.
+ const unackedLabel =
+ datum.rawStatus === 'FAILED' ? 'no ack' : datum.rawStatus ===
'IN_PROGRESS' ? 'pending' : undefined;
+ const rows: TimelineDatum[] = [];
+ const unacked: UnackedDatum[] = [];
+ const instants: InstantDatum[] = [];
+ const rowOrder: string[] = [];
+ let maxTotal = 0;
+ const rowPrefixOwner = new Map<string, string>();
+
+ for (const vertex of vertices) {
+ const cp = perVertex[vertex.id];
+ if (!cp || !cp.subtasks) {
+ continue;
+ }
+ const opNameFull = (vertex.name || '').replace(/\s+/g, ' ').trim();
+ let opName = shortenOperatorName(vertex.name);
+ // shortenOperatorName truncates, so distinct vertices can collide; the
row key must stay unique.
+ const owner = rowPrefixOwner.get(opName);
+ if (owner !== undefined && owner !== vertex.id) {
+ opName = `${opName} [${vertex.id.slice(0, 4)}]`;
+ }
+ rowPrefixOwner.set(opName, vertex.id);
+
+ // By index, rather than in the order the response lists them.
+ for (const s of [...cp.subtasks].sort((x, y) => x.index - y.index)) {
+ const row = `${opName} #${s.index}`;
+ if (!isCompletedSubtask(s)) {
+ // Finished tasks never land here: the JobManager records them as
acknowledged when it triggers.
+ if (unackedLabel) {
+ unacked.push({ row, operatorFull: opNameFull, subtask: s.index,
label: unackedLabel, range: [base, base] });
+ rowOrder.push(row);
+ }
+ continue;
+ }
+ const startDelay = Math.max(s.start_delay || 0, 0);
+ const reportedAlignment = Math.max(s.alignment.duration || 0, 0);
+ const alignment = alignmentBeforeSync(datum, config, startDelay,
reportedAlignment);
+ const alignmentInAsync = reportedAlignment - alignment;
+ const sync = Math.max(s.checkpoint.sync || 0, 0);
+ const asyncMs = Math.max(s.checkpoint.async || 0, 0);
+ const total = startDelay + alignment + sync + asyncMs;
+ rowOrder.push(row);
+ maxTotal = Math.max(maxTotal, total);
+ if (total === 0) {
+ instants.push({
+ row,
+ operatorFull: opNameFull,
+ subtask: s.index,
+ aborted: !!s.aborted,
+ stateSize: s.state_size,
+ checkpointedSize: s.checkpointed_size,
+ range: [base, base]
+ });
+ continue;
+ }
+
+ const segs: Array<[PhaseKey, number]> = [
+ ['start_delay', startDelay],
+ ['alignment', alignment],
+ ['sync', sync],
+ ['async', asyncMs]
+ ];
+ let cursor = base;
+ for (const [phase, ms] of segs) {
+ if (ms > 0) {
+ rows.push({
+ row,
+ operatorFull: opNameFull,
+ subtask: s.index,
+ phase,
+ phaseLabel:
+ phase === 'async' && alignmentInAsync > 0 ? `${phase} (incl.
${alignmentInAsync}ms alignment)` : phase,
+ range: [cursor, cursor + ms],
+ durationMs: ms,
+ aborted: !!s.aborted,
+ stateSize: s.state_size,
+ checkpointedSize: s.checkpointed_size,
+ totalDuration: total
+ });
+ }
+ cursor += ms;
+ }
+ }
+ }
+
+ // Unacked subtasks span the whole checkpoint: until it failed, or the
latest ack so far while in progress.
+ const unackedEnd = base + Math.max(datum.duration, maxTotal, 1);
+ for (const u of unacked) {
+ u.range = [base, unackedEnd];
+ }
+
+ return { rows, unacked, instants, rowOrder };
+}
+
+// How much of a subtask's alignment came before its sync phase. With
unaligned checkpoints, a checkpoint starts
+// aligned and switches to unaligned once aligned_checkpoint_timeout has
passed since the trigger; after that the
+// rest of the alignment runs during the async phase, whose duration includes
it (checkpoint_monitoring.md).
+// Savepoints and at-least-once checkpoints never switch. The per-subtask
unaligned_checkpoint flag can't tell: it
+// only says whether in-flight data was persisted.
+function alignmentBeforeSync(
+ datum: TimelineCheckpoint,
+ config: CheckpointConfig | undefined,
+ startDelay: number,
+ alignment: number
+): number {
+ if (!config?.unaligned_checkpoints || config.mode !== 'exactly_once' ||
datum.isSavepoint) {
+ return alignment;
+ }
+ const timeout = config.aligned_checkpoint_timeout;
+ // 0 and Long.MAX_VALUE both mean unaligned from the first barrier
(CheckpointOptions.forConfig).
+ if (!(timeout > 0) || timeout >= Number.MAX_SAFE_INTEGER) {
+ return 0;
+ }
+ return Math.max(0, Math.min(alignment, timeout - startDelay));
+}
+
+// Rows by when they finished, latest first; ties keep operator then subtask
order (the sort is stable). Subtasks that
+// never finished come first: those without an ack, and aborted ones, whose
reported phases stop short of where they
+// were when the checkpoint failed (they are why it failed).
+export function slowestFirstOrder({ rows, unacked, instants, rowOrder }:
TimelineRows): string[] {
+ const end = new Map<string, number>();
+ for (const d of [...rows, ...instants]) {
+ end.set(d.row, d.aborted ? Infinity : Math.max(end.get(d.row) ??
-Infinity, d.range[1]));
+ }
+ for (const d of unacked) {
+ end.set(d.row, Infinity);
+ }
+ return [...rowOrder].sort((a, b) => {
+ const [ea, eb] = [end.get(a) ?? 0, end.get(b) ?? 0];
+ return ea === eb ? 0 : eb > ea ? 1 : -1;
+ });
+}
+
+// Backend tags completed subtasks "completed" (incl. aborted) and others
"pending_or_failed".
+function isCompletedSubtask(
+ s: SubTaskCheckpointStatisticsItem
+): s is SubTaskCheckpointStatisticsItem & CompletedSubTaskCheckpointStatistics
{
+ return s.status === 'completed';
+}
+
+function shortenOperatorName(name: string): string {
+ if (!name) {
+ return '';
+ }
+ const compact = name.replace(/\s+/g, ' ').trim();
+ return compact.length <= OPERATOR_NAME_MAX ? compact : `${compact.slice(0,
OPERATOR_NAME_MAX - 3)}...`;
+}
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline.testing.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline.testing.ts
new file mode 100644
index 00000000000..ab92d621718
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/checkpoint-timeline.testing.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+// Test data shared by the timeline specs.
+
+import {
+ CheckpointHistory,
+ CheckpointSubTask,
+ SubTaskCheckpointStatisticsItem,
+ VerticesItem
+} from '@flink-runtime-web/interfaces';
+
+export const TRIGGERED = 1_000;
+
+export interface Phases {
+ start_delay?: number;
+ alignment?: number;
+ sync?: number;
+ async?: number;
+}
+
+export const completed = (index: number, phases: Phases, aborted = false):
SubTaskCheckpointStatisticsItem =>
+ ({
+ index,
+ status: 'completed',
+ ack_timestamp: 0,
+ end_to_end_duration: 0,
+ checkpointed_size: 0,
+ state_size: 0,
+ checkpoint: { sync: phases.sync ?? 0, async: phases.async ?? 0 },
+ alignment: { buffer: 0, processed: 0, persisted: 0, duration:
phases.alignment ?? 0 },
+ start_delay: phases.start_delay ?? 0,
+ unaligned_checkpoint: false,
+ aborted
+ }) as SubTaskCheckpointStatisticsItem;
+
+export const noStats = (index: number): SubTaskCheckpointStatisticsItem =>
+ ({ index, status: 'pending_or_failed' }) as SubTaskCheckpointStatisticsItem;
+
+export const details = (...subtasks: SubTaskCheckpointStatisticsItem[]):
CheckpointSubTask =>
+ ({ subtasks }) as unknown as CheckpointSubTask;
+
+export const vertex = (id: string, name: string): VerticesItem => ({ id, name,
status: 'RUNNING' }) as VerticesItem;
+
+export const historyEntry = (id: number, status: string, acked: number,
isSavepoint = false): CheckpointHistory =>
+ ({
+ id,
+ status,
+ is_savepoint: isSavepoint,
+ trigger_timestamp: TRIGGERED,
+ end_to_end_duration: 5,
+ num_acknowledged_subtasks: acked,
+ num_subtasks: 4,
+ state_size: 0
+ }) as CheckpointHistory;
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html
new file mode 100644
index 00000000000..2cab659262e
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html
@@ -0,0 +1,202 @@
+<!--
+ ~ 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.
+ -->
+
+<div class="timeline-wrapper">
+ <section class="strip-section">
+ <header class="strip-header">
+ <div class="strip-title">
+ <strong>Recent Checkpoints</strong>
+ <span class="hint">Click a bar to load its per-subtask timeline
below.</span>
+ </div>
+ <ul class="legend">
+ <li>
+ <span class="swatch completed"></span>
+ Completed
+ </li>
+ <li>
+ <span class="swatch savepoint"></span>
+ Savepoint
+ </li>
+ <li>
+ <span class="swatch in-progress"></span>
+ In progress
+ </li>
+ <li>
+ <span class="swatch failed"></span>
+ Failed
+ </li>
+ </ul>
+ </header>
+ <nz-alert
+ *ngIf="statsRefreshFailed"
+ class="stale-stats"
+ nzType="warning"
+ nzShowIcon
+ [nzMessage]="staleStatsMessage"
+ ></nz-alert>
+ <ng-template #staleStatsMessage>
+ Could not refresh the checkpoint statistics from the JobManager; showing
the ones loaded
+ {{ statsUpdatedAt ? 'at ' + (statsUpdatedAt | date: 'HH:mm:ss') :
'earlier' }}. Retrying.
+ </ng-template>
+ <div class="strip-canvas" #stripContainer></div>
+ <nz-empty *ngIf="!hasHistory" nzNotFoundContent="No checkpoint history
yet"></nz-empty>
+ </section>
+
+ <section class="timeline-section">
+ <header class="timeline-header">
+ <div class="timeline-title">
+ <strong *ngIf="selected">
+ Checkpoint #{{ selected.id }}
+ <nz-tag
+ *ngIf="userPinned"
+ class="pin-badge"
+ nzColor="gold"
+ nz-tooltip
+ nzTooltipTitle="Pinned: the timeline below will not change until
you click another checkpoint or 'Follow newest'."
+ >
+ pinned
+ </nz-tag>
+ </strong>
+ <strong *ngIf="!selected">Per-subtask Timeline</strong>
+ <span *ngIf="selected" class="hint">
+ {{ selected.rawStatus }}
+ <ng-container *ngIf="selected.isSavepoint">· savepoint</ng-container>
+ · triggered {{ selected.triggered | date: 'yyyy-MM-dd HH:mm:ss.SSS'
}} ·
+ {{ selected.duration | humanizeDuration }} · acked {{ selected.acked
}} ·
+ {{ selected.size | humanizeBytes }}
+ </span>
+ <div class="timeline-actions">
+ <button
+ *ngIf="userPinned"
+ nz-button
+ nzSize="small"
+ type="button"
+ class="follow-newest"
+ (click)="followNewest()"
+ >
+ Follow newest
+ </button>
+ </div>
+ </div>
+ <ul class="legend">
+ <li>
+ <span class="swatch phase-start"></span>
+ start_delay
+ </li>
+ <li>
+ <span class="swatch phase-align"></span>
+ alignment
+ </li>
+ <li>
+ <span class="swatch phase-sync"></span>
+ sync
+ </li>
+ <li>
+ <span class="swatch phase-async"></span>
+ async
+ </li>
+ <li nz-tooltip [nzTooltipTitle]="abortedTip"
nzTooltipPlacement="bottom">
+ <span class="swatch aborted"></span>
+ aborted
+ </li>
+ <li nz-tooltip [nzTooltipTitle]="noAckTip" nzTooltipPlacement="bottom">
+ <span class="swatch no-ack"></span>
+ no ack
+ </li>
+ <ng-template #abortedTip>
+ Started its snapshot, but the checkpoint failed before it finished.
The bar shows the
+ partial work.
+ </ng-template>
+ <ng-template #noAckTip>
+ Reported no statistics for this checkpoint: still pending, or never
acknowledged before
+ the checkpoint failed (for example, the barrier never arrived). The
bar spans the whole
+ checkpoint.
+ </ng-template>
+ </ul>
+ </header>
+
+ <!-- Reserve the line while a checkpoint is selected so the chart doesn't
shift. -->
+ <div *ngIf="selected" class="timeline-note">
+ <span *ngIf="selectedRotatedOut && !timelineEmpty">
+ Checkpoint #{{ selected?.id }} is no longer in the recent-checkpoints
strip above — showing
+ the frozen snapshot from when it was pinned.
+ </span>
+ <span *ngIf="totalRows > 1 && !timelineEmpty" class="timeline-controls">
+ <!-- Text, not whitespace-only nodes, around the select: Angular drops
whitespace-only nodes. -->
+ <span *ngIf="slowestCountOptions.length > 0">
+ {{ shownRows < totalRows ? 'Showing the' : 'Showing all' }}
+ <nz-select
+ class="row-count"
+ nzSize="small"
+ aria-label="Number of slowest subtasks shown"
+ [ngModel]="shownRows"
+ (ngModelChange)="setSlowestCount($event)"
+ >
+ <nz-option
+ *ngFor="let n of slowestCountOptions"
+ [nzValue]="n"
+ [nzLabel]="'' + n"
+ ></nz-option>
+ </nz-select>
+ {{ shownRows < totalRows ? 'slowest of ' + totalRows + ' subtasks' :
'subtasks' }}
+ {{ scrollable ? '· rows ' + (firstRow + 1) + '–' + (firstRow +
visibleRows) : '' }}
+ </span>
+ <label
+ nz-checkbox
+ class="slowest-first"
+ [ngModel]="slowestFirst"
+ (ngModelChange)="setSlowestFirst($event)"
+ >
+ slowest first
+ </label>
+ </span>
+ </div>
+
+ <nz-alert
+ *ngIf="failedVertexCount > 0"
+ class="partial-failure"
+ nzType="warning"
+ nzShowIcon
+ [nzMessage]="partialFailureMessage"
+ ></nz-alert>
+
+ <div class="timeline-canvas-wrapper" [class.loading]="loadingTimeline"
#timelineWrapper>
+ <nz-spin [nzSpinning]="loadingTimeline">
+ <!-- With more rows than are visible, the chart stays in place while
this box scrolls. -->
+ <div
+ class="timeline-scroll"
+ [class.scrollable]="scrollable"
+ [style.height.px]="scrollable ? chartHeight : null"
+ (scroll)="onTimelineScroll()"
+ #timelineScroll
+ >
+ <div [style.height.px]="scrollable ? scrollContentHeight : null">
+ <!-- G2 sets an inline position on its container, so the sticky
goes on this wrapper. -->
+ <div class="timeline-sticky">
+ <div class="timeline-canvas" [class.hidden]="timelineEmpty"
#timelineContainer></div>
+ </div>
+ </div>
+ </div>
+ <nz-empty
+ *ngIf="timelineEmpty && !loadingTimeline"
+ [nzNotFoundContent]="emptyTimelineMessage"
+ ></nz-empty>
+ </nz-spin>
+ </div>
+ </section>
+</div>
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.less
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.less
new file mode 100644
index 00000000000..9e2cadd53a8
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.less
@@ -0,0 +1,201 @@
+/*
+ * 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 "theme";
+
+// The theme has no neutral palette token for this one; keep in sync with
PHASE_COLOR in the .ts.
+@phase-start-color: #b8c0cc;
+
+.timeline-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: @margin-lg;
+}
+
+header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: @padding-sm;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: @margin-xs;
+}
+
+.hint {
+ margin-left: @margin-xs;
+ color: @text-color-secondary;
+ font-size: @font-size-sm;
+}
+
+.legend {
+ display: flex;
+ gap: @padding-sm;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ color: @text-color-secondary;
+ font-size: @font-size-sm;
+
+ li {
+ display: inline-flex;
+ align-items: center;
+ }
+
+ .swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin-right: 4px;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ }
+
+ .completed {
+ background: @green-6;
+ }
+
+ .savepoint {
+ background: @purple-6;
+ }
+
+ .in-progress {
+ background: @gold-6;
+ }
+
+ .failed {
+ background: @red-6;
+ }
+
+ .phase-start {
+ background: @phase-start-color;
+ }
+
+ .phase-align {
+ background: @blue-3;
+ }
+
+ .phase-sync {
+ background: @orange-6;
+ }
+
+ .phase-async {
+ background: @green-6;
+ }
+
+ .aborted {
+ border: 1.5px dashed @red-6;
+ background: transparent;
+ }
+
+ .no-ack {
+ border: 1.5px dashed @red-6;
+ background: repeating-linear-gradient(-45deg, @red-1 0 3px, @red-3 3px
4.5px);
+ }
+}
+
+.stale-stats {
+ margin-bottom: @margin-xs;
+}
+
+.strip-canvas {
+ width: 100%;
+ // The chart's height, reserved before it first renders.
+ min-height: 160px;
+ cursor: pointer;
+}
+
+.pin-badge {
+ vertical-align: middle;
+ margin-left: @margin-xs;
+}
+
+.timeline-note {
+ display: flex;
+ gap: @padding-sm;
+ justify-content: space-between;
+ align-items: center;
+ // Fixed height (a small select's) so the chart doesn't shift when the note
toggles.
+ min-height: 24px;
+ margin: 0 0 @margin-xs;
+ color: @text-color-secondary;
+ font-size: @font-size-sm;
+
+ .timeline-controls {
+ display: inline-flex;
+ gap: @padding-sm;
+ align-items: center;
+ margin-left: auto;
+ white-space: nowrap;
+ }
+
+ .slowest-first {
+ color: @text-color-secondary;
+ font-size: @font-size-sm;
+ }
+
+ .row-count {
+ width: 72px;
+ margin: 0 4px;
+ }
+}
+
+.timeline-title {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+ gap: @margin-xs;
+ align-items: center;
+ min-width: 0;
+}
+
+.timeline-actions {
+ display: inline-flex;
+ gap: @margin-xs;
+ align-items: center;
+ margin-left: auto;
+}
+
+.partial-failure {
+ margin-bottom: @padding-sm;
+}
+
+.timeline-canvas-wrapper {
+ position: relative;
+ min-height: 160px;
+
+ &.loading {
+ opacity: 0.7;
+ }
+}
+
+.timeline-scroll.scrollable {
+ overflow-y: auto;
+}
+
+.timeline-sticky {
+ position: sticky;
+ top: 0;
+}
+
+.timeline-canvas {
+ width: 100%;
+
+ &.hidden {
+ display: none;
+ }
+}
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.spec.ts
new file mode 100644
index 00000000000..c69b6a530d7
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.spec.ts
@@ -0,0 +1,672 @@
+/*
+ * 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 { HttpErrorResponse } from '@angular/common/http';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NEVER, Observable, of, Subject, throwError } from 'rxjs';
+
+import { Checkpoint, CheckpointConfig, CheckpointHistory, CheckpointSubTask }
from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { StripDatum } from './checkpoint-timeline-rows';
+import { completed, details, historyEntry, noStats, vertex } from
'./checkpoint-timeline.testing';
+import { JobCheckpointsTimelineComponent } from
'./job-checkpoints-timeline.component';
+
+// jsdom has no canvas, so g2 is swapped for a fake chart that records what
the component asks it to draw.
+const g2 = vi.hoisted(() => {
+ class FakeMark {
+ public readonly dataCalls: unknown[][] = [];
+ public readonly scales: Array<[string, { domain: unknown[] }]> = [];
+ public readonly interactions: Array<[string, unknown]> = [];
+
+ public encode(): this {
+ return this;
+ }
+ public axis(): this {
+ return this;
+ }
+ public legend(): this {
+ return this;
+ }
+ public animate(): this {
+ return this;
+ }
+ public style(): this {
+ return this;
+ }
+ public tooltip(): this {
+ return this;
+ }
+ public data(data: unknown[]): this {
+ this.dataCalls.push(data);
+ return this;
+ }
+ public scale(channel: string, options: { domain: unknown[] }): this {
+ this.scales.push([channel, options]);
+ return this;
+ }
+ public interaction(name: string, options: unknown): this {
+ this.interactions.push([name, options]);
+ return this;
+ }
+ }
+
+ class FakeChart {
+ public static readonly instances: FakeChart[] = [];
+ public readonly marks: FakeMark[] = [];
+ public readonly handlers = new Map<string, (event?: unknown) => void>();
+ public readonly emitted: Array<[string, unknown]> = [];
+ public readonly attrs = new Map<string, unknown>();
+ public readonly interactions: Array<[string, unknown]> = [];
+ public renders = 0;
+ public destroyed = false;
+
+ constructor(public readonly options: Record<string, unknown>) {
+ FakeChart.instances.push(this);
+ }
+
+ public interval(): FakeMark {
+ const mark = new FakeMark();
+ this.marks.push(mark);
+ return mark;
+ }
+ public coordinate(): this {
+ return this;
+ }
+ public interaction(name: string, options: unknown): this {
+ this.interactions.push([name, options]);
+ return this;
+ }
+ public on(event: string, handler: (event?: unknown) => void): this {
+ this.handlers.set(event, handler);
+ return this;
+ }
+ public emit(event: string, payload?: unknown): void {
+ this.emitted.push([event, payload]);
+ }
+ public attr(key: string, value: unknown): this {
+ this.attrs.set(key, value);
+ return this;
+ }
+ public render(): Promise<this> {
+ this.renders++;
+ return Promise.resolve(this);
+ }
+ public forceFit(): Promise<void> {
+ return Promise.resolve();
+ }
+ public destroy(): void {
+ this.destroyed = true;
+ }
+ }
+ return { FakeChart };
+});
+
+vi.mock('@antv/g2', () => ({ Chart: g2.FakeChart }));
+
+type FakeChart = InstanceType<typeof g2.FakeChart>;
+
+describe('JobCheckpointsTimelineComponent', () => {
+ let fixture: ComponentFixture<JobCheckpointsTimelineComponent>;
+ let element: HTMLElement;
+ let loadStats: ReturnType<typeof vi.fn>;
+ let loadDetails: ReturnType<typeof vi.fn>;
+ // The Timeline tab is shown when the strip has a width; jsdom lays nothing
out, so the width is faked.
+ let stripWidth: number;
+ let resizeCallbacks: Array<() => void>;
+ const serverError = new HttpErrorResponse({ status: 500 });
+
+ const strip = (): FakeChart => g2.FakeChart.instances[0];
+ const timeline = (): FakeChart | undefined =>
g2.FakeChart.instances.slice(1).filter(c => !c.destroyed)[0];
+ const timelineCharts = (): FakeChart[] => g2.FakeChart.instances.slice(1);
+ const stripData = (): StripDatum[] => strip().marks[0].dataCalls.at(-1) as
StripDatum[];
+ const stripBar = (id: number): StripDatum => stripData().find(d => d.id ===
id)!;
+ const detailsRequests = (): Array<[string, number]> =>
+ loadDetails.mock.calls.map(([jobId, id]) => [jobId as string, id as
number]);
+ const text = (): string => element.textContent!.replace(/\s+/g, ' ');
+
+ const setHistory = (history: CheckpointHistory[]): void => {
+ fixture.componentRef.setInput('history', history);
+ fixture.detectChanges();
+ };
+ const mount = (history: CheckpointHistory[], vertexIds = ['a'], config?:
CheckpointConfig): void => {
+ fixture.componentRef.setInput('jobId', 'job-1');
+ fixture.componentRef.setInput(
+ 'vertices',
+ vertexIds.map(id => vertex(id, id === 'a' ? 'Agg' : id === 's' ? 'Sink'
: id))
+ );
+ fixture.componentRef.setInput('config', config);
+ setHistory(history);
+ // ngAfterViewInit loads the newest checkpoint and starts polling, after
the first check of the view.
+ fixture.detectChanges();
+ };
+ const clickBar = (id: number): void => {
+ strip().handlers.get('interval:click')!({ data: { data: stripBar(id) } });
+ fixture.detectChanges();
+ };
+ const showTab = (): void => {
+ stripWidth = 800;
+ resizeCallbacks.forEach(callback => callback());
+ fixture.detectChanges();
+ };
+
+ beforeEach(async () => {
+ g2.FakeChart.instances.length = 0;
+ loadStats = vi.fn().mockReturnValue(NEVER);
+ loadDetails = vi.fn().mockReturnValue(of({ a: details(completed(0, {
async: 5 })) }));
+ stripWidth = 800;
+ resizeCallbacks = [];
+ vi.spyOn(HTMLElement.prototype, 'clientWidth',
'get').mockImplementation(() => stripWidth);
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(callback: () => void) {
+ resizeCallbacks.push(callback);
+ }
+ public observe(): void {}
+ public disconnect(): void {}
+ }
+ );
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
{
+ callback(0);
+ return 1;
+ });
+ await TestBed.configureTestingModule({
+ imports: [JobCheckpointsTimelineComponent],
+ providers: [
+ {
+ provide: JobService,
+ useValue: { loadCheckpointStats: loadStats,
loadCheckpointAllSubtaskDetails: loadDetails }
+ }
+ ]
+ }).compileComponents();
+ fixture = TestBed.createComponent(JobCheckpointsTimelineComponent);
+ element = fixture.nativeElement as HTMLElement;
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ describe('strip', () => {
+ it('draws the recent checkpoints oldest first in a 160px chart', () => {
+ mount([historyEntry(9, 'COMPLETED', 4), historyEntry(8, 'FAILED', 2),
historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(strip().options['height']).toBe(160);
+ expect(stripData().map(d => [d.id, d.status])).toEqual([
+ [7, 'COMPLETED'],
+ [8, 'FAILED'],
+ [9, 'COMPLETED']
+ ]);
+ expect(text()).not.toContain('No checkpoint history yet');
+ });
+
+ it('says when there is no checkpoint history', () => {
+ mount([]);
+
+ expect(text()).toContain('No checkpoint history yet');
+ expect(text()).toContain('Per-subtask Timeline');
+ });
+
+ it('redraws only when the bars or the pin change', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ const renders = strip().renders;
+
+ setHistory([historyEntry(7, 'COMPLETED', 4)]);
+ expect(strip().renders).toBe(renders);
+ clickBar(7);
+ expect(strip().renders).toBe(renders + 1);
+ setHistory([historyEntry(8, 'IN_PROGRESS', 0), historyEntry(7,
'COMPLETED', 4)]);
+ expect(strip().renders).toBe(renders + 2);
+ // The bars moved, so an open tooltip would point at the wrong one.
+ expect(strip().emitted.at(-1)).toEqual(['tooltip:hide', undefined]);
+ });
+
+ it('shows the tooltip only while the pointer is over a bar', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ const pointer = (event: string, payload?: unknown): void =>
strip().handlers.get(event)!(payload);
+
+ expect(strip().marks[0].interactions).toContainEqual(['tooltip', {
disableNative: true, wait: 0 }]);
+ strip().emitted.length = 0;
+ pointer('interval:pointermove', { data: { data: stripBar(7) } });
+ pointer('plot:pointermove', { target: { className: 'element' } });
+ pointer('interval:pointerout');
+ // Over the plot but off the bars, e.g. after a re-render moved them.
+ pointer('plot:pointermove', { target: { className: 'plot' } });
+
+ expect(strip().emitted).toEqual([
+ ['tooltip:show', { data: { data: stripBar(7) } }],
+ ['tooltip:hide', undefined],
+ ['tooltip:hide', undefined]
+ ]);
+ });
+ });
+
+ describe('selection', () => {
+ it('follows the newest checkpoint', () => {
+ mount([historyEntry(8, 'COMPLETED', 4), historyEntry(7, 'COMPLETED',
4)]);
+
+ expect(text()).toContain('Checkpoint #8');
+ expect(element.querySelector('.pin-badge')).toBeNull();
+ expect(detailsRequests()).toEqual([['job-1', 8]]);
+ });
+
+ it('pins a clicked checkpoint until Follow newest is clicked', () => {
+ mount([historyEntry(8, 'COMPLETED', 4), historyEntry(7, 'COMPLETED',
4)]);
+
+ clickBar(7);
+ expect(text()).toContain('Checkpoint #7');
+
expect(element.querySelector('nz-tag.pin-badge')?.textContent?.trim()).toBe('pinned');
+ setHistory([historyEntry(9, 'COMPLETED', 4), historyEntry(8,
'COMPLETED', 4), historyEntry(7, 'COMPLETED', 4)]);
+ expect(text()).toContain('Checkpoint #7');
+
+ const followNewest =
element.querySelector<HTMLButtonElement>('button.follow-newest')!;
+ expect(followNewest.classList).toContain('ant-btn');
+ followNewest.click();
+ fixture.detectChanges();
+
+ expect(text()).toContain('Checkpoint #9');
+ expect(element.querySelector('.pin-badge')).toBeNull();
+ expect(element.querySelector('button.follow-newest')).toBeNull();
+ });
+
+ it('explains when the pinned checkpoint leaves the strip', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ clickBar(7);
+
+ setHistory([historyEntry(9, 'COMPLETED', 4), historyEntry(8,
'COMPLETED', 4)]);
+
+ expect(text()).toContain('Checkpoint #7 is no longer in the
recent-checkpoints strip above');
+ });
+
+ it('follows the newest checkpoint again once the history empties while
pinned', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ clickBar(7);
+
+ setHistory([]);
+ expect(text()).toContain('Per-subtask Timeline');
+ setHistory([historyEntry(1, 'COMPLETED', 4)]);
+
+ expect(text()).toContain('Checkpoint #1');
+ expect(element.querySelector('.pin-badge')).toBeNull();
+ });
+ });
+
+ describe('reloads', () => {
+ it('reloads the followed checkpoint once it completes, without waiting for
a newer one', () => {
+ mount([historyEntry(7, 'IN_PROGRESS', 2)]);
+ setHistory([historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(detailsRequests()).toEqual([
+ ['job-1', 7],
+ ['job-1', 7]
+ ]);
+ expect(text()).toContain('acked 4/4');
+ });
+
+ it('updates one timeline chart across reloads instead of rebuilding it',
() => {
+ mount([historyEntry(7, 'IN_PROGRESS', 2)]);
+ setHistory([historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(timelineCharts().length).toBe(1);
+ expect([timeline()!.renders, timeline()!.destroyed]).toEqual([2, false]);
+ });
+
+ it('does not reload when nothing changed or while a load is in flight', ()
=> {
+ mount([historyEntry(7, 'IN_PROGRESS', 2)]);
+ setHistory([historyEntry(7, 'IN_PROGRESS', 2)]);
+ loadDetails.mockReturnValue(NEVER);
+ setHistory([historyEntry(7, 'IN_PROGRESS', 3)]);
+ setHistory([historyEntry(7, 'IN_PROGRESS', 4)]);
+
+ expect(detailsRequests().length).toBe(2);
+ });
+
+ it('keeps refreshing a pinned in-progress checkpoint until it finishes',
() => {
+ mount([historyEntry(7, 'IN_PROGRESS', 2)]);
+ clickBar(7);
+ setHistory([historyEntry(8, 'IN_PROGRESS', 0), historyEntry(7,
'IN_PROGRESS', 3)]);
+ setHistory([historyEntry(8, 'IN_PROGRESS', 1), historyEntry(7,
'COMPLETED', 4)]);
+ setHistory([historyEntry(8, 'COMPLETED', 4), historyEntry(7,
'COMPLETED', 4)]);
+
+ expect(detailsRequests().map(([, id]) => id)).toEqual([7, 7, 7, 7]);
+ expect(text()).toContain('Checkpoint #7');
+ expect(text()).toContain('acked 4/4');
+ expect(element.querySelector('.pin-badge')).not.toBeNull();
+ });
+
+ it('re-selects a finished checkpoint from the cache, and fetches an
in-progress savepoint again', () => {
+ mount([historyEntry(8, 'IN_PROGRESS', 2, true), historyEntry(7,
'COMPLETED', 4, true)]);
+ clickBar(7);
+ clickBar(8);
+ clickBar(7);
+ clickBar(8);
+
+ expect(detailsRequests().map(([, id]) => id)).toEqual([8, 7, 8, 8]);
+ });
+
+ it('keeps cached details only for checkpoints still in the strip, plus the
pinned one', () => {
+ // The cache isn't visible in the page, so this one reads it.
+ const cached = (): number[] =>
+ [...(fixture.componentInstance as unknown as { detailsCache:
Map<number, unknown> }).detailsCache.keys()].sort(
+ (a, b) => a - b
+ );
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ clickBar(7);
+ setHistory([historyEntry(9, 'COMPLETED', 4), historyEntry(8,
'COMPLETED', 4)]);
+ expect(cached()).toEqual([7]);
+
+
element.querySelector<HTMLButtonElement>('button.follow-newest')!.click();
+ setHistory([historyEntry(10, 'COMPLETED', 4), historyEntry(9,
'COMPLETED', 4)]);
+ expect(cached()).toEqual([9, 10]);
+ });
+ });
+
+ describe('loading', () => {
+ it('does not fetch subtask details until the Timeline tab is shown', () =>
{
+ stripWidth = 0;
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ expect(loadDetails).not.toHaveBeenCalled();
+ expect(loadStats).not.toHaveBeenCalled();
+
+ showTab();
+
+ expect(detailsRequests()).toEqual([['job-1', 7]]);
+ expect(loadStats).toHaveBeenCalledWith('job-1');
+ expect(timeline()).toBeDefined();
+ });
+
+ it('says the JobManager no longer retains the checkpoint when every vertex
returns 404', () => {
+ loadDetails.mockReturnValue(of({ a: null }));
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(text()).toContain('the JobManager no longer retains this
checkpoint');
+ expect(timeline()).toBeUndefined();
+ });
+
+ it('reports other request errors as a failed request, and retries once
rather than on every poll', () => {
+ loadDetails.mockReturnValue(throwError(() => serverError));
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ expect(text()).toContain('the request to the JobManager failed');
+
+ for (let poll = 0; poll < 3; poll++) {
+ setHistory([historyEntry(7, 'COMPLETED', 4)]);
+ }
+
+ expect(loadDetails).toHaveBeenCalledTimes(2);
+ });
+
+ it('draws a partial load with a warning, and does not cache it', () => {
+ loadDetails.mockReturnValue(of({ a: details(completed(0, { async: 5 })),
b: null }));
+ mount([historyEntry(7, 'COMPLETED', 4)], ['a', 'b']);
+
+ expect(text()).toContain('1 of 2 operators failed to load — the timeline
below is incomplete.');
+ expect(timeline()!.marks[0].dataCalls.at(-1)).toHaveLength(1);
+ clickBar(7);
+ expect(loadDetails).toHaveBeenCalledTimes(2);
+ });
+
+ it('drops a load still in flight when the job changes', () => {
+ const response = new Subject<Record<string, CheckpointSubTask | null>>();
+ loadDetails.mockReturnValue(response);
+ mount([historyEntry(42, 'COMPLETED', 4)]);
+
+ fixture.componentRef.setInput('jobId', 'job-2');
+ fixture.detectChanges();
+ response.next({ a: details(completed(0, { async: 5 })) });
+ expect(timeline()).toBeUndefined();
+
+ loadDetails.mockReturnValue(of({ a: details(completed(0, { async: 5 }))
}));
+ setHistory([historyEntry(42, 'COMPLETED', 4)]);
+ expect(detailsRequests()).toEqual([
+ ['job-1', 42],
+ ['job-2', 42]
+ ]);
+ });
+ });
+
+ describe('polling', () => {
+ const stats = (history: CheckpointHistory[]): Observable<Checkpoint> =>
of({ history } as Checkpoint);
+
+ it('shows polled history before the parent passes any', () => {
+ loadStats.mockReturnValue(stats([historyEntry(7, 'COMPLETED', 4)]));
+ mount([]);
+
+ expect(text()).not.toContain('No checkpoint history yet');
+ expect(text()).toContain('Checkpoint #7');
+ });
+
+ it('drops a stats poll for the previous job that lands after the job
changes', () => {
+ const oldJobStats = new Subject<Checkpoint>();
+ loadStats.mockReturnValue(oldJobStats);
+ mount([]);
+
+ fixture.componentRef.setInput('jobId', 'job-2');
+ fixture.detectChanges();
+ oldJobStats.next({ history: [historyEntry(42, 'COMPLETED', 4)] } as
Checkpoint);
+ fixture.detectChanges();
+
+ expect(loadStats).toHaveBeenCalledWith('job-1');
+ expect(text()).toContain('No checkpoint history yet');
+ expect(loadDetails).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [undefined, 15_000],
+ [0, 15_000],
+ [100, 1_500],
+ [5_000, 5_000],
+ [60_000, 15_000],
+ // Long.MAX_VALUE: periodic checkpointing disabled.
+ [2 ** 63, 15_000]
+ ])('polls a job with a %s ms checkpoint interval every %s ms', (interval,
delay) => {
+ vi.useFakeTimers();
+ loadStats.mockReturnValue(stats([historyEntry(7, 'COMPLETED', 4)]));
+ mount([], ['a'], { interval } as CheckpointConfig);
+ expect(loadStats).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(delay - 1);
+ expect(loadStats).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(1);
+ expect(loadStats).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the last stats on a failed poll, marked as stale, and keeps
polling', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 0, 1, 10, 20, 30));
+ loadStats
+ .mockReturnValueOnce(throwError(() => serverError))
+ .mockReturnValue(stats([historyEntry(8, 'COMPLETED', 4),
historyEntry(7, 'COMPLETED', 4)]));
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(text()).toContain('Checkpoint #7');
+
expect(element.querySelector('nz-alert.stale-stats')?.textContent).toContain(
+ 'Could not refresh the checkpoint statistics from the JobManager;
showing the ones loaded at 10:20:30. Retrying.'
+ );
+
+ vi.advanceTimersByTime(15_000);
+ fixture.detectChanges();
+
+ expect(element.querySelector('nz-alert.stale-stats')).toBeNull();
+ expect(text()).toContain('Checkpoint #8');
+ });
+ });
+
+ describe('row window', () => {
+ let nextId = 1;
+ const phases = (): FakeChart['marks'][number] => timeline()!.marks[0];
+ const lastScale = (channel: string): { domain: unknown[] } =>
+ phases()
+ .scales.filter(([c]) => c === channel)
+ .at(-1)![1];
+ const shownRows = (): unknown[] => lastScale('x').domain;
+ const scrollBox = (): HTMLDivElement =>
element.querySelector<HTMLDivElement>('.timeline-scroll')!;
+ // A checkpoint of one operator where a higher subtask index finishes
later: the slowest 25 of 300 are #275–#299.
+ const showSubtasks = (count: number): void => {
+ loadDetails.mockReturnValue(
+ of({ a: details(...Array.from({ length: count }, (_, i) =>
completed(i, { async: 5 + i }))) })
+ );
+ setHistory([historyEntry(nextId++, 'COMPLETED', 4)]);
+ };
+ const scrollTo = (row: number): void => {
+ Object.defineProperty(scrollBox(), 'scrollTop', { value: row * 22,
writable: true, configurable: true });
+ scrollBox().dispatchEvent(new Event('scroll'));
+ fixture.detectChanges();
+ };
+ const toggleSlowestFirst = (): void => {
+ element.querySelector<HTMLLabelElement>('label.slowest-first')!.click();
+ fixture.detectChanges();
+ };
+ const pickSlowest = (count: number): void => {
+ fixture.componentInstance.setSlowestCount(count);
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => mount([]));
+
+ it('shows the 25 slowest subtasks in operator order, and offers up to 200
or the row count', async () => {
+ showSubtasks(300);
+ // ngModel writes the select's value asynchronously.
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(shownRows()).toEqual(Array.from({ length: 25 }, (_, i) => `Agg
#${275 + i}`));
+ expect(new Set((phases().dataCalls.at(-1) as Array<{ row: string
}>).map(r => r.row)).size).toBe(25);
+ expect(timeline()!.attrs.get('height')).toBe(25 * 22 + 56);
+ expect(text()).toContain('Showing the 25 slowest of 300 subtasks');
+ expect(element.querySelector('nz-select.row-count')).not.toBeNull();
+ expect(scrollBox().classList).not.toContain('scrollable');
+ expect(fixture.componentInstance.slowestCountOptions).toEqual([25, 50,
75, 100, 125, 150, 175, 200]);
+
+ showSubtasks(60);
+ expect(fixture.componentInstance.slowestCountOptions).toEqual([25, 50,
60]);
+
+ showSubtasks(20);
+ expect(element.querySelector('nz-select.row-count')).toBeNull();
+ expect(timeline()!.attrs.get('height')).toBe(20 * 22 + 56);
+ });
+
+ it('scrolls through more of the slowest than fit, without moving the time
scale', () => {
+ showSubtasks(300);
+ const timeDomain = lastScale('y').domain;
+
+ pickSlowest(100);
+ expect(scrollBox().classList).toContain('scrollable');
+ expect(scrollBox().style.height).toBe(`${25 * 22 + 56}px`);
+ expect([shownRows()[0], text().includes('rows 1–25')]).toEqual(['Agg
#200', true]);
+ scrollTo(30);
+
+ expect([shownRows()[0], shownRows().length, text().includes('rows
31–55')]).toEqual(['Agg #230', 25, true]);
+ expect(lastScale('y').domain).toEqual(timeDomain);
+ });
+
+ it('keeps the window within the picked rows, and starts from the top for
another count', () => {
+ showSubtasks(300);
+ pickSlowest(100);
+ scrollTo(90);
+ expect(text()).toContain('rows 76–100');
+
+ pickSlowest(50);
+
+ expect([scrollBox().scrollTop, shownRows()[0]]).toEqual([0, 'Agg #250']);
+ expect(text()).toContain('rows 1–25');
+ });
+
+ it('sorts the picked rows by when they finished, latest first, and starts
again from the top', () => {
+ showSubtasks(300);
+ pickSlowest(50);
+ scrollTo(10);
+
+ toggleSlowestFirst();
+
+ expect(scrollBox().scrollTop).toBe(0);
+ expect(shownRows().slice(0, 3)).toEqual(['Agg #299', 'Agg #298', 'Agg
#297']);
+ toggleSlowestFirst();
+ expect(shownRows()[0]).toBe('Agg #250');
+ });
+
+ it('puts subtasks without an ack first when sorting, keeping operator
order for ties', () => {
+ fixture.componentRef.setInput('vertices', [vertex('a', 'Agg'),
vertex('s', 'Sink')]);
+ loadDetails.mockReturnValue(
+ of({
+ a: details(completed(0, { async: 10 }), noStats(1), completed(2, {
async: 30 })),
+ s: details(noStats(0), completed(1, { async: 30 }))
+ })
+ );
+ setHistory([historyEntry(nextId++, 'FAILED', 3)]);
+
+ toggleSlowestFirst();
+
+ expect(shownRows()).toEqual(['Agg #1', 'Sink #0', 'Agg #2', 'Sink #1',
'Agg #0']);
+ });
+
+ it('counts aborted subtasks as the slowest, however short their reported
phases', () => {
+ // A failed checkpoint: 30 subtasks finished, #30 was aborted after
reporting 1ms, #31 never acked.
+ loadDetails.mockReturnValue(
+ of({
+ a: details(
+ ...Array.from({ length: 30 }, (_, i) => completed(i, { async: 5 +
i })),
+ completed(30, { async: 1 }, true),
+ noStats(31)
+ )
+ })
+ );
+ setHistory([historyEntry(nextId++, 'FAILED', 30)]);
+
+ expect(shownRows()).toContain('Agg #30');
+ expect(shownRows()).toContain('Agg #31');
+ toggleSlowestFirst();
+ expect(shownRows().slice(0, 3)).toEqual(['Agg #30', 'Agg #31', 'Agg
#29']);
+ });
+ });
+
+ it('highlights the hovered row across the timeline', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+
+ expect(timeline()!.interactions).toContainEqual(['elementHighlightByX',
true]);
+ });
+
+ it('shows a timeline tooltip only over a bar, next to the pointer, and hides
it when the rows are redrawn', () => {
+ mount([historyEntry(7, 'COMPLETED', 4)]);
+ const chart = timeline()!;
+ const pointer = (event: string, payload?: unknown): void =>
chart.handlers.get(event)!(payload);
+ const bar = { phase: 'async' };
+ const plot = { className: 'plot', getBBox: () => ({ x: 50, y: 20 }) };
+
+ expect(chart.interactions).toContainEqual(['tooltip',
expect.objectContaining({ disableNative: true, wait: 0 })]);
+ chart.emitted.length = 0;
+ pointer('interval:pointermove', {
+ data: { data: bar },
+ canvas: { x: 80, y: 30 },
+ target: { className: 'element', parentNode: { className: 'group',
parentNode: plot } }
+ });
+ pointer('plot:pointermove', { target: { className: 'plot' } });
+ clickBar(7);
+
+ expect(chart.emitted).toEqual([
+ // g2 takes the position relative to the plot area.
+ ['tooltip:show', { offsetX: 30, offsetY: 10, data: { data: bar } }],
+ ['tooltip:hide', undefined],
+ ['tooltip:hide', undefined]
+ ]);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts
new file mode 100644
index 00000000000..7f0ce9e54e3
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts
@@ -0,0 +1,913 @@
+/*
+ * 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 { DatePipe, NgFor, NgIf } from '@angular/common';
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ Input,
+ OnChanges,
+ OnDestroy,
+ SimpleChanges,
+ ViewChild
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { BehaviorSubject, defer, EMPTY, Observable, of, Subject, timer } from
'rxjs';
+import { catchError, distinctUntilChanged, filter, map, repeat, switchMap,
takeUntil } from 'rxjs/operators';
+
+import { Chart } from '@antv/g2';
+import { HumanizeBytesPipe } from
'@flink-runtime-web/components/humanize-bytes.pipe';
+import { HumanizeDurationPipe } from
'@flink-runtime-web/components/humanize-duration.pipe';
+import {
+ Checkpoint,
+ CheckpointConfig,
+ CheckpointHistory,
+ CheckpointSubTask,
+ VerticesItem
+} from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { formatTimeAxis, startAlignedTicks } from '@flink-runtime-web/utils';
+import { NzAlertModule } from 'ng-zorro-antd/alert';
+import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
+import { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSelectModule } from 'ng-zorro-antd/select';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTagModule } from 'ng-zorro-antd/tag';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+import {
+ buildTimelineRows,
+ InstantDatum,
+ PhaseKey,
+ slowestFirstOrder,
+ StripDatum,
+ TimelineDatum,
+ TimelineRows,
+ toStripDatum,
+ UnackedDatum
+} from './checkpoint-timeline-rows';
+
+// 'not-retained': every vertex returned 404, i.e. the JobManager no longer
keeps this checkpoint's stats.
+type LoadError = 'not-retained' | 'request-failed';
+
+interface TimelineLoad {
+ datum: StripDatum;
+ perVertex: Record<string, CheckpointSubTask | null>;
+ failedCount: number;
+ requestedCount: number;
+ error?: LoadError;
+}
+
+// The legend swatches in the .less file use the theme's palette tokens for
the same colors.
+const STRIP_COLOR: Record<StripDatum['status'], string> = {
+ COMPLETED: '#52c41a',
+ SAVEPOINT: '#722ed1',
+ IN_PROGRESS: '#faad14',
+ FAILED: '#f5222d'
+};
+
+const STRIP_STATUSES = Object.keys(STRIP_COLOR) as Array<StripDatum['status']>;
+
+const PHASE_COLOR: Record<PhaseKey, string> = {
+ start_delay: '#b8c0cc',
+ alignment: '#91d5ff',
+ sync: '#fa8c16',
+ async: '#52c41a'
+};
+
+const PHASES = Object.keys(PHASE_COLOR) as PhaseKey[];
+
+const NOT_COMPLETED_STROKE = '#f5222d';
+const NO_ACK_FILL = '#fff1f0';
+const NO_ACK_STRIPE = '#ffa39e';
+const INSTANT_FILL = '#8c8c8c';
+
+type IntervalMark = ReturnType<Chart['interval']>;
+
+// Built once and updated in place on reloads, so following an in-progress
checkpoint doesn't rebuild the canvas.
+interface TimelineChart {
+ chart: Chart;
+ phases: IntervalMark;
+ unacked: IntervalMark;
+ instants: IntervalMark;
+}
+
+@Component({
+ selector: 'flink-job-checkpoints-timeline',
+ templateUrl: './job-checkpoints-timeline.component.html',
+ styleUrls: ['./job-checkpoints-timeline.component.less'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ NgFor,
+ NgIf,
+ DatePipe,
+ FormsModule,
+ HumanizeDurationPipe,
+ HumanizeBytesPipe,
+ NzAlertModule,
+ NzButtonModule,
+ NzCheckboxModule,
+ NzEmptyModule,
+ NzSelectModule,
+ NzSpinModule,
+ NzTagModule,
+ NzTooltipModule
+ ]
+})
+export class JobCheckpointsTimelineComponent implements AfterViewInit,
OnChanges, OnDestroy {
+ @Input() public jobId: string;
+ @Input() public vertices: VerticesItem[] = [];
+ @Input() public history: CheckpointHistory[] = [];
+ @Input() public config?: CheckpointConfig;
+
+ @ViewChild('stripContainer', { static: true }) private readonly
stripContainer: ElementRef<HTMLDivElement>;
+ @ViewChild('timelineContainer', { static: true }) private readonly
timelineContainer: ElementRef<HTMLDivElement>;
+ @ViewChild('timelineScroll', { static: true }) private readonly
timelineScroll: ElementRef<HTMLDivElement>;
+ @ViewChild('timelineWrapper', { static: true }) private readonly
timelineWrapper: ElementRef<HTMLDivElement>;
+
+ public selected?: StripDatum;
+ public loadingTimeline = false;
+ public timelineEmpty = false;
+ public loadError?: LoadError;
+ public failedVertexCount = 0;
+ public requestedVertexCount = 0;
+ // Rows of the shown checkpoint, how many of the slowest are shown, and the
first one scrolled to.
+ public totalRows = 0;
+ public slowestCount = JobCheckpointsTimelineComponent.SHOWN_ROWS_DEFAULT;
+ public firstRow = 0;
+ public chartHeight = 0;
+ public slowestFirst = false;
+ // When the shown checkpoint stats were last fetched, and whether the latest
poll for them failed.
+ public statsUpdatedAt?: number;
+ public statsRefreshFailed = false;
+
+ // Diverges from @Input once polling starts feeding live updates.
+ private currentHistory: CheckpointHistory[] = [];
+ public userPinned = false;
+ private stripChart?: Chart;
+ private stripInterval?: IntervalMark;
+ // What the strip last drew; polls that change nothing skip the redraw,
which would drop an open tooltip.
+ private stripDrawn?: string;
+ private timeline?: TimelineChart;
+ // Full operator name per row label, for the native tooltip on the truncated
labels.
+ private rowTitles = new Map<string, string>();
+ // Kept so scrolling can redraw another window of rows without rebuilding
them.
+ private timelineRows?: TimelineRows & { datum: StripDatum; slowestOrder:
string[] };
+ private scrollFrame?: number;
+ private noAckHatch?: HTMLCanvasElement;
+ private resizeObserver?: ResizeObserver;
+ private readonly destroy$ = new Subject<void>();
+ // null cancels a load in flight.
+ private readonly loadTimeline$ = new Subject<StripDatum | null>();
+ // A load requested while the Timeline tab was hidden; it runs once the tab
is shown.
+ private loadDeferred = false;
+ // The checkpoint whose failed load was already retried while following, so
polls don't retry it again.
+ private retriedCheckpointId?: number;
+ // Terminal (completed or failed) checkpoint stats no longer change; memoize
by id for instant re-selects.
+ // Holds only the checkpoints that can be selected again: those in the
strip, plus the pinned one.
+ private readonly detailsCache = new Map<number, Record<string,
CheckpointSubTask | null>>();
+ private readonly visible$ = new BehaviorSubject<boolean>(false);
+ private static readonly POLL_MIN_MS = 1500;
+ private static readonly POLL_MAX_MS = 15000;
+ private static readonly STRIP_MAX_BARS = 60;
+ private static readonly STRIP_HEIGHT = 160;
+ private static readonly TIMELINE_ROW_HEIGHT = 22;
+ // paddingTop (time axis) + paddingBottom, plus a little slack.
+ private static readonly TIMELINE_HEIGHT_BASE = 56;
+ // Rows shown by default (the slowest) and the most that can be picked; the
chart is at most VISIBLE_ROWS tall.
+ private static readonly SHOWN_ROWS_DEFAULT = 25;
+ private static readonly SHOWN_ROWS_MAX = 200;
+ private static readonly VISIBLE_ROWS = 25;
+ private static readonly TIMELINE_HEIGHT_MIN = 160;
+ private static readonly LABEL_CHAR_WIDTH = 7;
+ private static readonly LABEL_PADDING_BUFFER = 24;
+ private static readonly LABEL_PADDING_MIN = 260;
+ private static readonly LABEL_PADDING_MAX = 520;
+
+ private readonly humanizeBytesPipe = new HumanizeBytesPipe();
+ private readonly humanizeDurationPipe = new HumanizeDurationPipe();
+
+ constructor(
+ private readonly jobService: JobService,
+ private readonly cdr: ChangeDetectorRef
+ ) {}
+
+ public ngAfterViewInit(): void {
+ this.currentHistory = this.history || [];
+ this.renderStrip();
+ this.subscribeTimelineLoads();
+ if (this.currentHistory.length > 0) {
+ this.selectInternal(toStripDatum(this.currentHistory[0]), false);
+ }
+ this.startPolling();
+ // G2 locks autoFit at construction; re-fit + resume polling once width is
real.
+ this.visible$.next(this.stripContainer.nativeElement.clientWidth > 0);
+ if (typeof ResizeObserver !== 'undefined') {
+ this.resizeObserver = new ResizeObserver(() => {
+ const stripVisible = this.stripContainer.nativeElement.clientWidth > 0;
+ if (this.stripChart && stripVisible) {
+ void this.stripChart.forceFit();
+ }
+ if (this.timeline && this.timelineContainer.nativeElement.clientWidth
> 0) {
+ void this.timeline.chart.forceFit();
+ }
+ this.visible$.next(stripVisible);
+ });
+ this.resizeObserver.observe(this.stripContainer.nativeElement);
+ this.resizeObserver.observe(this.timelineContainer.nativeElement);
+ }
+ }
+
+ public ngOnChanges(changes: SimpleChanges): void {
+ if (!this.stripContainer || !this.stripContainer.nativeElement) {
+ return;
+ }
+ if (changes['jobId'] && !changes['jobId'].firstChange) {
+ this.userPinned = false;
+ this.selected = undefined;
+ this.currentHistory = [];
+ this.statsUpdatedAt = undefined;
+ this.statsRefreshFailed = false;
+ // Otherwise a load still in flight for the old job lands in the cache
under an id the new job reuses.
+ this.loadTimeline$.next(null);
+ this.detailsCache.clear();
+ this.retriedCheckpointId = undefined;
+ this.clearTimeline();
+ this.renderStrip();
+ }
+ if (changes['history']) {
+ this.markStatsFresh();
+ this.applyHistory(this.history || []);
+ }
+ if (changes['vertices'] && !changes['vertices'].firstChange &&
this.selected) {
+ this.loadTimeline(this.selected);
+ }
+ }
+
+ public ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.resizeObserver?.disconnect();
+ if (this.scrollFrame !== undefined) {
+ cancelAnimationFrame(this.scrollFrame);
+ }
+ this.stripChart?.destroy();
+ this.timeline?.chart.destroy();
+ }
+
+ private renderStrip(): void {
+ const data = (this.currentHistory || [])
+ .slice(0, JobCheckpointsTimelineComponent.STRIP_MAX_BARS)
+ .map(toStripDatum)
+ .reverse();
+
+ if (!this.stripChart || !this.stripInterval) {
+ this.stripChart = new Chart({
+ container: this.stripContainer.nativeElement,
+ autoFit: true,
+ height: JobCheckpointsTimelineComponent.STRIP_HEIGHT,
+ paddingTop: 16,
+ paddingRight: 16,
+ paddingBottom: 36,
+ paddingLeft: 56
+ });
+ const chart = this.stripChart;
+ const isPinned = (d: StripDatum): boolean => this.userPinned &&
this.selected?.id === d.id;
+ this.stripInterval = chart
+ .interval()
+ .encode('x', 'idLabel')
+ .encode('y', 'duration')
+ .encode('color', 'status')
+ .scale('y', { nice: true, domainMin: 0 })
+ .scale('color', { domain: STRIP_STATUSES, range: STRIP_STATUSES.map(s
=> STRIP_COLOR[s]) })
+ .axis('x', { title: false, labelAutoHide: true, labelAutoRotate: false
})
+ .axis('y', { title: false })
+ .legend(false)
+ .animate(false)
+ .style('maxWidth', 18)
+ // Keeps 0ms bars (e.g. in progress before the first ack) and very
short ones visible and clickable.
+ .style('minHeight', 3)
+ .style('stroke', (d: StripDatum) => (isPinned(d) ? '#1890ff' :
'transparent'))
+ .style('lineWidth', (d: StripDatum) => (isPinned(d) ? 2.5 : 0))
+ .tooltip({
+ title: '',
+ items: [
+ (d: StripDatum) => ({
+ name: `#${d.idLabel}`,
+ color: STRIP_COLOR[d.status],
+ value: `${d.rawStatus} ·
${this.humanizeDurationPipe.transform(d.duration)} · acked ${d.acked}`
+ })
+ ]
+ })
+ .interaction('tooltip', BARS_ONLY_TOOLTIP);
+ showTooltipOverBarsOnly(chart);
+ chart.on('interval:click', (ev: { data?: { data?: StripDatum } }) => {
+ const datum = ev.data?.data;
+ if (datum) {
+ this.select(datum);
+ }
+ });
+ }
+
+ const drawn = JSON.stringify([this.userPinned ? this.selected?.id : null,
data]);
+ if (drawn === this.stripDrawn) {
+ return;
+ }
+ this.stripDrawn = drawn;
+ // The bars move under the pointer; the next move over one shows its
tooltip again.
+ this.stripChart.emit('tooltip:hide');
+ this.stripInterval.scale('x', { domain: data.map(d => d.idLabel) });
+ this.stripInterval.data(data);
+ void this.stripChart.render();
+ }
+
+ public select(datum: StripDatum): void {
+ // Clicking the already-pinned bar resumes auto-follow.
+ if (this.userPinned && this.selected && this.selected.id === datum.id) {
+ this.followNewest();
+ return;
+ }
+ this.selectInternal(datum, true);
+ }
+
+ public followNewest(): void {
+ this.userPinned = false;
+ if (this.currentHistory.length > 0) {
+ this.selectInternal(toStripDatum(this.currentHistory[0]), false);
+ } else {
+ this.cdr.markForCheck();
+ }
+ }
+
+ private selectInternal(datum: StripDatum, pinned: boolean): void {
+ this.selected = datum;
+ if (pinned) {
+ this.userPinned = true;
+ }
+ this.cdr.markForCheck();
+ this.renderStrip();
+ this.loadTimeline(datum);
+ }
+
+ private applyHistory(next: CheckpointHistory[]): void {
+ const prevNewestId = this.currentHistory.length > 0 ?
this.currentHistory[0].id : -1;
+ const nextNewestId = next.length > 0 ? next[0].id : -1;
+ this.currentHistory = next;
+ this.pruneDetailsCache();
+ this.renderStrip();
+ // The empty state and the rotated-out note read currentHistory.
+ this.cdr.markForCheck();
+
+ if (next.length === 0) {
+ // The pinned checkpoint is gone too (e.g. after a JobManager failover),
so follow whatever comes next.
+ this.userPinned = false;
+ this.selected = undefined;
+ this.clearTimeline();
+ return;
+ }
+
+ if (!this.userPinned && nextNewestId !== prevNewestId) {
+ this.selectInternal(toStripDatum(next[0]), false);
+ return;
+ }
+ // Still showing the same checkpoint (the followed newest one, or the
pinned one): reload once it gained acks or
+ // finished, or retry a failed load once (retrying on every poll would
repeat the error notifications). Not while
+ // a load is in flight, so polling doesn't keep cancelling a slow fetch.
+ const selected = this.selected;
+ const latest = this.userPinned ? next.find(h => h.id === selected?.id) :
next[0];
+ if (!selected || !latest || latest.id !== selected.id ||
this.loadingTimeline) {
+ return;
+ }
+ const current = toStripDatum(latest);
+ const progressed = current.rawStatus !== selected.rawStatus ||
current.acked !== selected.acked;
+ const retry =
+ (this.loadError !== undefined || this.failedVertexCount > 0) &&
this.retriedCheckpointId !== current.id;
+ if (retry) {
+ this.retriedCheckpointId = current.id;
+ }
+ if (progressed || retry) {
+ this.selectInternal(current, false);
+ }
+ }
+
+ private pruneDetailsCache(): void {
+ const selectable = new Set(
+ this.currentHistory.slice(0,
JobCheckpointsTimelineComponent.STRIP_MAX_BARS).map(h => h.id)
+ );
+ for (const id of this.detailsCache.keys()) {
+ if (!selectable.has(id) && id !== this.selected?.id) {
+ this.detailsCache.delete(id);
+ }
+ }
+ }
+
+ private startPolling(): void {
+ this.visible$
+ .pipe(
+ distinctUntilChanged(),
+ switchMap(visible =>
+ visible
+ ? defer((): Observable<Checkpoint | 'failed'> => {
+ const jobId = this.jobId;
+ return jobId
+ ? this.jobService.loadCheckpointStats(jobId).pipe(
+ catchError(() => of('failed' as const)),
+ // A response for the previous job can land after a
jobId change has reset the state.
+ filter(() => jobId === this.jobId)
+ )
+ : EMPTY;
+ }).pipe(repeat({ delay: () => timer(this.computePollDelayMs())
}))
+ : EMPTY
+ ),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(stats => {
+ if (stats === 'failed') {
+ // Keep showing the last stats, marked as stale; the next poll tries
again.
+ this.statsRefreshFailed = true;
+ this.cdr.markForCheck();
+ return;
+ }
+ this.markStatsFresh();
+ this.applyHistory(stats.history || []);
+ });
+ }
+
+ private markStatsFresh(): void {
+ this.statsUpdatedAt = Date.now();
+ this.statsRefreshFailed = false;
+ }
+
+ private computePollDelayMs(): number {
+ // Disabled periodic checkpointing reports Long.MAX_VALUE, which the upper
clamp covers.
+ const interval = this.config?.interval ?? 0;
+ return interval > 0
+ ? Math.max(
+ JobCheckpointsTimelineComponent.POLL_MIN_MS,
+ Math.min(interval, JobCheckpointsTimelineComponent.POLL_MAX_MS)
+ )
+ : JobCheckpointsTimelineComponent.POLL_MAX_MS;
+ }
+
+ private loadTimeline(datum: StripDatum): void {
+ // The component also lives in the hidden tab; don't fetch every vertex's
details until it is shown.
+ if (!this.visible$.value) {
+ this.loadDeferred = true;
+ return;
+ }
+ this.loadDeferred = false;
+ if (!this.jobId || this.vertices.length === 0) {
+ this.showEmptyTimeline();
+ return;
+ }
+ this.loadingTimeline = true;
+ this.timelineEmpty = false;
+ this.loadError = undefined;
+ this.failedVertexCount = 0;
+ this.cdr.markForCheck();
+ this.loadTimeline$.next(datum);
+ }
+
+ private cacheDetails(datum: StripDatum, perVertex: Record<string,
CheckpointSubTask | null>): void {
+ // In-progress stats still change; only cache terminal checkpoints.
`status` is 'SAVEPOINT' for an in-progress
+ // savepoint, so check rawStatus.
+ if (datum.rawStatus !== 'IN_PROGRESS') {
+ this.detailsCache.set(datum.id, perVertex);
+ }
+ }
+
+ private subscribeTimelineLoads(): void {
+ this.visible$
+ .pipe(
+ filter(visible => visible && this.loadDeferred),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => {
+ if (this.selected) {
+ this.loadTimeline(this.selected);
+ }
+ });
+
+ this.loadTimeline$
+ .pipe(
+ switchMap((datum): Observable<TimelineLoad> => {
+ if (!datum) {
+ return EMPTY;
+ }
+ const requestedCount = this.vertices.length;
+ const cached = this.detailsCache.get(datum.id);
+ if (cached) {
+ return of({ datum, perVertex: cached, failedCount: 0,
requestedCount });
+ }
+ const vertexIds = this.vertices.map(v => v.id);
+ return this.jobService.loadCheckpointAllSubtaskDetails(this.jobId,
datum.id, vertexIds).pipe(
+ map(perVertex => {
+ const failedCount = requestedCount -
Object.values(perVertex).filter(v => v !== null).length;
+ if (failedCount === 0) {
+ this.cacheDetails(datum, perVertex);
+ }
+ const error: LoadError | undefined =
+ requestedCount > 0 && failedCount === requestedCount ?
'not-retained' : undefined;
+ return { datum, perVertex, failedCount, requestedCount, error };
+ }),
+ catchError(() =>
+ of<TimelineLoad>({
+ datum,
+ perVertex: {},
+ failedCount: requestedCount,
+ requestedCount,
+ error: 'request-failed'
+ })
+ )
+ );
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(({ datum, perVertex, failedCount, requestedCount, error }) =>
{
+ this.loadingTimeline = false;
+ if (error) {
+ this.showEmptyTimeline(error);
+ return;
+ }
+ this.failedVertexCount = failedCount;
+ this.requestedVertexCount = requestedCount;
+ this.renderTimeline(datum, perVertex);
+ });
+ }
+
+ private renderTimeline(datum: StripDatum, perVertex: Record<string,
CheckpointSubTask | null>): void {
+ const built = buildTimelineRows(datum, this.vertices, perVertex,
this.config);
+ if (built.rowOrder.length === 0) {
+ this.showEmptyTimeline();
+ return;
+ }
+ this.timelineEmpty = false;
+ this.timelineRows = { datum, ...built, slowestOrder:
slowestFirstOrder(built) };
+ this.totalRows = built.rowOrder.length;
+ this.rowTitles = new Map();
+ for (const row of [...built.rows, ...built.unacked, ...built.instants]) {
+ if (!this.rowTitles.has(row.row)) {
+ this.rowTitles.set(row.row, `${row.operatorFull} #${row.subtask}`);
+ }
+ }
+ this.drawTimelineWindow();
+ }
+
+ // Draws the rows scrolled to, out of the slowest ones picked. The chart is
at most 25 rows tall and stays in place
+ // while the box around it scrolls, so its canvas stays far below the 65,535
px browsers allow, however big the job.
+ private drawTimelineWindow(): void {
+ const data = this.timelineRows;
+ if (!data) {
+ return;
+ }
+ const { datum, rows, unacked, instants, rowOrder } = data;
+ const C = JobCheckpointsTimelineComponent;
+ const slowest = data.slowestOrder.slice(0, this.shownRows);
+ const picked = new Set(slowest);
+ const order = this.slowestFirst ? slowest : rowOrder.filter(row =>
picked.has(row));
+ const visible = this.visibleRows;
+ this.firstRow = Math.max(0, Math.min(this.firstRow, order.length -
visible));
+ const windowRows = order.slice(this.firstRow, this.firstRow + visible);
+ const inWindow = new Set(windowRows);
+ const shown = <T extends { row: string }>(items: T[]): T[] =>
items.filter(d => inWindow.has(d.row));
+ this.chartHeight = Math.max(visible * C.TIMELINE_ROW_HEIGHT +
C.TIMELINE_HEIGHT_BASE, C.TIMELINE_HEIGHT_MIN);
+
+ // The label padding and the time scale cover all rows, so scrolling or
picking more rows doesn't shift them.
+ const longestLabel = rowOrder.reduce((m, s) => Math.max(m, s.length), 0);
+ const leftPadding = Math.min(
+ C.LABEL_PADDING_MAX,
+ Math.max(C.LABEL_PADDING_MIN, longestLabel * C.LABEL_CHAR_WIDTH +
C.LABEL_PADDING_BUFFER)
+ );
+ const scaleMin = datum.triggered;
+ const scaleMax = Math.max(
+ [...rows, ...unacked].reduce((m, r) => Math.max(m, r.range[1]),
scaleMin),
+ scaleMin + 1
+ );
+ const xScale = { domain: windowRows };
+ const yScale = {
+ domain: [scaleMin, scaleMax],
+ nice: false,
+ tickMethod: () => startAlignedTicks(new Date(scaleMin), new
Date(scaleMax)).map(Number)
+ };
+
+ const timeline = (this.timeline ??= this.createTimelineChart());
+ timeline.chart.attr('height', this.chartHeight);
+ timeline.chart.attr('paddingLeft', leftPadding);
+ timeline.phases.data(shown(rows)).scale('x', xScale).scale('y', yScale);
+ timeline.unacked.data(shown(unacked)).scale('x', xScale).scale('y',
yScale);
+ timeline.instants.data(shown(instants)).scale('x', xScale).scale('y',
yScale);
+ // The rows move under the pointer; the next move over a bar shows its
tooltip again.
+ timeline.chart.emit('tooltip:hide');
+ void timeline.chart.render();
+ this.cdr.markForCheck();
+ }
+
+ // How many rows are drawn: the slowest ones picked, or all rows if there
are fewer.
+ public get shownRows(): number {
+ return Math.min(this.slowestCount, this.totalRows);
+ }
+
+ public get visibleRows(): number {
+ return Math.min(JobCheckpointsTimelineComponent.VISIBLE_ROWS,
this.shownRows);
+ }
+
+ public get scrollable(): boolean {
+ return this.shownRows > this.visibleRows;
+ }
+
+ // The scroll box's content: the chart plus one row height for every shown
row out of view.
+ public get scrollContentHeight(): number {
+ return this.chartHeight + (this.shownRows - this.visibleRows) *
JobCheckpointsTimelineComponent.TIMELINE_ROW_HEIGHT;
+ }
+
+ // 25, 50, ... up to 200 or the number of rows, whichever is smaller; none
when every row is already shown.
+ public get slowestCountOptions(): number[] {
+ const C = JobCheckpointsTimelineComponent;
+ const max = Math.min(C.SHOWN_ROWS_MAX, this.totalRows);
+ if (max <= C.SHOWN_ROWS_DEFAULT) {
+ return [];
+ }
+ const options: number[] = [];
+ for (let n = C.SHOWN_ROWS_DEFAULT; n < max; n += C.SHOWN_ROWS_DEFAULT) {
+ options.push(n);
+ }
+ options.push(max);
+ return options;
+ }
+
+ public setSlowestCount(count: number): void {
+ this.slowestCount = count;
+ this.scrollToTop();
+ }
+
+ public setSlowestFirst(slowestFirst: boolean): void {
+ this.slowestFirst = slowestFirst;
+ this.scrollToTop();
+ }
+
+ // Other rows or another order: start again from the top.
+ private scrollToTop(): void {
+ this.firstRow = 0;
+ this.timelineScroll.nativeElement.scrollTop = 0;
+ this.drawTimelineWindow();
+ }
+
+ public onTimelineScroll(): void {
+ const first = Math.round(
+ this.timelineScroll.nativeElement.scrollTop /
JobCheckpointsTimelineComponent.TIMELINE_ROW_HEIGHT
+ );
+ if (first === this.firstRow) {
+ return;
+ }
+ this.firstRow = first;
+ // One redraw per frame, however many scroll events arrive.
+ if (this.scrollFrame === undefined) {
+ this.scrollFrame = requestAnimationFrame(() => {
+ this.scrollFrame = undefined;
+ this.drawTimelineWindow();
+ });
+ }
+ }
+
+ private createTimelineChart(): TimelineChart {
+ const container = this.timelineContainer.nativeElement;
+ const chart = new Chart({ container, autoFit: true, paddingTop: 32,
paddingRight: 32, paddingBottom: 16 });
+ chart.coordinate({ transform: [{ type: 'transpose' }] });
+ const phases = chart
+ .interval()
+ .encode('x', 'row')
+ .encode('y', 'range')
+ .encode('color', 'phase')
+ .scale('color', { domain: PHASES, range: PHASES.map(p => PHASE_COLOR[p])
})
+ .axis('x', {
+ title: false,
+ labelAutoHide: false,
+ labelAutoRotate: false,
+ labelFill: '#262626',
+ labelOpacity: 1
+ })
+ // On top, so the time axis is in view as soon as the chart is.
+ .axis('y', { title: false, position: 'top', labelFormatter: (v: number)
=> formatTimeAxis(new Date(Number(v))) })
+ .legend(false)
+ .animate(false)
+ .style('stroke', (d: TimelineDatum) => (d.aborted ? NOT_COMPLETED_STROKE
: 'transparent'))
+ .style('lineWidth', (d: TimelineDatum) => (d.aborted ? 1.5 : 0))
+ .style('lineDash', (d: TimelineDatum) => (d.aborted ? [3, 2] : [0, 0]))
+ .tooltip({
+ title: (d: TimelineDatum) => `${d.operatorFull} #${d.subtask}`,
+ items: [
+ (d: TimelineDatum) => ({ name: d.phaseLabel, color:
PHASE_COLOR[d.phase], value: `${d.durationMs}ms` }),
+ (d: TimelineDatum) => ({ name: 'total', color: 'transparent', value:
`${d.totalDuration}ms` }),
+ (d: TimelineDatum) => ({
+ name: 'state',
+ color: 'transparent',
+ value: this.humanizeBytesPipe.transform(d.stateSize)
+ }),
+ (d: TimelineDatum) => ({
+ name: 'checkpointed',
+ color: 'transparent',
+ value: this.humanizeBytesPipe.transform(d.checkpointedSize)
+ })
+ ]
+ });
+ // On the chart rather than on one mark: the other marks' defaults would
override it.
+ chart.interaction('tooltip', {
+ ...BARS_ONLY_TOOLTIP,
+ // Outside the scroll box, which would clip it on the last visible rows.
g2 places a tooltip before measuring
+ // it, so on its first frame it doesn't flip up yet.
+ mount: this.timelineWrapper.nativeElement,
+ // g2 5 ellipsizes the tooltip title at 360px; let full operator names
wrap instead.
+ css: {
+ '.g2-tooltip': { 'max-width': '480px' },
+ '.g2-tooltip-title': { 'white-space': 'normal', 'overflow-wrap':
'anywhere' }
+ }
+ });
+ showTooltipOverBarsOnly(chart);
+ // Returned from a callback: g2 deep-copies constant style objects, which
would turn the canvas into a plain object.
+ const hatchFill = { image: (this.noAckHatch ??= createHatchPattern()),
repetition: 'repeat' };
+ const unacked = chart
+ .interval()
+ .encode('x', 'row')
+ .encode('y', 'range')
+ .animate(false)
+ .style('fill', () => hatchFill)
+ .style('stroke', NOT_COMPLETED_STROKE)
+ .style('lineWidth', 1.5)
+ .style('lineDash', [3, 2])
+ .tooltip({
+ title: (d: UnackedDatum) => `${d.operatorFull} #${d.subtask}`,
+ items: [(d: UnackedDatum) => ({ name: d.label, color:
NOT_COMPLETED_STROKE, value: 'no stats reported' })]
+ });
+ const instants = chart
+ .interval()
+ .encode('x', 'row')
+ .encode('y', 'range')
+ .animate(false)
+ // The range is zero-length; once transposed, minHeight is the on-screen
length along the time axis.
+ .style('minHeight', 2)
+ .style('fill', (d: InstantDatum) => (d.aborted ? NOT_COMPLETED_STROKE :
INSTANT_FILL))
+ .tooltip({
+ title: (d: InstantDatum) => `${d.operatorFull} #${d.subtask}`,
+ // Same rows as the phase bars, so 'total' always means the sum of the
phases.
+ items: [
+ (d: InstantDatum) => ({
+ name: 'all phases',
+ color: d.aborted ? NOT_COMPLETED_STROKE : INSTANT_FILL,
+ value: '0ms'
+ }),
+ () => ({ name: 'total', color: 'transparent', value: '0ms' }),
+ (d: InstantDatum) => ({
+ name: 'state',
+ color: 'transparent',
+ value: this.humanizeBytesPipe.transform(d.stateSize)
+ }),
+ (d: InstantDatum) => ({
+ name: 'checkpointed',
+ color: 'transparent',
+ value: this.humanizeBytesPipe.transform(d.checkpointedSize)
+ })
+ ]
+ });
+
+ // Row labels are truncated; show the full operator name as a native
tooltip while hovering one.
+ chart.on('axis-label-item:pointerover', (ev: { target?: { attributes?: {
text?: unknown } } }) => {
+ container.title = this.rowTitles.get(String(ev.target?.attributes?.text
?? '')) ?? '';
+ });
+ chart.on('axis-label-item:pointerout', () => (container.title = ''));
+ // Fades the other rows while hovering one, to follow a row across the
chart.
+ chart.interaction('elementHighlightByX', true);
+ return { chart, phases, unacked, instants };
+ }
+
+ private clearTimeline(): void {
+ this.timeline?.chart.destroy();
+ this.timeline = undefined;
+ this.timelineRows = undefined;
+ this.totalRows = 0;
+ this.firstRow = 0;
+ this.timelineContainer.nativeElement.title = '';
+ this.loadingTimeline = false;
+ this.timelineEmpty = false;
+ this.loadError = undefined;
+ this.failedVertexCount = 0;
+ this.requestedVertexCount = 0;
+ }
+
+ private showEmptyTimeline(error?: LoadError): void {
+ this.clearTimeline();
+ this.timelineEmpty = true;
+ this.loadError = error;
+ this.cdr.markForCheck();
+ }
+
+ public get emptyTimelineMessage(): string {
+ switch (this.loadError) {
+ case 'not-retained':
+ return 'Could not load subtask data — the JobManager no longer retains
this checkpoint';
+ case 'request-failed':
+ return 'Could not load subtask data — the request to the JobManager
failed';
+ default:
+ return 'No subtask data for this checkpoint';
+ }
+ }
+
+ // The `history` input is the parent's snapshot; polling only updates
currentHistory.
+ public get hasHistory(): boolean {
+ return this.currentHistory.length > 0;
+ }
+
+ public get selectedRotatedOut(): boolean {
+ return this.userPinned && !!this.selected && !this.currentHistory.some(h
=> h.id === this.selected!.id);
+ }
+
+ public get partialFailureMessage(): string {
+ const operators = this.requestedVertexCount === 1 ? 'operator' :
'operators';
+ return `${this.failedVertexCount} of ${this.requestedVertexCount}
${operators} failed to load — the timeline below is incomplete.`;
+ }
+}
+
+// g2 points a bar chart's tooltip at the bar nearest the pointer along the
band axis, so it also shows over the empty
+// space next to short bars. With this, a chart shows it only while the
pointer is over a bar. No throttling, which
+// would drop the show for the bar the pointer ends on.
+const BARS_ONLY_TOOLTIP = { disableNative: true, wait: 0 };
+
+interface G2Node {
+ className?: string;
+ parentNode?: G2Node | null;
+ getBBox?: () => { x: number; y: number };
+}
+
+interface BarPointerEvent {
+ data?: { data?: object };
+ canvas?: { x: number; y: number };
+ target?: G2Node;
+}
+
+function showTooltipOverBarsOnly(chart: Chart): void {
+ const hide = (): void => void chart.emit('tooltip:hide');
+ // On every move, not just on entering a bar: a redraw drops the tooltip.
+ chart.on('interval:pointermove', (ev: BarPointerEvent) => {
+ const datum = ev.data?.data;
+ if (!datum) {
+ return;
+ }
+ // At the pointer, as g2 would place it; g2 takes the position relative to
the plot area.
+ let plot = ev.target;
+ while (plot && plot.className !== 'plot') {
+ plot = plot.parentNode ?? undefined;
+ }
+ const origin = plot?.getBBox?.();
+ const at = origin && ev.canvas ? { offsetX: ev.canvas.x - origin.x,
offsetY: ev.canvas.y - origin.y } : {};
+ chart.emit('tooltip:show', { ...at, data: { data: datum } });
+ });
+ chart.on('interval:pointerout', hide);
+ // A redraw can also move the bars away from a still pointer, with no
pointerout.
+ chart.on('plot:pointermove', (ev: BarPointerEvent) => {
+ if (ev.target?.className !== 'element') {
+ hide();
+ }
+ });
+}
+
+// Diagonal hatch for unacked rows, used by g2 as a repeating pattern fill.
+function createHatchPattern(): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+ canvas.width = 8;
+ canvas.height = 8;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.fillStyle = NO_ACK_FILL;
+ ctx.fillRect(0, 0, 8, 8);
+ ctx.strokeStyle = NO_ACK_STRIPE;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ // Main diagonal plus the two corner stubs so the stripes stay continuous
when tiled.
+ ctx.moveTo(0, 8);
+ ctx.lineTo(8, 0);
+ ctx.moveTo(-2, 2);
+ ctx.lineTo(2, -2);
+ ctx.moveTo(6, 10);
+ ctx.lineTo(10, 6);
+ ctx.stroke();
+ }
+ return canvas;
+}
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
index fb801c71748..9e07ec614b4 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
@@ -23,7 +23,7 @@ import type { Chart } from '@antv/g2';
import { JobService, ConfigService } from '@flink-runtime-web/services';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { JobTimelineComponent, startAlignedTicks } from
'./job-timeline.component';
+import { JobTimelineComponent } from './job-timeline.component';
import { JobLocalService } from '../job-local.service';
const mockJobDetail = {
@@ -130,21 +130,3 @@ describe('JobTimelineComponent', () => {
expect(fakeSub.render).toHaveBeenCalled();
});
});
-
-describe('startAlignedTicks', () => {
- const DAY_MS = 86_400_000;
-
- it('anchors ticks at the domain minimum', () => {
- const min = new Date(1_000);
- const ticks = startAlignedTicks(min, new Date(1_000 + 60_000));
- expect(ticks[0]).toEqual(min);
- expect(ticks.length).toBeLessThanOrEqual(8);
- });
-
- it('keeps the tick count bounded for ranges beyond the largest step', () => {
- const min = new Date(0);
- const ticks = startAlignedTicks(min, new Date(30 * DAY_MS));
- expect(ticks[0]).toEqual(min);
- expect(ticks.length).toBeLessThanOrEqual(8);
- });
-});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.ts
index 7fce3648ef1..cbec5e4ba11 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.ts
@@ -32,33 +32,11 @@ import { distinctUntilChanged, filter, takeUntil } from
'rxjs/operators';
import { Chart } from '@antv/g2';
import { JobDetailCorrect, VerticesItemRange } from
'@flink-runtime-web/interfaces';
import { JobService, ColorKey, ConfigService } from
'@flink-runtime-web/services';
-import { timeFormat } from 'd3-time-format';
+import { formatTimeAxis, startAlignedTicks } from '@flink-runtime-web/utils';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { JobLocalService } from '../job-local.service';
-// g2 5's string labelFormatter uses d3's numeric formatter, so a time scale
needs
-// a function; mirror the previous 'HH:mm:ss.SSS' mask.
-const formatTimeAxis = timeFormat('%H:%M:%S.%L');
-
-const TICK_STEPS_MS = [
- 1, 2, 5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200, 10800,
21600, 43200, 86400
-].map(s => s * 1000);
-
-const MAX_TICKS = 7;
-
-// Unlike g2's default "nice" ticks, start at the domain minimum so the axis
is anchored to the actual start time.
-// Beyond the largest step, spread the ticks evenly instead so the axis never
gets one tick per day.
-export function startAlignedTicks(min: Date, max: Date): Date[] {
- const range = Number(max) - Number(min);
- const step = TICK_STEPS_MS.find(s => range / s <= MAX_TICKS) ??
Math.ceil(range / MAX_TICKS);
- const ticks: Date[] = [];
- for (let t = Number(min); t <= Number(max) && step > 0; t += step) {
- ticks.push(new Date(t));
- }
- return ticks;
-}
-
@Component({
selector: 'flink-job-timeline',
templateUrl: './job-timeline.component.html',
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
index 9da4661ed15..7fc9e5a27e9 100644
--- a/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
+++ b/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
@@ -16,14 +16,15 @@
* limitations under the License.
*/
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpContext, HttpErrorResponse } from
'@angular/common/http';
import { firstValueFrom, of, throwError, toArray } from 'rxjs';
-import { JobDetail, JobOverview } from '@flink-runtime-web/interfaces';
+import { CheckpointSubTask, JobDetail, JobOverview } from
'@flink-runtime-web/interfaces';
import { JobResourceRequirements } from
'@flink-runtime-web/interfaces/job-resource-requirements';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigService } from './config.service';
+import { EXPECTED_NOT_FOUND } from './http-context';
import { JobService } from './job.service';
describe('JobService', () => {
@@ -133,6 +134,42 @@ describe('JobService', () => {
});
});
+ describe('loadCheckpointAllSubtaskDetails', () => {
+ const subtasksOf = (vid: string): string =>
`./jobs/job-1/checkpoints/details/7/subtasks/${vid}`;
+
+ it('maps a 404 to null for that vertex and keeps the other vertices',
async () => {
+ const detail = { id: 7 } as unknown as CheckpointSubTask;
+ httpClient.get.mockImplementation((url: string) =>
+ url === subtasksOf('v1') ? of(detail) : throwError(() => new
HttpErrorResponse({ status: 404 }))
+ );
+
+ const result = await
firstValueFrom(service.loadCheckpointAllSubtaskDetails('job-1', 7, ['v1',
'v2']));
+
+ expect(result).toEqual({ v1: detail, v2: null });
+ });
+
+ it('fails the whole load on any other error', async () => {
+ const error = new HttpErrorResponse({ status: 500 });
+ httpClient.get.mockImplementation((url: string) => (url ===
subtasksOf('v1') ? of({}) : throwError(() => error)));
+
+ await
expect(firstValueFrom(service.loadCheckpointAllSubtaskDetails('job-1', 7,
['v1', 'v2']))).rejects.toBe(
+ error
+ );
+ });
+
+ it('marks its requests as expecting a 404, unlike the single-vertex
default', () => {
+ httpClient.get.mockReturnValue(of({}));
+
+ service.loadCheckpointAllSubtaskDetails('job-1', 7, ['v1',
'v2']).subscribe();
+ service.loadCheckpointSubtaskDetails('job-1', 7, 'v3');
+
+ const expectsNotFound = httpClient.get.mock.calls.map(([, options]) =>
+ (options as { context: HttpContext }).context.get(EXPECTED_NOT_FOUND)
+ );
+ expect(expectsNotFound).toEqual([true, true, false]);
+ });
+ });
+
describe('URL construction', () => {
it('cancels a job through the yarn-cancel handler', async () => {
httpClient.get.mockReturnValue(of(undefined));
diff --git a/flink-runtime-web/web-dashboard/src/app/services/job.service.ts
b/flink-runtime-web/web-dashboard/src/app/services/job.service.ts
index 2e2ca5d46ea..0ed64bfb736 100644
--- a/flink-runtime-web/web-dashboard/src/app/services/job.service.ts
+++ b/flink-runtime-web/web-dashboard/src/app/services/job.service.ts
@@ -16,10 +16,10 @@
* limitations under the License.
*/
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpContext, HttpErrorResponse, HttpStatusCode } from
'@angular/common/http';
import { Injectable } from '@angular/core';
-import { EMPTY, forkJoin, mergeMap, Observable } from 'rxjs';
-import { catchError, map } from 'rxjs/operators';
+import { EMPTY, forkJoin, from, mergeMap, Observable, of, throwError } from
'rxjs';
+import { catchError, map, reduce } from 'rxjs/operators';
import {
Checkpoint,
@@ -53,11 +53,14 @@ import {
import { JobResourceRequirements } from
'@flink-runtime-web/interfaces/job-resource-requirements';
import { ConfigService } from './config.service';
+import { EXPECTED_NOT_FOUND } from './http-context';
@Injectable({
providedIn: 'root'
})
export class JobService {
+ private static readonly SUBTASK_DETAILS_CONCURRENCY = 6;
+
constructor(
private readonly httpClient: HttpClient,
private readonly configService: ConfigService
@@ -180,10 +183,36 @@ export class JobService {
public loadCheckpointSubtaskDetails(
jobId: string,
checkPointId: number,
- vertexId: string
+ vertexId: string,
+ expectNotFound = false
): Observable<CheckpointSubTask> {
return this.httpClient.get<CheckpointSubTask>(
-
`${this.configService.BASE_URL}/jobs/${jobId}/checkpoints/details/${checkPointId}/subtasks/${vertexId}`
+
`${this.configService.BASE_URL}/jobs/${jobId}/checkpoints/details/${checkPointId}/subtasks/${vertexId}`,
+ { context: new HttpContext().set(EXPECTED_NOT_FOUND, expectNotFound) }
+ );
+ }
+
+ public loadCheckpointAllSubtaskDetails(
+ jobId: string,
+ checkPointId: number,
+ vertexIds: string[]
+ ): Observable<Record<string, CheckpointSubTask | null>> {
+ // A 404 means the checkpoint has dropped out of the JobManager's stats
history; that vertex maps to null.
+ // Any other error fails the whole load. Bounded so jobs with many
vertices don't send every request at once.
+ return from(vertexIds).pipe(
+ mergeMap(
+ vid =>
+ this.loadCheckpointSubtaskDetails(jobId, checkPointId, vid,
true).pipe(
+ catchError((err: unknown) =>
+ err instanceof HttpErrorResponse && err.status ===
HttpStatusCode.NotFound
+ ? of<CheckpointSubTask | null>(null)
+ : throwError(() => err)
+ ),
+ map(result => ({ [vid]: result }))
+ ),
+ JobService.SUBTASK_DETAILS_CONCURRENCY
+ ),
+ reduce((acc, part) => Object.assign(acc, part), {} as Record<string,
CheckpointSubTask | null>)
);
}
diff --git a/flink-runtime-web/web-dashboard/src/app/utils/public-api.ts
b/flink-runtime-web/web-dashboard/src/app/utils/public-api.ts
index 328d9f1adef..7bcbea555b0 100644
--- a/flink-runtime-web/web-dashboard/src/app/utils/public-api.ts
+++ b/flink-runtime-web/web-dashboard/src/app/utils/public-api.ts
@@ -18,3 +18,4 @@
export * from './is-nil';
export * from './strong-type';
+export * from './time-axis';
diff --git a/flink-runtime-web/web-dashboard/src/app/utils/time-axis.spec.ts
b/flink-runtime-web/web-dashboard/src/app/utils/time-axis.spec.ts
new file mode 100644
index 00000000000..58d65f599cd
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/utils/time-axis.spec.ts
@@ -0,0 +1,43 @@
+/*
+ * 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, it } from 'vitest';
+
+import { startAlignedTicks } from './time-axis';
+
+describe('startAlignedTicks', () => {
+ const DAY_MS = 86_400_000;
+
+ it('anchors ticks at the domain minimum', () => {
+ const min = new Date(1_000);
+ const ticks = startAlignedTicks(min, new Date(1_000 + 60_000));
+ expect(ticks[0]).toEqual(min);
+ expect(ticks.length).toBeLessThanOrEqual(8);
+ });
+
+ it('uses sub-second steps for short ranges', () => {
+ expect(startAlignedTicks(new Date(0), new
Date(300)).map(Number)).toEqual([0, 50, 100, 150, 200, 250, 300]);
+ });
+
+ it('keeps the tick count bounded for ranges beyond the largest step', () => {
+ const min = new Date(0);
+ const ticks = startAlignedTicks(min, new Date(30 * DAY_MS));
+ expect(ticks[0]).toEqual(min);
+ expect(ticks.length).toBeLessThanOrEqual(8);
+ });
+});
diff --git a/flink-runtime-web/web-dashboard/src/app/utils/time-axis.ts
b/flink-runtime-web/web-dashboard/src/app/utils/time-axis.ts
new file mode 100644
index 00000000000..8b54cce5faa
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/utils/time-axis.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { timeFormat } from 'd3-time-format';
+
+// g2 5's string labelFormatter uses d3's numeric formatter, so a time scale
needs
+// a function; mirror the previous 'HH:mm:ss.SSS' mask.
+export const formatTimeAxis = timeFormat('%H:%M:%S.%L');
+
+const TICK_STEPS_MS = [
+ ...[1, 2, 5, 10, 20, 50, 100, 200, 500],
+ ...[1, 2, 5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200,
10800, 21600, 43200, 86400].map(s => s * 1000)
+];
+
+const MAX_TICKS = 7;
+
+// Unlike g2's default "nice" ticks, start at the domain minimum so the axis
is anchored to the actual start time.
+// Beyond the largest step, spread the ticks evenly instead so the axis never
gets one tick per day.
+export function startAlignedTicks(min: Date, max: Date): Date[] {
+ const range = Number(max) - Number(min);
+ const step = TICK_STEPS_MS.find(s => range / s <= MAX_TICKS) ??
Math.ceil(range / MAX_TICKS);
+ const ticks: Date[] = [];
+ for (let t = Number(min); t <= Number(max) && step > 0; t += step) {
+ ticks.push(new Date(t));
+ }
+ return ticks;
+}