nictownsend commented on code in PR #28060:
URL: https://github.com/apache/flink/pull/28060#discussion_r4480064433


##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html:
##########
@@ -0,0 +1,174 @@
+<!--
+  ~ 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>
+    <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 }}
+          <span
+            *ngIf="userPinned"
+            class="pin-badge"
+            title="Pinned: the timeline below will not change until you click 
another checkpoint or 'Follow newest'."
+          >
+            pinned
+          </span>
+        </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" type="button" class="follow-newest" 
(click)="followNewest()">

Review Comment:
   The rest of the repo uses `nz-button`  rather than raw stylling



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html:
##########
@@ -0,0 +1,174 @@
+<!--
+  ~ 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>
+    <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 }}
+          <span
+            *ngIf="userPinned"
+            class="pin-badge"
+            title="Pinned: the timeline below will not change until you click 
another checkpoint or 'Follow newest'."
+          >
+            pinned
+          </span>
+        </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" 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' }}
+          <select
+            class="row-count"
+            aria-label="Number of slowest subtasks shown"
+            (change)="onSlowestCountChange($event)"
+          >
+            <option *ngFor="let n of slowestCountOptions" [value]="n" 
[selected]="n === shownRows">
+              {{ n }}
+            </option>
+          </select>
+          {{ shownRows < totalRows ? 'slowest of ' + totalRows + ' subtasks' : 
'subtasks' }}
+          {{ scrollable ? '· rows ' + (firstRow + 1) + '–' + (firstRow + 
visibleRows) : '' }}
+        </span>
+        <label class="slowest-first">
+          <input type="checkbox" [checked]="slowestFirst" 
(change)="onSlowestFirstChange($event)" />

Review Comment:
   The rest of the repo uses `nz-checkbox`  rather than raw stylling



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html:
##########
@@ -0,0 +1,174 @@
+<!--
+  ~ 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>
+    <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 }}
+          <span
+            *ngIf="userPinned"
+            class="pin-badge"
+            title="Pinned: the timeline below will not change until you click 
another checkpoint or 'Follow newest'."
+          >
+            pinned
+          </span>
+        </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" 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' }}
+          <select
+            class="row-count"

Review Comment:
   The rest of the repo uses `nz-select`  rather than raw stylling



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html:
##########
@@ -0,0 +1,174 @@
+<!--
+  ~ 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>
+    <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 }}
+          <span
+            *ngIf="userPinned"
+            class="pin-badge"

Review Comment:
   There's an `ng-badge` and `nz-tag` component available



