This is an automated email from the ASF dual-hosted git repository.
davidrad pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 64d0316aad2 [FLINK-39042][webui] Make watermark timestamps
timezone-aware and configurable (#27552)
64d0316aad2 is described below
commit 64d0316aad22e83888b65e6c3036d0a980ae33a9
Author: Feat Zhang <[email protected]>
AuthorDate: Mon Mar 23 18:42:33 2026 +0800
[FLINK-39042][webui] Make watermark timestamps timezone-aware and
configurable (#27552)
* [FLINK-39042][ui] Make watermark timestamps timezone-aware and
configurable
* [FLINK-39042][webui] Use Intl API for timezone handling and support DST
Address review feedback:
- Use native Intl.DateTimeFormat API instead of manual offset calculation
- Replace fixed UTC offsets with IANA timezone names (e.g.,
America/Los_Angeles)
- IANA timezones automatically handle daylight saving time transitions
- Remove redundant console.log statements and improve code quality
- Add documentation comments with reference to IANA timezone database
* [FLINK-39042][webui] Make watermark timestamps timezone-aware and
configurable
* [FLINK-39042][webui] Address code review comments
- Add MDN reference for Intl.DateTimeFormat API
- Clarify DST handling in timezone abbreviations (e.g., PST vs PDT)
- Improve error messages to explicitly mention UTC fallback
- Update comments to better explain timezone resolution logic
* Address review comments
- Remove 'pure: false' from HumanizeWatermarkToDatetimePipe as template
already passes timezone argument
- Use Intl.supportedValuesOf('timeZone') to get all supported timezones
instead of hardcoded list
- Keep browser local timezone as default
- Revert unnecessary changes in job-status.component.less
* fix: improve timezone fallback handling and CSS property ordering
- Enhance timezone detection fallback logic in watermark component\n- Fix
CSS property ordering in job status component for better maintainability
* style: improve code formatting for better readability
- Split long lines in timezone options initialization for improved code
readability
* style: optimize CSS property ordering for better maintainability
- Reorder CSS properties in job status component for improved code
organization
* fix: add type assertion for Intl.supportedValuesOf to fix TS compilation
error
* [FLINK-39042][webui] Use browser locale instead of hardcoded en-US in
watermark datetime pipe
Replace hardcoded 'en-US' locale with undefined in Intl.DateTimeFormat to
respect the user's browser locale settings. This ensures the date display
order follows the user's regional preferences rather than being fixed to
the en-US format.
Suggested by @davidradl in code review.
---
.../src/app/components/humanize-watermark.pipe.ts | 63 +++++++++++++++--
.../job-detail/status/job-status.component.less | 4 +-
.../job-overview-drawer-watermarks.component.html | 25 ++++++-
.../job-overview-drawer-watermarks.component.less | 18 +++++
.../job-overview-drawer-watermarks.component.ts | 82 ++++++++++++++++++++--
5 files changed, 178 insertions(+), 14 deletions(-)
diff --git
a/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.ts
b/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.ts
index 516dd54a652..50caca30864 100644
---
a/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.ts
+++
b/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.ts
@@ -21,7 +21,8 @@ import { Pipe, PipeTransform } from '@angular/core';
import { ConfigService } from '@flink-runtime-web/services';
@Pipe({
- name: 'humanizeWatermark'
+ name: 'humanizeWatermark',
+ standalone: true
})
export class HumanizeWatermarkPipe implements PipeTransform {
constructor(private readonly configService: ConfigService) {}
@@ -36,16 +37,68 @@ export class HumanizeWatermarkPipe implements PipeTransform
{
}
@Pipe({
- name: 'humanizeWatermarkToDatetime'
+ name: 'humanizeWatermarkToDatetime',
+ standalone: true
})
export class HumanizeWatermarkToDatetimePipe implements PipeTransform {
constructor(private readonly configService: ConfigService) {}
- public transform(value: number): number | string {
+ public transform(value: number, timezone: string = 'UTC'): number | string {
if (value == null || isNaN(value) || value <=
this.configService.LONG_MIN_VALUE) {
return 'N/A';
- } else {
- return new Date(value).toLocaleString();
+ }
+
+ try {
+ const date = new Date(value);
+
+ // Use Intl.DateTimeFormat for proper timezone handling including DST
+ // This native browser API automatically handles daylight saving time
transitions
+ // Use undefined as locale to respect the user's browser locale settings
+ // Reference:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat
+ const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ hour12: false,
+ minute: '2-digit',
+ second: '2-digit'
+ });
+
+ // Get timezone abbreviation (e.g., PST, PDT, EST, EDT)
+ // The abbreviation automatically reflects DST status (e.g., PST vs PDT)
+ const timezoneFormatter = new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ timeZoneName: 'short'
+ });
+
+ // Format the date parts
+ const parts = dateFormatter.formatToParts(date);
+ const year = parts.find(p => p.type === 'year')?.value;
+ const month = parts.find(p => p.type === 'month')?.value;
+ const day = parts.find(p => p.type === 'day')?.value;
+ const hour = parts.find(p => p.type === 'hour')?.value;
+ const minute = parts.find(p => p.type === 'minute')?.value;
+ const second = parts.find(p => p.type === 'second')?.value;
+
+ // Extract timezone abbreviation which includes DST information
+ // For example: PST (standard) vs PDT (daylight saving)
+ const timezoneParts = timezoneFormatter.formatToParts(date);
+ const timezoneAbbr = timezoneParts.find(p => p.type ===
'timeZoneName')?.value || timezone;
+
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}
(${timezoneAbbr})`;
+ } catch (error) {
+ // Fallback to UTC if timezone is invalid, so using UTC
+ console.error('[HumanizeWatermarkToDatetimePipe] Error formatting date,
falling back to UTC:', error);
+ const date = new Date(value);
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(date.getUTCDate()).padStart(2, '0');
+ const hour = String(date.getUTCHours()).padStart(2, '0');
+ const minute = String(date.getUTCMinutes()).padStart(2, '0');
+ const second = String(date.getUTCSeconds()).padStart(2, '0');
+ return `${year}-${month}-${day} ${hour}:${minute}:${second} (UTC)`;
}
}
}
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/status/job-status.component.less
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/status/job-status.component.less
index 9a2ef00ce4a..8285f117954 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/status/job-status.component.less
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/status/job-status.component.less
@@ -42,10 +42,10 @@
.back-link {
display: inline-flex;
align-items: center;
- color: @link-color;
- text-decoration: none;
margin-right: @margin-xs;
+ color: @link-color;
font-size: @font-size-base;
+ text-decoration: none;
cursor: pointer;
&:hover {
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.html
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.html
index 4241e5387dd..ef368ac8a9e 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.html
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.html
@@ -16,6 +16,27 @@
~ limitations under the License.
-->
+<!-- Timezone selector -->
+<div class="timezone-selector">
+ <label>Timezone:</label>
+ <nz-select
+ [(ngModel)]="selectedTimezone"
+ nzSize="small"
+ nzShowSearch
+ [nzPlaceHolder]="'Select Timezone'"
+ style="width: 300px"
+ >
+ <nz-option
+ *ngFor="let option of timezoneOptions"
+ [nzLabel]="option.label"
+ [nzValue]="option.value"
+ nzCustomContent
+ >
+ <span [title]="option.label">{{ option.label }}</span>
+ </nz-option>
+ </nz-select>
+</div>
+
<nz-table
class="no-border small full-height"
nzSize="small"
@@ -39,7 +60,7 @@
class="header-icon"
nz-icon
nz-tooltip
- nzTooltipTitle="This column shows the datetime that is parsed from
watermark timestamp with local time zone. Note that the time zone is obtained
through your browser. "
+ nzTooltipTitle="This column shows the datetime that is parsed from
watermark timestamp. You can select the desired timezone from the dropdown
above."
nzType="info-circle"
></i>
</th>
@@ -51,7 +72,7 @@
<tr>
<td>{{ watermark.subTaskIndex }}</td>
<td>{{ watermark.watermark | humanizeWatermark }}</td>
- <td>{{ watermark.watermark | humanizeWatermarkToDatetime }}</td>
+ <td>{{ watermark.watermark | humanizeWatermarkToDatetime:
selectedTimezone }}</td>
</tr>
</ng-container>
</ng-template>
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.less
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.less
index 2e996cab4ed..7ee65c5351c 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.less
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.less
@@ -37,3 +37,21 @@
}
}
}
+
+.timezone-selector {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ padding: 4px 0;
+
+ label {
+ margin-right: 8px;
+ color: @text-color;
+ font-size: @font-size-sm;
+ white-space: nowrap;
+ }
+
+ nz-select {
+ flex-shrink: 0;
+ }
+}
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.ts
index 3153cbd5370..b243f251256 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.ts
@@ -16,8 +16,9 @@
* limitations under the License.
*/
-import { NgIf } from '@angular/common';
+import { NgForOf, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy,
OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
import { of, Subject } from 'rxjs';
import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators';
@@ -28,6 +29,7 @@ import {
import { MetricsService } from '@flink-runtime-web/services';
import { typeDefinition } from '@flink-runtime-web/utils';
import { NzIconModule } from 'ng-zorro-antd/icon';
+import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
@@ -43,7 +45,18 @@ interface WatermarkData {
templateUrl: './job-overview-drawer-watermarks.component.html',
styleUrls: ['./job-overview-drawer-watermarks.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [NzTableModule, NgIf, HumanizeWatermarkPipe,
HumanizeWatermarkToDatetimePipe, NzIconModule, NzTooltipModule]
+ standalone: true,
+ imports: [
+ NgIf,
+ NgForOf,
+ FormsModule,
+ NzSelectModule,
+ NzTableModule,
+ NzIconModule,
+ NzTooltipModule,
+ HumanizeWatermarkPipe,
+ HumanizeWatermarkToDatetimePipe
+ ]
})
export class JobOverviewDrawerWatermarksComponent implements OnInit, OnDestroy
{
public readonly trackBySubtaskIndex = (_: number, node: { subTaskIndex:
number; watermark: number }): number =>
@@ -54,6 +67,14 @@ export class JobOverviewDrawerWatermarksComponent implements
OnInit, OnDestroy {
public virtualItemSize = 36;
public readonly narrowLogData = typeDefinition<WatermarkData>();
+ // Timezone related properties
+ // Using browser's native Intl API to get all supported timezones
+ // This provides a complete timezone list and properly handles Daylight
Saving Time (DST)
+ // Reference:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf
+ public timezoneOptions: Array<{ label: string; value: string }> = [];
+
+ public selectedTimezone: string = '';
+
private readonly destroy$ = new Subject<void>();
constructor(
@@ -62,7 +83,60 @@ export class JobOverviewDrawerWatermarksComponent implements
OnInit, OnDestroy {
private readonly cdr: ChangeDetectorRef
) {}
+ private getBrowserTimezone(): string {
+ // Get browser's IANA timezone identifier
+ // This will properly handle DST changes
+ // Reference:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch (error) {
+ console.error('[getBrowserTimezone] Error getting browser timezone,
falling back to UTC:', error);
+ // As a last resort, rely on runtime environment offset
+ // Note: Etc/GMT uses POSIX-style signs (opposite of ISO-8601):
+ // - Etc/GMT-8 = UTC+8 (East of Greenwich)
+ // - Etc/GMT+8 = UTC-8 (West of Greenwich)
+ // Reference: https://github.com/eggert/tz/blob/main/etcetera
+ const offset = new Date().getTimezoneOffset();
+ const sign = offset > 0 ? '+' : '-';
+ const hours = String(Math.floor(Math.abs(offset) / 60));
+ return `Etc/GMT${sign}${hours}`;
+ }
+ }
+
+ private initializeTimezoneOptions(): void {
+ // Use modern browser API to get all supported timezones
+ // Reference:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf
+ try {
+ // Cast to access supportedValuesOf which is available in modern
browsers but not in ES2018 type definitions
+ const intl = Intl as typeof Intl & { supportedValuesOf?: (key: string)
=> string[] };
+ if (typeof intl.supportedValuesOf === 'function') {
+ const timezones: string[] = intl.supportedValuesOf('timeZone');
+ this.timezoneOptions = timezones
+ .map((tz: string) => ({ label: tz, value: tz }))
+ .sort((a: { label: string }, b: { label: string }) =>
a.label.localeCompare(b.label));
+ } else {
+ // Fallback for browsers that don't support Intl.supportedValuesOf
+ console.warn(
+ '[initializeTimezoneOptions] Intl.supportedValuesOf not supported,
falling back to browser local timezone'
+ );
+ const browserTz = this.getBrowserTimezone();
+ this.timezoneOptions = [{ label: browserTz, value: browserTz }];
+ }
+ } catch (error) {
+ console.error('[initializeTimezoneOptions] Error initializing timezone
options:', error);
+ // Fallback to browser local timezone on error
+ const browserTz = this.getBrowserTimezone();
+ this.timezoneOptions = [{ label: browserTz, value: browserTz }];
+ }
+ }
+
public ngOnInit(): void {
+ // Initialize timezone options
+ this.initializeTimezoneOptions();
+
+ // Set default timezone to browser's timezone
+ this.selectedTimezone = this.getBrowserTimezone();
+
this.jobLocalService
.jobWithVertexChanges()
.pipe(
@@ -78,9 +152,7 @@ export class JobOverviewDrawerWatermarksComponent implements
OnInit, OnDestroy {
}
return list;
}),
- catchError(() => {
- return of([] as WatermarkData[]);
- })
+ catchError(() => of([] as WatermarkData[]))
)
),
takeUntil(this.destroy$)