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 516dd54a6528c..50caca308644c 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/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 4241e5387dd5c..ef368ac8a9e73 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. --> + +
+ + + + {{ option.label }} + + +
+ @@ -51,7 +72,7 @@ {{ watermark.subTaskIndex }} {{ watermark.watermark | humanizeWatermark }} - {{ watermark.watermark | humanizeWatermarkToDatetime }} + {{ watermark.watermark | humanizeWatermarkToDatetime: selectedTimezone }} 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 2e996cab4ed7a..7ee65c5351cf8 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 3153cbd537086..b243f251256d6 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(); + // 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(); 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$)