##########
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 { ChangeDetectorRef, ElementRef, SimpleChange } from '@angular/core';
+import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
+
+import {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  SubTaskCheckpointStatisticsItem,
+  VerticesItem
+} from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobCheckpointsTimelineComponent } from 
'./job-checkpoints-timeline.component';
+
+const TRIGGERED = 1_000;
+
+interface Phases {
+  start_delay?: number;
+  alignment?: number;
+  sync?: number;
+  async?: number;
+}
+
+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;
+
+const noStats = (index: number): SubTaskCheckpointStatisticsItem =>
+  ({ index, status: 'pending_or_failed' }) as SubTaskCheckpointStatisticsItem;
+
+const details = (...subtasks: SubTaskCheckpointStatisticsItem[]): 
CheckpointSubTask =>
+  ({ subtasks }) as unknown as CheckpointSubTask;
+
+const vertex = (id: string, name: string): VerticesItem => ({ id, name, 
status: 'RUNNING' }) as VerticesItem;
+
+const checkpoint = (
+  rawStatus: string,
+  duration: number,
+  isSavepoint = false
+): { id: number; duration: number; rawStatus: string; isSavepoint: boolean; 
triggered: number } => ({
+  id: 7,
+  duration,
+  rawStatus,
+  isSavepoint,
+  triggered: TRIGGERED
+});
+
+type BuildRows = (
+  datum: ReturnType<typeof checkpoint>,
+  perVertex: Record<string, CheckpointSubTask | null>
+) => {
+  rows: Array<{ row: string; phase: string; phaseLabel: string; range: 
[number, number]; totalDuration: number }>;
+  unacked: Array<{ row: string; label: string; range: [number, number] }>;
+  instants: Array<{ row: string; aborted: boolean; range: [number, number] }>;
+  rowOrder: string[];
+};
+
+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 };
+
+describe('JobCheckpointsTimelineComponent rows', () => {
+  let component: JobCheckpointsTimelineComponent;
+  let buildRows: BuildRows;
+
+  beforeEach(() => {
+    component = new JobCheckpointsTimelineComponent(
+      {} as JobService,
+      { markForCheck: () => undefined } as unknown as ChangeDetectorRef
+    );
+    buildRows = (component as unknown as { buildTimelineRows: BuildRows 
}).buildTimelineRows.bind(component);
+  });
+
+  it('keeps rows in operator then subtask order instead of sorting by 
duration', () => {
+    component.vertices = [vertex('a', 'Agg'), vertex('s', 'Sink')];
+
+    const { rowOrder } = buildRows(checkpoint('COMPLETED', 60), {
+      a: details(completed(0, { async: 5 }), completed(1, { async: 50 })),

Review Comment:
   Can we swap the 5 and 50 for `a` - so that we get `agg 1, agg 0, sink 0` - 
just to avoid any regressions like "sort by index" would break this test.



##########
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 { ChangeDetectorRef, ElementRef, SimpleChange } from '@angular/core';
+import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
+
+import {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  SubTaskCheckpointStatisticsItem,
+  VerticesItem
+} from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobCheckpointsTimelineComponent } from 
'./job-checkpoints-timeline.component';
+
+const TRIGGERED = 1_000;
+
+interface Phases {
+  start_delay?: number;
+  alignment?: number;
+  sync?: number;
+  async?: number;
+}
+
+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;
+
+const noStats = (index: number): SubTaskCheckpointStatisticsItem =>
+  ({ index, status: 'pending_or_failed' }) as SubTaskCheckpointStatisticsItem;
+
+const details = (...subtasks: SubTaskCheckpointStatisticsItem[]): 
CheckpointSubTask =>
+  ({ subtasks }) as unknown as CheckpointSubTask;
+
+const vertex = (id: string, name: string): VerticesItem => ({ id, name, 
status: 'RUNNING' }) as VerticesItem;
+
+const checkpoint = (
+  rawStatus: string,
+  duration: number,
+  isSavepoint = false
+): { id: number; duration: number; rawStatus: string; isSavepoint: boolean; 
triggered: number } => ({
+  id: 7,
+  duration,
+  rawStatus,
+  isSavepoint,
+  triggered: TRIGGERED
+});
+
+type BuildRows = (
+  datum: ReturnType<typeof checkpoint>,
+  perVertex: Record<string, CheckpointSubTask | null>
+) => {
+  rows: Array<{ row: string; phase: string; phaseLabel: string; range: 
[number, number]; totalDuration: number }>;
+  unacked: Array<{ row: string; label: string; range: [number, number] }>;
+  instants: Array<{ row: string; aborted: boolean; range: [number, number] }>;
+  rowOrder: string[];
+};
+
+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 };
+
+describe('JobCheckpointsTimelineComponent rows', () => {
+  let component: JobCheckpointsTimelineComponent;
+  let buildRows: BuildRows;
+
+  beforeEach(() => {
+    component = new JobCheckpointsTimelineComponent(
+      {} as JobService,
+      { markForCheck: () => undefined } as unknown as ChangeDetectorRef
+    );
+    buildRows = (component as unknown as { buildTimelineRows: BuildRows 
}).buildTimelineRows.bind(component);
+  });
+
+  it('keeps rows in operator then subtask order instead of sorting by 
duration', () => {
+    component.vertices = [vertex('a', 'Agg'), vertex('s', 'Sink')];
+
+    const { rowOrder } = buildRows(checkpoint('COMPLETED', 60), {
+      a: details(completed(0, { async: 5 }), completed(1, { async: 50 })),
+      s: details(completed(0, { start_delay: 1 }))
+    });
+
+    expect(rowOrder).toEqual(['Agg #0', 'Agg #1', 'Sink #0']);
+  });
+
+  it('keeps a row with an instant marker for subtasks whose phases all rounded 
to 0ms', () => {
+    component.vertices = [vertex('s', 'Source')];
+
+    const { instants, rowOrder } = buildRows(checkpoint('COMPLETED', 15), {
+      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', () => {
+    component.vertices = [vertex('a', 'Agg')];
+
+    const { unacked, rowOrder } = buildRows(checkpoint('FAILED', 10), {
+      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', () => {
+    component.vertices = [vertex('a', 'Agg')];
+
+    const { unacked } = buildRows(checkpoint('IN_PROGRESS', 0), {
+      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', () => {
+    component.vertices = [vertex('a', 'Agg')];
+
+    const { unacked } = buildRows(checkpoint('FAILED', 10, true), { a: 
details(noStats(0)) });
+
+    expect(unacked.map(u => u.label)).toEqual(['no ack']);
+  });
+
+  it('ignores missing stats on completed checkpoints', () => {
+    component.vertices = [vertex('a', 'Agg')];
+
+    const { unacked, rowOrder } = buildRows(checkpoint('COMPLETED', 10), {
+      a: details(completed(0, { async: 2 }), noStats(1))
+    });
+
+    expect(unacked).toEqual([]);
+    expect(rowOrder).toEqual(['Agg #0']);
+  });
+
+  const segments = (
+    rows: ReturnType<BuildRows>['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]);
+
+  // 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 => {
+    component.vertices = [vertex('a', 'Agg')];
+    component.config = unalignedConfig(t);
+
+    const { rows } = buildRows(checkpoint('COMPLETED', 20), { a: 
details(completed(0, SUBTASK_PHASES)) });
+
+    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', 
() => {
+    component.vertices = [vertex('a', 'Agg')];
+    component.config = unalignedConfig(30_000);
+
+    const { rows } = buildRows(checkpoint('COMPLETED', 40_000), {
+      a: details(
+        completed(0, { start_delay: 5_000, alignment: 31_000, sync: 10, async: 
8_000 }),
+        completed(1, SUBTASK_PHASES)
+      )
+    });
+
+    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) 
=> {
+    component.vertices = [vertex('a', 'Agg')];
+    component.config = config;
+
+    const { rows } = buildRows(checkpoint('COMPLETED', 20, isSavepoint), { a: 
details(completed(0, SUBTASK_PHASES)) });
+
+    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]
+    ]);
+  });
+
+  it('updates one timeline chart across reloads instead of rebuilding it', () 
=> {
+    const mark = (): { data: ReturnType<typeof vi.fn>; scale: 
ReturnType<typeof vi.fn> } => {
+      const m = { data: vi.fn(), scale: vi.fn() };
+      m.data.mockReturnValue(m);
+      m.scale.mockReturnValue(m);
+      return m;
+    };
+    const chart = { attr: vi.fn(), render: vi.fn(), destroy: vi.fn() };
+    const create = vi.fn(() => ({ chart, phases: mark(), unacked: mark(), 
instants: mark() }));
+    const internals = component as unknown as {
+      createTimelineChart: typeof create;
+      renderTimeline: (datum: ReturnType<typeof checkpoint>, perVertex: 
Record<string, CheckpointSubTask>) => void;
+    };
+    internals.createTimelineChart = create;

Review Comment:
   This is patching internals, could we instead perhaps use TestBed with a mock 
at the g2 level with a fake Chart that tracks/counts these calls? That way we 
can assert changes based on public inputs to the component, not 
`renderTimeline` etc.
   
   ```
   fixture = TestBed.createComponent(JobCheckpointsTimelineComponent);
   fixture.componentRef.setInput('jobId', 'job-1');
   fixture.componentRef.setInput('history', [historyEntry(7, 'COMPLETED', 4)]);
   fixture.detectChanges();            // first render: ngOnChanges 
(firstChange), ngOnInit, ngAfterViewInit
   
   fixture.componentRef.setInput('history', [historyEntry(8, 'COMPLETED', 4), 
...]);
   fixture.detectChanges();            // ngOnChanges again, firstChange: false
   ```



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.toStripDatum(this.currentHistory[0]), false);
+    }
+    this.startPolling();

Review Comment:
   What was the reason for polling inside the component? If the parent polled, 
would this give a better experience on sibling pages as well as this one?



##########
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 { ChangeDetectorRef, ElementRef, SimpleChange } from '@angular/core';
+import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
+
+import {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  SubTaskCheckpointStatisticsItem,
+  VerticesItem
+} from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobCheckpointsTimelineComponent } from 
'./job-checkpoints-timeline.component';
+
+const TRIGGERED = 1_000;
+
+interface Phases {
+  start_delay?: number;
+  alignment?: number;
+  sync?: number;
+  async?: number;
+}
+
+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;
+
+const noStats = (index: number): SubTaskCheckpointStatisticsItem =>
+  ({ index, status: 'pending_or_failed' }) as SubTaskCheckpointStatisticsItem;
+
+const details = (...subtasks: SubTaskCheckpointStatisticsItem[]): 
CheckpointSubTask =>
+  ({ subtasks }) as unknown as CheckpointSubTask;
+
+const vertex = (id: string, name: string): VerticesItem => ({ id, name, 
status: 'RUNNING' }) as VerticesItem;
+
+const checkpoint = (
+  rawStatus: string,
+  duration: number,
+  isSavepoint = false
+): { id: number; duration: number; rawStatus: string; isSavepoint: boolean; 
triggered: number } => ({
+  id: 7,
+  duration,
+  rawStatus,
+  isSavepoint,
+  triggered: TRIGGERED
+});
+
+type BuildRows = (
+  datum: ReturnType<typeof checkpoint>,
+  perVertex: Record<string, CheckpointSubTask | null>
+) => {
+  rows: Array<{ row: string; phase: string; phaseLabel: string; range: 
[number, number]; totalDuration: number }>;
+  unacked: Array<{ row: string; label: string; range: [number, number] }>;
+  instants: Array<{ row: string; aborted: boolean; range: [number, number] }>;
+  rowOrder: string[];
+};
+
+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 };
+
+describe('JobCheckpointsTimelineComponent rows', () => {

Review Comment:
   Wondering if the row building could be a pure function imported by this 
component? Makes the component smaller, and we can then test it without driving 
component internals.



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.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 = [];
+      // 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.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 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
+    };
+  }
+
+  private renderStrip(): void {
+    const data = (this.currentHistory || [])
+      .slice(0, JobCheckpointsTimelineComponent.STRIP_MAX_BARS)
+      .map(h => this.toStripDatum(h))
+      .reverse();
+
+    if (!this.stripChart || !this.stripInterval) {
+      this.stripChart = new Chart({
+        container: this.stripContainer.nativeElement,
+        autoFit: true,
+        height: 110,
+        paddingTop: 16,
+        paddingRight: 16,
+        paddingBottom: 36,
+        paddingLeft: 56
+      });
+      const isPinned = (d: StripDatum): boolean => this.userPinned && 
this.selected?.id === d.id;
+      this.stripInterval = this.stripChart
+        .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}`
+            })
+          ]
+        });
+      this.stripChart.on('interval:click', (ev: { data?: { data?: StripDatum } 
}) => {
+        const datum = ev.data?.data;
+        if (datum) {
+          this.select(datum);
+        }
+      });
+    }
+
+    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(this.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(this.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 = this.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(() => {
+                const jobId = this.jobId;
+                return jobId
+                  ? this.jobService.loadCheckpointStats(jobId).pipe(
+                      catchError(() => of(undefined)),
+                      // A response for the previous job can land after a 
jobId change has reset the state.
+                      filter(() => jobId === this.jobId)
+                    )
+                  : of(undefined);
+              }).pipe(repeat({ delay: () => timer(this.computePollDelayMs()) 
}))
+            : EMPTY
+        ),
+        takeUntil(this.destroy$)
+      )
+      .subscribe(stats => {
+        if (!stats) {
+          return;
+        }
+        this.applyHistory(stats.history || []);
+      });
+  }
+
+  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 buildTimelineRows(datum: StripDatum, perVertex: Record<string, 
CheckpointSubTask | null>): 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 this.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);
+
+      for (const s of cp.subtasks) {
+        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 = this.alignmentBeforeSync(datum, 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.
+  private alignmentBeforeSync(datum: StripDatum, startDelay: number, 
alignment: number): number {
+    const config = this.config;
+    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));
+  }
+
+  private renderTimeline(datum: StripDatum, perVertex: Record<string, 
CheckpointSubTask | null>): void {
+    const built = this.buildTimelineRows(datum, perVertex);
+    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);
+    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 onSlowestFirstChange(event: Event): void {
+    this.setSlowestFirst((event.target as HTMLInputElement).checked);
+  }
+
+  public onSlowestCountChange(event: Event): void {
+    this.setSlowestCount(Number((event.target as HTMLSelectElement).value));
+  }
+
+  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)
+          })
+        ]
+      })
+      .interaction('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' }
+        }
+      });
+    // 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 = ''));

Review Comment:
   ```suggestion
       chart.on('axis-label-item:pointerout', () => (container.title = '')); 
chart.interaction('elementHighlightByX', true);
   ```
   
   Makes it easy to see which row when there are lots of subtasks.



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.html:
##########
@@ -0,0 +1,174 @@
+<!--
+  ~ 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>
+    <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 }}
+          <span
+            *ngIf="userPinned"

Review Comment:
   Can we use a tooltip like with the legend, for consistency?



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.less:
##########
@@ -0,0 +1,218 @@
+/*
+ * 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.
+ */
+
+.timeline-wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+header {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.hint {
+  margin-left: 8px;
+  color: #8c8c8c;

Review Comment:
   We should try to use theme imports - like in 
`flink-runtime-web/web-dashboard/src/app/components/blocked-badge/blocked-badge.component.less`
 and reference color tokens



##########
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';

Review Comment:
   We never actually render the component - can we have tests with TestBed (e.g 
`pages/job-manager/metrics/job-manager-metrics.component.spec.ts`) so that we 
can assert on render behaviour (e.g ngIf and bindings)



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.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 = [];
+      // 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.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 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
+    };
+  }
+
+  private renderStrip(): void {
+    const data = (this.currentHistory || [])
+      .slice(0, JobCheckpointsTimelineComponent.STRIP_MAX_BARS)
+      .map(h => this.toStripDatum(h))
+      .reverse();
+
+    if (!this.stripChart || !this.stripInterval) {
+      this.stripChart = new Chart({
+        container: this.stripContainer.nativeElement,
+        autoFit: true,
+        height: 110,
+        paddingTop: 16,
+        paddingRight: 16,
+        paddingBottom: 36,
+        paddingLeft: 56
+      });
+      const isPinned = (d: StripDatum): boolean => this.userPinned && 
this.selected?.id === d.id;
+      this.stripInterval = this.stripChart
+        .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))

Review Comment:
   ```
   const chart = new Chart(..);
   this.stripChart = chart;
   
   ...
   
   
   .interaction('tooltip', { disableNative: true })
   .interaction('elementHighlight', true)
   ...
   chart.on('interval:pointerover', (ev: { data?: { data?: StripDatum } }) => {
     const datum = ev.data?.data;
     if (datum) {
       chart.emit('tooltip:show', { data: { data: datum } });
     }
   });
   chart.on('interval:pointerout', () => chart.emit('tooltip:hide'));
   ```
   
   This will allow the tooltips to only show when you hover over the actual bar 
in the recent checkpoints. Currently it's popping up whenever you hover, which 
for small bars with large whitespace is making it hard to see which is hovered 
over.



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.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 = [];
+      // 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.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 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
+    };
+  }
+
+  private renderStrip(): void {
+    const data = (this.currentHistory || [])
+      .slice(0, JobCheckpointsTimelineComponent.STRIP_MAX_BARS)
+      .map(h => this.toStripDatum(h))
+      .reverse();
+
+    if (!this.stripChart || !this.stripInterval) {
+      this.stripChart = new Chart({
+        container: this.stripContainer.nativeElement,
+        autoFit: true,
+        height: 110,
+        paddingTop: 16,
+        paddingRight: 16,
+        paddingBottom: 36,
+        paddingLeft: 56
+      });
+      const isPinned = (d: StripDatum): boolean => this.userPinned && 
this.selected?.id === d.id;
+      this.stripInterval = this.stripChart
+        .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}`
+            })
+          ]
+        });
+      this.stripChart.on('interval:click', (ev: { data?: { data?: StripDatum } 
}) => {
+        const datum = ev.data?.data;
+        if (datum) {
+          this.select(datum);
+        }
+      });
+    }
+
+    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(this.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(this.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 = this.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(() => {
+                const jobId = this.jobId;
+                return jobId
+                  ? this.jobService.loadCheckpointStats(jobId).pipe(
+                      catchError(() => of(undefined)),

Review Comment:
   This causes silent failures of the timeline page, making it look like 
checkpoints have stopped. It would be better to show an error or at least an 
indicator that the data is stale.
   
   <img width="1542" height="997" alt="Image" 
src="https://github.com/user-attachments/assets/32e723f6-bfdb-4e6c-a95a-b76382ab5463";
 />



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.toStripDatum(this.currentHistory[0]), false);
+    }
+    this.startPolling();

Review Comment:
   As in, the parent could own the polling and pass a live history to the 
timeline, leaving the other tabs on manual refresh. Then the refresh button 
could say "new data available" or something to indicate that it has new data, 
so the refresh button is more of a "load latest state".



##########
flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/timeline/job-checkpoints-timeline.component.ts:
##########
@@ -0,0 +1,1047 @@
+/*
+ * 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 { 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 {
+  CheckpointConfig,
+  CheckpointHistory,
+  CheckpointSubTask,
+  CompletedSubTaskCheckpointStatistics,
+  SubTaskCheckpointStatisticsItem,
+  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 { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
+
+type PhaseKey = 'start_delay' | 'alignment' | 'sync' | 'async';
+
+// '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;
+}
+
+interface StripDatum {
+  id: number;
+  idLabel: string;
+  duration: number;
+  status: 'COMPLETED' | 'SAVEPOINT' | 'IN_PROGRESS' | 'FAILED';
+  rawStatus: string;
+  isSavepoint: boolean;
+  triggered: number;
+  acked: string;
+  size: number;
+}
+
+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.
+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.
+interface InstantDatum {
+  row: string;
+  operatorFull: string;
+  subtask: number;
+  aborted: boolean;
+  stateSize: number;
+  checkpointedSize: number;
+  range: [number, number];
+}
+
+interface TimelineRows {
+  rows: TimelineDatum[];
+  unacked: UnackedDatum[];
+  instants: InstantDatum[];
+  rowOrder: string[];
+}
+
+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: '#91caff',
+  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';
+
+const OPERATOR_NAME_MAX = 60;
+
+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,
+    HumanizeDurationPipe,
+    HumanizeBytesPipe,
+    NzAlertModule,
+    NzEmptyModule,
+    NzSpinModule,
+    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;
+
+  // Diverges from @Input once polling starts feeding live updates.
+  private currentHistory: CheckpointHistory[] = [];
+  public userPinned = false;
+  private stripChart?: Chart;
+  private stripInterval?: IntervalMark;
+  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 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(this.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 = [];
+      // 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.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 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
+    };
+  }
+
+  private renderStrip(): void {
+    const data = (this.currentHistory || [])
+      .slice(0, JobCheckpointsTimelineComponent.STRIP_MAX_BARS)
+      .map(h => this.toStripDatum(h))
+      .reverse();
+
+    if (!this.stripChart || !this.stripInterval) {
+      this.stripChart = new Chart({
+        container: this.stripContainer.nativeElement,
+        autoFit: true,
+        height: 110,

Review Comment:
   This is quite small, particularly when one checkpoint is longer than most, 
the small checkpoints are hard to see in the chart.
   
   Maybe 160? Also 
[job-checkpoints-timeline.component.less](https://github.com/apache/flink/pull/28060/changes#diff-8cbb2bf30835ffadb766c5014cadfeaac0bbe2e6f3dacca8bb71219bc60df449)
 sets height of 80, it should match the 160.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to