diff --git a/README.md b/README.md index c3e1d219f..2a7fdb576 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ spec: | alertingRuleTenantLabelKey | name of the alerting rule label used to match the tenantId for log-based alerts. Allows log-based alerts to request metrics to the proper tenant endpoint | `tenantId` | string | | alertingRuleNamespaceLabelKey | name of the label used to filter alerting rules by namespace | `kubernetes_namespace_name` | string | | useTenantInHeader | whether or not the tenant header `X-Scope-OrgID` should be used instead of using the tenant in the URL request | `false` | boolean | +| showTimezoneSelector | whether or not to show the timezone selector in the UI | `false` | boolean | ## Build a testint the image diff --git a/pkg/server/server.go b/pkg/server/server.go index af0266411..36afe862b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -41,6 +41,7 @@ type PluginConfig struct { Timeout time.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` LogsLimit int `json:"logsLimit,omitempty" yaml:"logsLimit,omitempty"` Schema string `json:"schema,omitempty" yaml:"schema,omitempty"` + ShowTimezoneSelector bool `json:"showTimezoneSelector,omitempty" yaml:"showTimezoneSelector,omitempty"` } func (pluginConfig *PluginConfig) MarshalJSON() ([]byte, error) { diff --git a/web/locales/en/plugin__logging-view-plugin.json b/web/locales/en/plugin__logging-view-plugin.json index a601aed80..c61ffc4be 100644 --- a/web/locales/en/plugin__logging-view-plugin.json +++ b/web/locales/en/plugin__logging-view-plugin.json @@ -18,6 +18,7 @@ "Explain Log Volume": "Explain Log Volume", "Search by {{attributeName}}": "Search by {{attributeName}}", "Attribute": "Attribute", + "No results found for": "No results found for", "Filter by {{attributeName}}": "Filter by {{attributeName}}", "Search": "Search", "Loading...": "Loading...", @@ -131,6 +132,10 @@ "From": "From", "To": "To", "Invalid date range": "Invalid date range", + "Select timezone": "Select timezone", + "Clear input value": "Clear input value", + "All Timezones": "All Timezones", + "Common Timezones": "Common Timezones", "Hide Histogram": "Hide Histogram", "Show Histogram": "Show Histogram", "Pause streaming": "Pause streaming", @@ -141,4 +146,4 @@ "Aggregated Logs": "Aggregated Logs", "Logs": "Logs", "Please select a namespace": "Please select a namespace" -} +} \ No newline at end of file diff --git a/web/src/__tests__/date-utils.spec.ts b/web/src/__tests__/date-utils.spec.ts index 6f3c1f8b8..c60f7369a 100644 --- a/web/src/__tests__/date-utils.spec.ts +++ b/web/src/__tests__/date-utils.spec.ts @@ -1,4 +1,11 @@ -import { DateFormat, dateToFormat } from '../date-utils'; +import { + DateFormat, + dateToFormat, + getBrowserTimezone, + getTimezoneOffset, + normalizeTimezone, + parseInTimezone, +} from '../date-utils'; const mockDate = new Date('2023-02-22T08:38:36Z'); const mockDateWithMillis = new Date('2023-02-22T08:38:36.001Z'); @@ -14,7 +21,7 @@ describe('date utils', () => { { format: DateFormat.TimeMed, output: '08:38:36' }, { format: DateFormat.TimeShort, output: '08:38' }, ].forEach(({ format, output }) => { - expect(dateToFormat(mockDate, format)).toEqual(output); + expect(dateToFormat(mockDate, format, 'UTC')).toEqual(output); }); }); @@ -26,7 +33,7 @@ describe('date utils', () => { { format: DateFormat.TimeMed, output: '08:38:36' }, { format: DateFormat.TimeShort, output: '08:38' }, ].forEach(({ format, output }) => { - expect(dateToFormat(mockDateWithMillis, format)).toEqual(output); + expect(dateToFormat(mockDateWithMillis, format, 'UTC')).toEqual(output); }); }); @@ -38,11 +45,159 @@ describe('date utils', () => { { format: DateFormat.TimeMed, output: '08:08:06' }, { format: DateFormat.TimeShort, output: '08:08' }, ].forEach(({ format, output }) => { - expect(dateToFormat(mockDateWithValuesUnderTen, format)).toEqual(output); + expect(dateToFormat(mockDateWithValuesUnderTen, format, 'UTC')).toEqual(output); }); }); it('should return invalid date when the date is invalid', () => { expect(dateToFormat(invalidDate, DateFormat.DateMed)).toEqual('invalid date'); }); + + it('should format a date in a specific timezone', () => { + // 08:38:36 UTC = 03:38:36 in America/New_York (EST, UTC-5) + const utcDate = new Date('2023-02-22T08:38:36Z'); + expect(dateToFormat(utcDate, DateFormat.TimeMed, 'America/New_York')).toEqual('03:38:36'); + expect(dateToFormat(utcDate, DateFormat.TimeMed, 'UTC')).toEqual('08:38:36'); + + // 08:38:36 UTC = 14:08:36 in Asia/Kolkata (IST, UTC+5:30) + expect(dateToFormat(utcDate, DateFormat.TimeMed, 'Asia/Kolkata')).toEqual('14:08:36'); + }); + + it('should format DateMed in a specific timezone', () => { + // 2023-02-22T02:00:00 UTC = 2023-02-21 21:00 in America/New_York + const utcDate = new Date('2023-02-22T02:00:00Z'); + expect(dateToFormat(utcDate, DateFormat.DateMed, 'America/New_York')).toEqual('2023-02-21'); + expect(dateToFormat(utcDate, DateFormat.DateMed, 'UTC')).toEqual('2023-02-22'); + }); +}); + +describe('normalizeTimezone', () => { + it('should return undefined for empty string', () => { + expect(normalizeTimezone('')).toBeUndefined(); + }); + + it('should return undefined for whitespace-only string', () => { + expect(normalizeTimezone(' ')).toBeUndefined(); + }); + + it('should return undefined for undefined input', () => { + expect(normalizeTimezone(undefined)).toBeUndefined(); + }); + + it('should return the timezone for valid string', () => { + expect(normalizeTimezone('UTC')).toEqual('UTC'); + expect(normalizeTimezone('America/New_York')).toEqual('America/New_York'); + }); + + it('should trim whitespace from valid timezone', () => { + expect(normalizeTimezone(' UTC ')).toEqual('UTC'); + }); +}); + +describe('getBrowserTimezone', () => { + it('should return a non-empty string', () => { + const tz = getBrowserTimezone(); + expect(typeof tz).toBe('string'); + expect(tz.length).toBeGreaterThan(0); + }); + + it('should return a valid IANA timezone identifier', () => { + const tz = getBrowserTimezone(); + // Valid IANA timezones contain a slash (e.g., "America/New_York") or are "UTC" + expect(tz === 'UTC' || tz.includes('/')).toBe(true); + }); +}); + +describe('getTimezoneOffset', () => { + const testDate = new Date('2023-02-22T12:00:00Z'); + + it('should return offset for UTC', () => { + const result = getTimezoneOffset('UTC', testDate); + expect(result.offsetMinutes).toEqual(0); + expect(result.label).toMatch(/GMT|UTC/); + }); + + it('should return negative offset for America/New_York (EST)', () => { + const result = getTimezoneOffset('America/New_York', testDate); + // EST is UTC-5 = -300 minutes + expect(result.offsetMinutes).toEqual(-300); + expect(result.label).toMatch(/GMT-0?5(:00)?/); + }); + + it('should return positive offset for Asia/Kolkata', () => { + const result = getTimezoneOffset('Asia/Kolkata', testDate); + // IST is UTC+5:30 = +330 minutes + expect(result.offsetMinutes).toEqual(330); + expect(result.label).toMatch(/GMT\+0?5:30/); + }); + + it('should return positive offset for Europe/Paris (CET)', () => { + const result = getTimezoneOffset('Europe/Paris', testDate); + // CET is UTC+1 = +60 minutes (in February, no DST) + expect(result.offsetMinutes).toEqual(60); + }); + + it('should return empty result for empty timezone', () => { + const result = getTimezoneOffset(''); + expect(result.label).toEqual(''); + expect(result.offsetMinutes).toEqual(0); + }); + + it('should return empty result for invalid timezone', () => { + const result = getTimezoneOffset('Invalid/Timezone'); + expect(result.label).toEqual(''); + expect(result.offsetMinutes).toEqual(0); + }); +}); + +describe('parseInTimezone', () => { + it('should return NaN for invalid date string', () => { + expect(parseInTimezone('invalid', '12:00:00', 'UTC')).toBeNaN(); + expect(parseInTimezone('2023-02-22', 'invalid', 'UTC')).toBeNaN(); + }); + + it('should parse date/time in UTC correctly', () => { + const result = parseInTimezone('2023-02-22', '12:00:00', 'UTC'); + const expected = new Date('2023-02-22T12:00:00Z').getTime(); + expect(result).toEqual(expected); + }); + + it('should parse date/time in America/New_York correctly', () => { + // 12:00 in New York (EST, UTC-5) = 17:00 UTC + const result = parseInTimezone('2023-02-22', '12:00:00', 'America/New_York'); + const expected = new Date('2023-02-22T17:00:00Z').getTime(); + expect(result).toEqual(expected); + }); + + it('should parse date/time in Asia/Kolkata correctly', () => { + // 12:00 in Kolkata (IST, UTC+5:30) = 06:30 UTC + const result = parseInTimezone('2023-02-22', '12:00:00', 'Asia/Kolkata'); + const expected = new Date('2023-02-22T06:30:00Z').getTime(); + expect(result).toEqual(expected); + }); + + it('should handle milliseconds in time string', () => { + const result = parseInTimezone('2023-02-22', '12:00:00.500', 'UTC'); + const expected = new Date('2023-02-22T12:00:00.500Z').getTime(); + expect(result).toEqual(expected); + }); + + it('should use browser timezone when timezone is empty', () => { + const browserTz = getBrowserTimezone(); + const resultEmpty = parseInTimezone('2023-02-22', '12:00:00', ''); + const resultBrowser = parseInTimezone('2023-02-22', '12:00:00', browserTz); + expect(resultEmpty).toEqual(resultBrowser); + }); + + it('should handle date boundaries correctly', () => { + // 01:00 in New York (EST, UTC-5) = 06:00 UTC same day + const result1 = parseInTimezone('2023-02-22', '01:00:00', 'America/New_York'); + const expected1 = new Date('2023-02-22T06:00:00Z').getTime(); + expect(result1).toEqual(expected1); + + // 23:00 in Kolkata (IST, UTC+5:30) = 17:30 UTC same day + const result2 = parseInTimezone('2023-02-22', '23:00:00', 'Asia/Kolkata'); + const expected2 = new Date('2023-02-22T17:30:00Z').getTime(); + expect(result2).toEqual(expected2); + }); }); diff --git a/web/src/components/filters/search-select.tsx b/web/src/components/filters/search-select.tsx index 291292f60..fd90ded6d 100644 --- a/web/src/components/filters/search-select.tsx +++ b/web/src/components/filters/search-select.tsx @@ -146,7 +146,7 @@ export const SearchSelect: React.FC = ({ newSelectOptions = [ { isAriaDisabled: true, - children: `No results found for "${inputValue}"`, + children: `${t('No results found for')} "${inputValue}"`, value: NO_RESULTS, hasCheckbox: false, }, diff --git a/web/src/components/logs-histogram.tsx b/web/src/components/logs-histogram.tsx index dbd9e7c6f..25a7b6579 100644 --- a/web/src/components/logs-histogram.tsx +++ b/web/src/components/logs-histogram.tsx @@ -61,6 +61,7 @@ interface LogHistogramProps { isLoading?: boolean; error?: unknown; schema?: Schema; + timezone?: string; } const resultHasAbreviation = ( @@ -112,13 +113,14 @@ const metricValueToChartData = ( group: Severity, value: Array, interval: number, + timezone?: string, ): Array => { const timeEntries = new Set(); const chartData: Array = []; for (const metric of value) { const time = parseFloat(String(metric[0])) * 1000; - const formattedTime = dateToFormat(time, getTimeFormatFromInterval(interval)); + const formattedTime = dateToFormat(time, getTimeFormatFromInterval(interval), timezone); // Prevent duplicate entries to avoid chart rendering issues if (!timeEntries.has(time)) { @@ -136,12 +138,21 @@ const metricValueToChartData = ( return chartData; }; -const getChartsData = (data: HistogramData, interval: number): HistogramChartData => { +const getChartsData = ( + data: HistogramData, + interval: number, + timezone?: string, +): HistogramChartData => { const chartsData: HistogramChartData = {} as HistogramChartData; Object.keys(data).forEach((group) => { const severityGroup = severityFromString(group) ?? 'other'; - const chartData = metricValueToChartData(severityGroup, data[severityGroup], interval); + const chartData = metricValueToChartData( + severityGroup, + data[severityGroup], + interval, + timezone, + ); chartsData[severityGroup] = chartData; }); @@ -152,10 +163,9 @@ const getChartsData = (data: HistogramData, interval: number): HistogramChartDat const tickCountFromTimeRange = (timeRange: TimeRangeNumber, interval: number): number => Math.ceil((timeRange.end - timeRange.start) / interval); -const HistogramTooltip: React.FC = ({ - interval, - ...props -}) => { +const HistogramTooltip: React.FC< + ChartLegendTooltipProps & { interval: number; timezone?: string } +> = ({ interval, timezone, ...props }) => { const { x: xProps, y: yProps, center, height } = props; if (!center) { @@ -180,7 +190,7 @@ const HistogramTooltip: React.FC <> dateToFormat(datum.x ?? 0, getTimeFormatFromInterval(interval))} + title={(datum) => dateToFormat(datum.x ?? 0, getTimeFormatFromInterval(interval), timezone)} constrainToVisibleArea /> = ({ onChangeTimeRange, interval, schema, + timezone, }) => { const { t } = useTranslation('plugin__logging-view-plugin'); @@ -281,7 +292,7 @@ export const LogsHistogram: React.FC = ({ } const data = aggregateMetricsLogData(histogramData, schema); - const chartsData = getChartsData(data, intervalValue); + const chartsData = getChartsData(data, intervalValue, timezone); const tickCount = tickCountFromTimeRange(timeRangeValue, intervalValue); @@ -386,7 +397,11 @@ export const LogsHistogram: React.FC = ({ `${datum.y !== null ? datum.y : t('No data')}` } labelComponent={ - + } voronoiDimension="x" voronoiPadding={0} @@ -406,7 +421,7 @@ export const LogsHistogram: React.FC = ({ tickCount={60} fixLabelOverlap tickFormat={(tick: number) => - dateToFormat(tick, getTimeFormatFromTimeRange(timeRangeValue)) + dateToFormat(tick, getTimeFormatFromTimeRange(timeRangeValue), timezone) } /> diff --git a/web/src/components/logs-metrics.tsx b/web/src/components/logs-metrics.tsx index 32939bb47..2bec47109 100644 --- a/web/src/components/logs-metrics.tsx +++ b/web/src/components/logs-metrics.tsx @@ -36,6 +36,7 @@ interface LogsMetricsProps { error?: unknown; height?: number; displayLegendTable?: boolean; + timezone?: string; } const GRAPH_HEIGHT = 250; @@ -108,6 +109,7 @@ export const LogsMetrics: React.FC = ({ timeRange, height = GRAPH_HEIGHT, displayLegendTable = false, + timezone, }) => { const { t } = useTranslation('plugin__logging-view-plugin'); @@ -184,7 +186,11 @@ export const LogsMetrics: React.FC = ({ - dateToFormat(datum.x ?? 0, getTimeFormatFromTimeRange(timeRangeValue)) + dateToFormat( + datum.x ?? 0, + getTimeFormatFromTimeRange(timeRangeValue), + timezone, + ) } /> } @@ -214,6 +220,7 @@ export const LogsMetrics: React.FC = ({ dateToFormat( tick, intervalValue < 60 * 1000 ? DateFormat.TimeMed : DateFormat.TimeShort, + timezone, ) } /> diff --git a/web/src/components/logs-table.tsx b/web/src/components/logs-table.tsx index 9c6562d56..a7cb605ed 100644 --- a/web/src/components/logs-table.tsx +++ b/web/src/components/logs-table.tsx @@ -32,6 +32,7 @@ interface LogsTableProps { showStats?: boolean; isStreaming?: boolean; error?: unknown; + timezone?: string; } type TableCellValue = string | number | Resource | Array; @@ -42,7 +43,7 @@ const isJSONObject = (value: string): boolean => { return trimmedValue.startsWith('{') && trimmedValue.endsWith('}'); }; -const streamToTableData = (stream: StreamLogData): Array => { +const streamToTableData = (stream: StreamLogData, timezone?: string): Array => { const values = stream.values; return values.map((value) => { @@ -50,7 +51,7 @@ const streamToTableData = (stream: StreamLogData): Array => { const message = isJSONObject(logValue) ? stream.stream['message'] || logValue : logValue; const timestamp = parseFloat(String(value[0])); const time = timestamp / 1e6; - const formattedTime = dateToFormat(time, DateFormat.Full); + const formattedTime = dateToFormat(time, DateFormat.Full, timezone); const severity = parseName(stream.stream, ResourceLabel.Severity); const namespace = parseName(stream.stream, ResourceLabel.Namespace); const podName = parseName(stream.stream, ResourceLabel.Pod); @@ -71,14 +72,17 @@ const streamToTableData = (stream: StreamLogData): Array => { }); }; -const aggregateStreamLogData = (response?: QueryRangeResponse): Array => { +const aggregateStreamLogData = ( + response?: QueryRangeResponse, + timezone?: string, +): Array => { // TODO check timestamp aggregation for streams // TODO check if display matrix data is required const data = response?.data; if (isStreamsResult(data)) { return data.result - .flatMap(streamToTableData) + .flatMap((stream) => streamToTableData(stream, timezone)) .map((log, index) => ({ ...log, logIndex: index })); } @@ -223,6 +227,7 @@ export const LogsTable: React.FC = ({ isStreaming, children, error, + timezone, }) => { const [expandedItems, setExpandedItems] = React.useState>(new Set()); const [prevChildrenCount, setPrevChildrenCount] = React.useState(0); @@ -231,7 +236,7 @@ export const LogsTable: React.FC = ({ direction: direction === 'backward' ? 'desc' : 'asc', }); const tableData: Array = React.useMemo(() => { - const logsTableData = aggregateStreamLogData(logsData); + const logsTableData = aggregateStreamLogData(logsData, timezone); const logsTableDataWithExpanded = logsTableData.flatMap((row) => [ row, @@ -239,7 +244,7 @@ export const LogsTable: React.FC = ({ ]); return logsTableDataWithExpanded; - }, [logsData]); + }, [logsData, timezone]); useEffect(() => { setPrevChildrenCount(React.Children.count(children)); diff --git a/web/src/components/time-range-dropdown.tsx b/web/src/components/time-range-dropdown.tsx index 2849f9839..9fb0fd1b2 100644 --- a/web/src/components/time-range-dropdown.tsx +++ b/web/src/components/time-range-dropdown.tsx @@ -27,6 +27,7 @@ interface TimeRangeDropdownProps { value?: TimeRange; onChange?: (timeRange: TimeRange) => void; isDisabled?: boolean; + timezone?: string; } const getSelectedOptionIndex = ({ @@ -57,6 +58,7 @@ export const TimeRangeDropdown: React.FC = ({ onChange, value, isDisabled = false, + timezone, }) => { const { t } = useTranslation('plugin__logging-view-plugin'); @@ -111,6 +113,7 @@ export const TimeRangeDropdown: React.FC = ({ onClose={handleCloseModal} onSelectRange={handleCustomRangeSelected} initialRange={timeRangeValue} + timezone={timezone} /> )} @@ -126,7 +129,7 @@ export const TimeRangeDropdown: React.FC = ({ isExpanded={isOpen} > {timeRangeOptions[selectedIndex].key === CUSTOM_TIME_RANGE_KEY && timeRangeValue - ? formatTimeRange(timeRangeValue) + ? formatTimeRange(timeRangeValue, timezone) : timeRangeOptions[selectedIndex].name} )} diff --git a/web/src/components/time-range-select-modal.tsx b/web/src/components/time-range-select-modal.tsx index 4f655f0b1..4448f2054 100644 --- a/web/src/components/time-range-select-modal.tsx +++ b/web/src/components/time-range-select-modal.tsx @@ -8,7 +8,13 @@ import { } from '@patternfly/react-core'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { DateFormat, dateToFormat } from '../date-utils'; +import { + DateFormat, + dateToFormat, + getBrowserTimezone, + normalizeTimezone, + parseInTimezone, +} from '../date-utils'; import { TimeRange } from '../logs.types'; import { TestIds } from '../test-ids'; import { defaultTimeRange, numericTimeRange } from '../time-range'; @@ -16,33 +22,36 @@ import { padLeadingZero } from '../value-utils'; import { PrecisionTimePicker } from './precision-time-picker'; import './time-range-select-modal.css'; -interface TimeRangeSelectModal { +interface TimeRangeSelectModalProps { onClose: () => void; onSelectRange?: (timeRange: TimeRange) => void; initialRange?: TimeRange; + timezone?: string; } export const INTERVAL_AUTO_KEY = 'AUTO'; -export const TimeRangeSelectModal: React.FC = ({ +export const TimeRangeSelectModal: React.FC = ({ onClose, onSelectRange, initialRange, + timezone, }) => { const { t } = useTranslation('plugin__logging-view-plugin'); + const effectiveTimezone = normalizeTimezone(timezone) ?? getBrowserTimezone(); const initialRangeNumber = numericTimeRange(initialRange ?? defaultTimeRange()); const [startDate, setStartDate] = React.useState( - dateToFormat(initialRangeNumber.start, DateFormat.DateMed), + dateToFormat(initialRangeNumber.start, DateFormat.DateMed, effectiveTimezone), ); const [startTime, setStartTime] = React.useState( - dateToFormat(initialRangeNumber.start, DateFormat.TimeFull), + dateToFormat(initialRangeNumber.start, DateFormat.TimeFull, effectiveTimezone), ); const [endDate, setEndDate] = React.useState( - dateToFormat(initialRangeNumber.end, DateFormat.DateMed), + dateToFormat(initialRangeNumber.end, DateFormat.DateMed, effectiveTimezone), ); const [endTime, setEndTime] = React.useState( - dateToFormat(initialRangeNumber.end, DateFormat.TimeFull), + dateToFormat(initialRangeNumber.end, DateFormat.TimeFull, effectiveTimezone), ); const [isRangeValid, setIsRangeValid] = React.useState(false); @@ -50,21 +59,21 @@ export const TimeRangeSelectModal: React.FC = ({ React.useEffect(() => { if (isRangeSelected) { - const start = `${startDate}T${startTime}`; - const end = `${endDate}T${endTime}`; + const start = parseInTimezone(startDate, startTime, effectiveTimezone); + const end = parseInTimezone(endDate, endTime, effectiveTimezone); - setIsRangeValid(Date.parse(start) < Date.parse(end)); + setIsRangeValid(start < end); } else { setIsRangeValid(false); } - }, [startDate, endDate, startTime, endTime, isRangeSelected]); + }, [startDate, endDate, startTime, endTime, isRangeSelected, effectiveTimezone]); const handleSelectRange = () => { if (isRangeSelected) { - const start = `${startDate}T${startTime}`; - const end = `${endDate}T${endTime}`; + const start = parseInTimezone(startDate, startTime, effectiveTimezone); + const end = parseInTimezone(endDate, endTime, effectiveTimezone); - onSelectRange?.({ start: Date.parse(start), end: Date.parse(end) }); + onSelectRange?.({ start, end }); } }; diff --git a/web/src/components/timezone-dropdown.tsx b/web/src/components/timezone-dropdown.tsx new file mode 100644 index 000000000..8b7ff30ec --- /dev/null +++ b/web/src/components/timezone-dropdown.tsx @@ -0,0 +1,358 @@ +import { + Button, + MenuToggle, + MenuToggleElement, + Select, + SelectGroup, + SelectList, + SelectOption, + SelectOptionProps, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { getBrowserTimezone, getTimezoneOffset } from '../date-utils'; +import { TestIds } from '../test-ids'; + +// Extend Intl type to include supportedValuesOf (available in modern browsers) +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Intl { + function supportedValuesOf(key: string): string[]; + } +} + +// Format timezone for display: "America/New_York (UTC-05:00)" +const formatTimezoneLabel = (timezone: string): string => { + const { label } = getTimezoneOffset(timezone); + return label ? `${timezone} (${label})` : timezone; +}; + +// Curated list of common timezones +const DEFAULT_TIMEZONES = [ + 'UTC', + getBrowserTimezone(), // Browser's local timezone + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Asia/Tokyo', + 'Asia/Shanghai', + 'Asia/Kolkata', + 'Australia/Sydney', + // Remove duplicates if browser TZ is in the list +].filter((tz, index, arr) => arr.indexOf(tz) === index); + +const getAllTimezones = (): string[] => { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + // Fallback for older browsers + return DEFAULT_TIMEZONES; + } +}; + +interface TimezoneDropdownProps { + value?: string; + onChange?: (timezone: string) => void; + isDisabled?: boolean; +} + +export const TimezoneDropdown: React.FC = ({ + onChange, + value = '', + isDisabled, +}) => { + const { t } = useTranslation('plugin__logging-view-plugin'); + const [isOpen, setIsOpen] = React.useState(false); + const localTimezone = React.useMemo(() => getBrowserTimezone(), []); + const [selected, setSelected] = React.useState(value || localTimezone); + const [inputValue, setInputValue] = React.useState(value || localTimezone); + const [filterValue, setFilterValue] = React.useState(''); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const initialSelectOptions: SelectOptionProps[] = React.useMemo(() => { + return getAllTimezones().map((tz) => ({ + value: tz, + children: formatTimezoneLabel(tz), + })); + }, []); + + const commonTimezoneOptions: SelectOptionProps[] = React.useMemo(() => { + return DEFAULT_TIMEZONES.map((tz) => ({ + value: tz, + children: formatTimezoneLabel(tz), + })); + }, []); + + const [selectOptions, setSelectOptions] = + React.useState(initialSelectOptions); + + const NO_RESULTS = 'no results'; + + React.useEffect(() => { + const timezone = value || localTimezone; + setSelected(timezone); + setInputValue(formatTimezoneLabel(timezone)); + }, [value]); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = initialSelectOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialSelectOptions.filter((menuItem) => + String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + children: `${t('No results found for')} "${filterValue}"`, + value: NO_RESULTS, + }, + ]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(focusedItem.value); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (selectedOption: string, content: string) => { + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(selectedOption)); + onChange?.(String(selectedOption)); + closeMenu(); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + selectedValue: string | number | undefined, + ) => { + if ( + selectedValue && + selectedValue !== NO_RESULTS && + typeof selectedValue === 'string' && + selectedValue !== selected + ) { + const optionText = selectOptions.find((option) => option.value === selectedValue)?.children; + selectOption(selectedValue, optionText as string); + } + }; + + const onTextInputChange = (_event: React.FormEvent, typedValue: string) => { + setInputValue(typedValue); + setFilterValue(typedValue); + + resetActiveAndFocusedItem(); + + if (typedValue !== selected) { + setSelected(''); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (key === 'ArrowUp') { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, + // focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + selectOption(focusedItem.value, focusedItem.children as string); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + onChange?.(''); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + + + + + ); + + return ( + + ); +}; diff --git a/web/src/date-utils.ts b/web/src/date-utils.ts index 8f01dcf9c..19f9e9cd6 100644 --- a/web/src/date-utils.ts +++ b/web/src/date-utils.ts @@ -9,58 +9,89 @@ export enum DateFormat { Full, } -const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - year: 'numeric', - second: 'numeric', - hour12: false, -}); - -const isDateValid = (date: Date | number) => { - if (typeof date === 'number') { - return !isNaN(date); - } else if (date instanceof Date) { - return !isNaN(date.getTime()); - } +export const normalizeTimezone = (timezone?: string): string | undefined => + timezone?.trim() || undefined; + +const isDateValid = (date: Date | number): boolean => + date instanceof Date ? !isNaN(date.getTime()) : !isNaN(date); - return false; +const getFormattedParts = ( + date: Date, + options: Intl.DateTimeFormatOptions, + timezone?: string, +): Record => { + const formatter = new Intl.DateTimeFormat('en-US', { + ...options, + timeZone: normalizeTimezone(timezone), + }); + const parts = formatter.formatToParts(date); + return Object.fromEntries(parts.map((p) => [p.type, p.value])); }; -export const dateToFormat = (date: Date | number, format: DateFormat): string => { - if (isDateValid(date)) { - const dateObject = new Date(date); - const hours = padLeadingZero(dateObject.getHours()); - const minutes = padLeadingZero(dateObject.getMinutes()); - const seconds = padLeadingZero(dateObject.getSeconds()); - - switch (format) { - case DateFormat.TimeShort: - return `${hours}:${minutes}`; - case DateFormat.TimeMed: - return `${hours}:${minutes}:${seconds}`; - case DateFormat.DateMed: { - const month = padLeadingZero(dateObject.getMonth() + 1); - const dayOfTheMonth = padLeadingZero(dateObject.getDate()); - - return `${dateObject.getFullYear()}-${month}-${dayOfTheMonth}`; - } - case DateFormat.TimeFull: { - const fractionalSeconds = padLeadingZero(dateObject.getMilliseconds(), 3); - - return `${hours}:${minutes}:${seconds}.${fractionalSeconds}`; - } - case DateFormat.Full: { - const fractionalSeconds = padLeadingZero(dateObject.getMilliseconds(), 3); - - return `${dateTimeFormatter.format(date)}.${fractionalSeconds}`; - } - } +const getTimePartsInTimezone = (date: Date, timezone?: string) => { + const parts = getFormattedParts( + date, + { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }, + timezone, + ); + return { + hours: parts.hour ?? '00', + minutes: parts.minute ?? '00', + seconds: parts.second ?? '00', + milliseconds: padLeadingZero(date.getMilliseconds(), 3), + }; +}; + +const getDatePartsInTimezone = (date: Date, timezone?: string) => { + const parts = getFormattedParts( + date, + { year: 'numeric', month: '2-digit', day: '2-digit' }, + timezone, + ); + return { + year: parts.year ?? '0000', + month: parts.month ?? '00', + day: parts.day ?? '00', + }; +}; + +export const dateToFormat = ( + date: Date | number, + format: DateFormat, + timezone?: string, +): string => { + if (!isDateValid(date)) { + return 'invalid date'; } - return 'invalid date'; + const dateObject = new Date(date); + const { hours, minutes, seconds, milliseconds } = getTimePartsInTimezone(dateObject, timezone); + + switch (format) { + case DateFormat.TimeShort: + return `${hours}:${minutes}`; + case DateFormat.TimeMed: + return `${hours}:${minutes}:${seconds}`; + case DateFormat.TimeFull: + return `${hours}:${minutes}:${seconds}.${milliseconds}`; + case DateFormat.DateMed: { + const { year, month, day } = getDatePartsInTimezone(dateObject, timezone); + return `${year}-${month}-${day}`; + } + case DateFormat.Full: { + const formatted = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + year: 'numeric', + second: 'numeric', + hour12: false, + timeZone: normalizeTimezone(timezone), + }).format(dateObject); + return `${formatted}.${milliseconds}`; + } + } }; export const getTimeFormatFromTimeRange = (timeRange: TimeRangeNumber): DateFormat => @@ -69,9 +100,69 @@ export const getTimeFormatFromTimeRange = (timeRange: TimeRangeNumber): DateForm export const getTimeFormatFromInterval = (interval: number): DateFormat => { if (interval <= 60 * 1000) { return DateFormat.TimeFull; - } else if (interval < 30 * 60 * 1000) { + } + if (interval < 30 * 60 * 1000) { return DateFormat.TimeMed; } - return DateFormat.TimeShort; }; + +export const getBrowserTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone; + +export interface TimezoneOffset { + label: string; + offsetMinutes: number; +} + +/** + * Parses an offset label like "GMT-05:00" or "GMT+05:30" to minutes. + * Returns 0 for "GMT", "UTC", or unparseable strings. + */ +const parseOffsetLabel = (label: string): number => { + const match = label.match(/([+-])(\d{1,2})(?::(\d{2}))?/); + if (!match) return 0; + const sign = match[1] === '+' ? 1 : -1; + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3] || '0', 10); + return sign * (hours * 60 + minutes); +}; + +/** + * Gets the timezone offset for a given timezone at a specific date. + * Positive offset = ahead of UTC (e.g., +330 for Asia/Kolkata), + * negative = behind UTC (e.g., -300 for America/New_York). + */ +export const getTimezoneOffset = (timezone: string, date: Date = new Date()): TimezoneOffset => { + const normalizedTz = normalizeTimezone(timezone); + if (!normalizedTz) { + return { label: '', offsetMinutes: 0 }; + } + + try { + const parts = getFormattedParts(date, { timeZoneName: 'shortOffset' }, normalizedTz); + const label = parts.timeZoneName ?? ''; + return { label, offsetMinutes: parseOffsetLabel(label) }; + } catch { + return { label: '', offsetMinutes: 0 }; + } +}; + +/** + * Parses a date and time string as if they are in the given timezone, + * and returns the corresponding UTC timestamp in milliseconds. + */ +export const parseInTimezone = (dateStr: string, timeStr: string, timezone?: string): number => { + const effectiveTimezone = normalizeTimezone(timezone) ?? getBrowserTimezone(); + const localParsed = Date.parse(`${dateStr}T${timeStr}`); + + if (isNaN(localParsed)) { + return NaN; + } + + const localDate = new Date(localParsed); + const browserOffsetMinutes = -localDate.getTimezoneOffset(); + const targetOffsetMinutes = getTimezoneOffset(effectiveTimezone, localDate).offsetMinutes; + + // Convert from browser-interpreted local time to target timezone time + return localParsed - (targetOffsetMinutes - browserOffsetMinutes) * 60 * 1000; +}; diff --git a/web/src/hooks/useURLState.ts b/web/src/hooks/useURLState.ts index 3d7782d9f..d6f6e323e 100644 --- a/web/src/hooks/useURLState.ts +++ b/web/src/hooks/useURLState.ts @@ -2,6 +2,7 @@ import React, { DependencyList, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; import { filtersFromQuery, queryFromFilters } from '../attribute-filters'; import { AttributeList, Filters } from '../components/filters/filter.types'; +import { getBrowserTimezone } from '../date-utils'; import { Config, Direction, Schema, SchemaConfig, TimeRange } from '../logs.types'; import { ResourceLabel, ResourceToStreamLabels } from '../parse-resources'; import { intervalFromTimeRange } from '../time-range'; @@ -32,6 +33,8 @@ const TENANT_PARAM_KEY = 'tenant'; const SCHEMA_PARAM_KEY = 'schema'; const SHOW_RESOURCES_PARAM_KEY = 'showResources'; const SHOW_STATS_PARAM_KEY = 'showStats'; +const TIMEZONE_PARAM_KEY = 'tz'; +const STORED_TIMEZONE_KEY = 'logging-view-plugin/timezone'; export const DEFAULT_TENANT = 'application'; const DEFAULT_SHOW_RESOURCES = '0'; @@ -81,6 +84,19 @@ export const useURLState = ({ const intitalStatsShown = (queryParams.get(SHOW_STATS_PARAM_KEY) ?? DEFAULT_SHOW_STATS) === '1'; + // Timezone: URL param takes precedence over localStorage, + // which takes precedence over browser default + const getStoredTimezone = (): string | null => { + try { + const stored = window.localStorage.getItem(STORED_TIMEZONE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } + }; + const initialTimezone = + queryParams.get(TIMEZONE_PARAM_KEY) ?? getStoredTimezone() ?? getBrowserTimezone(); + const [query, setQuery] = React.useState(initialQuery); const [tenant, setTenant] = React.useState(initialTenant); const [schema, setSchema] = React.useState(initialSchema); @@ -95,6 +111,7 @@ export const useURLState = ({ const [areResourcesShown, setAreResourcesShown] = React.useState(initialResorcesShown); const [areStatsShown, setAreStatsShown] = React.useState(intitalStatsShown); const [direction, setDirection] = React.useState(getDirectionValue(initialDirection)); + const [timezone, setTimezone] = React.useState(initialTimezone); const [timeRange, setTimeRange] = React.useState( initialTimeRangeStart && initialTimeRangeEnd ? { @@ -166,6 +183,17 @@ export const useURLState = ({ navigate(`${location.pathname}?${queryParams.toString()}`); }; + const setTimezoneInURL = (selectedTimezone: string) => { + queryParams.set(TIMEZONE_PARAM_KEY, selectedTimezone); + navigate(`${location.pathname}?${queryParams.toString()}`); + // Also persist to localStorage + try { + window.localStorage.setItem(STORED_TIMEZONE_KEY, JSON.stringify(selectedTimezone)); + } catch { + // Ignore localStorage errors + } + }; + useEffect(() => { if (config?.schema != SchemaConfig.select) { const queryFromParams = queryParams.get(QUERY_PARAM_KEY); @@ -202,11 +230,14 @@ export const useURLState = ({ const timeRangeStartValue = queryParams.get(TIME_RANGE_START); const timeRangeEndValue = queryParams.get(TIME_RANGE_END); const directionValue = queryParams.get(DIRECTION); + const timezoneValue = + queryParams.get(TIMEZONE_PARAM_KEY) ?? getStoredTimezone() ?? getBrowserTimezone(); setQuery(queryValue.trim()); setTenant(tenantValue); setSchema(schemaValue); setDirection(getDirectionValue(directionValue)); + setTimezone(timezoneValue); setAreResourcesShown(showResourcesValue === '1'); setAreStatsShown(showStatsValue === '1'); setFilters( @@ -248,6 +279,8 @@ export const useURLState = ({ timeRange, setTimeRangeInURL, setDirectionInURL, + timezone, + setTimezoneInURL, attributes, direction, interval: timeRange ? intervalFromTimeRange(timeRange) : undefined, diff --git a/web/src/logs.types.ts b/web/src/logs.types.ts index 0b0b81a05..892ec631c 100644 --- a/web/src/logs.types.ts +++ b/web/src/logs.types.ts @@ -18,6 +18,7 @@ export type Config = { timeout?: number; logsLimit: number; schema: SchemaConfig; + showTimezoneSelector?: boolean; }; export type MetricValue = Array; diff --git a/web/src/pages/logs-detail-page.tsx b/web/src/pages/logs-detail-page.tsx index f499b4666..14b4be5a1 100644 --- a/web/src/pages/logs-detail-page.tsx +++ b/web/src/pages/logs-detail-page.tsx @@ -30,6 +30,7 @@ import { Direction, isMatrixResult, Schema } from '../logs.types'; import { getStreamLabelsFromSchema, ResourceLabel } from '../parse-resources'; import { TestIds } from '../test-ids'; import { getInitialTenantFromNamespace } from '../value-utils'; +import { TimezoneDropdown } from '../components/timezone-dropdown'; /* This comment creates an entry in the translations catalogue for console extensions @@ -55,7 +56,7 @@ const LogsDetailPage: React.FC = ({ const podname = podnameFromParams || podNameFromProps; const [isHistogramVisible, setIsHistogramVisible] = React.useState(false); - const { configLoaded } = useLogsConfig(); + const { config, configLoaded } = useLogsConfig(); const { isLoadingLogsData, @@ -95,6 +96,8 @@ const LogsDetailPage: React.FC = ({ timeRange, direction, setDirectionInURL, + timezone, + setTimezoneInURL, attributes, } = useURLState({ getDefaultQuery: ({ schema: s }) => { @@ -204,7 +207,15 @@ const LogsDetailPage: React.FC = ({ value={timeRange} onChange={setTimeRangeInURL} isDisabled={isQueryEmpty} + timezone={timezone} /> + {config.showTimezoneSelector && ( + + )} Refresh}>