diff --git a/docs/main/administration-guide/configure/reporting-configuration-settings.mdx b/docs/main/administration-guide/configure/reporting-configuration-settings.mdx index f8df1e8dc79..63c4a76309f 100644 --- a/docs/main/administration-guide/configure/reporting-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/reporting-configuration-settings.mdx @@ -97,6 +97,7 @@ Bots, deactivated users, and synthetic users in [Microsoft Teams integrations](/ - This setting is applicable to self-hosted deployments only. - From Mattermost v10.9, you can toggle between structured and plain text server logs in the System Console when console log output is configured as [JSON](/administration-guide/configure/environment-configuration-settings#output-console-logs-as-json) by specifying the log format as **Structured** or **Plain text**. This option is located in the filter row of the **Server logs** page. - From Mattermost v12.2, structured logs can be limited to a **Duration**, such as the last 5 minutes, 15 minutes, hour, or 24 hours, and **Live tail** can refresh them automatically every 10, 30, or 60 seconds. Live tail pauses while the browser tab is hidden. +- From Mattermost v12.2, select **Duration > Custom range…** to show structured logs between a start and an end date and time, for example to investigate a past incident. Live tail isn't available while a custom range is selected. - From Mattermost v12.2, in a [high availability](/deployment-guide/server/high-availability-cluster-based-deployment) deployment, a **Node** selector in the filter row shows structured logs from **All nodes** or from a single node. When logs from more than one node are listed, each log entry shows the node it came from. The selector appears once at least two nodes have responded; after a page reload with a single node selected, select **All nodes** to list every node again. If a node can't be reached, its logs are replaced by a single error entry, **Failed to fetch logs from node**, so logs from the other nodes still load. **Download** saves the log file of the node that serves the request. diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/reporting/server_logs.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/reporting/server_logs.ts index 5f6e2081998..4221e07f8ac 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/reporting/server_logs.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/reporting/server_logs.ts @@ -126,6 +126,18 @@ export default class ServerLogs { await this.page().getByRole('menuitemradio', {name: duration, exact: true}).click(); } + /** + * Open the custom range dialog from the Duration menu and apply the range it + * starts with, the last hour. + */ + async applyDefaultCustomRange() { + await this.selectDuration('Custom range…'); + const dialog = this.page().getByRole('dialog', {name: 'Custom time range'}); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', {name: 'Apply'}).click(); + await expect(dialog).not.toBeVisible(); + } + async selectStructuredFormat() { await this.selectLogFormat('Structured'); } diff --git a/e2e-tests/playwright/specs/functional/system_console/reporting/server_logs.spec.ts b/e2e-tests/playwright/specs/functional/system_console/reporting/server_logs.spec.ts index 9c32dd318f0..724348559e7 100644 --- a/e2e-tests/playwright/specs/functional/system_console/reporting/server_logs.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/reporting/server_logs.spec.ts @@ -5,6 +5,16 @@ import type {Client4} from '@mattermost/client'; import {expect, test} from '@mattermost/playwright-lib'; +// The layout of the logs query's date_from and date_to, which the server parses +const FILTER_DATE_LAYOUT = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \+00:00$/; + +const MINUTE_MS = 60 * 1000; + +// A time in the logs query's date layout, which is ISO 8601 in UTC with a space for the T and the Z +function formatFilterDate(time: number): string { + return new Date(time).toISOString().replace('T', ' ').replace('Z', ' +00:00'); +} + /** * Write a uniquely identifiable entry to the server log. * @@ -196,7 +206,7 @@ test.describe('System Console - Reporting - Server Logs', () => { // status alone would not catch a format change. The end is left open so the // server ends the range at its own clock. const body = (await queryRequest).postDataJSON(); - expect(body.date_from).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \+00:00$/); + expect(body.date_from).toMatch(FILTER_DATE_LAYOUT); expect(body.date_to).toBe(''); expect((await queryResponse).status()).toBe(200); @@ -211,6 +221,77 @@ test.describe('System Console - Reporting - Server Logs', () => { }, ); + /** + * @objective Verify a custom time range sends both bounds to the logs query API in a format + * the server accepts. + */ + test( + 'applies a custom time range through the logs query API', + {tag: ['@system_console', '@server_logs']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + const marker = `pw-log-${pw.random.id()}`; + + // # Write a uniquely identifiable entry to the server log + await writeServerLogEntry(adminClient, marker); + + // # Log in as admin and go to the server logs page + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + await systemConsolePage.gotoServerLogs(); + await systemConsolePage.toBeVisible(); + + const serverLogs = systemConsolePage.serverLogs; + await serverLogs.toBeVisible(); + + // # Wait for the entry to reach the page + await expect(async () => { + await serverLogs.reloadButton.click(); + await expect(serverLogs.row(marker)).toBeVisible({timeout: 2000}); + }).toPass({timeout: 30000}); + + // # Apply the default custom range, the last hour + const page = systemConsolePage.page; + const queryRequest = page.waitForRequest( + (request) => request.url().includes('/api/v4/logs/query') && request.method() === 'POST', + ); + const queryResponse = page.waitForResponse((response) => response.url().includes('/api/v4/logs/query')); + const browserNow = () => page.evaluate(() => Date.now()); + const openedAt = await browserNow(); + await serverLogs.applyDefaultCustomRange(); + const appliedAt = await browserNow(); + + // * Verify both bounds are sent in the layout the server parses + const body = (await queryRequest).postDataJSON(); + expect(body.date_from).toMatch(FILTER_DATE_LAYOUT); + expect(body.date_to).toMatch(FILTER_DATE_LAYOUT); + expect((await queryResponse).status()).toBe(200); + + // * Verify the end is the last millisecond of the minute the dialog opened in, so the + // range stops there instead of running on, and the start is an hour before that minute. + // The minute can tick over while the dialog opens, so either side of it is accepted + const openedMinutes: number[] = []; + for (let minute = openedAt - (openedAt % MINUTE_MS); minute <= appliedAt; minute += MINUTE_MS) { + openedMinutes.push(minute); + } + const minuteEnds = openedMinutes.map((minute) => formatFilterDate(minute + MINUTE_MS - 1)); + expect(minuteEnds).toContain(body.date_to); + const openedMinute = openedMinutes[minuteEnds.indexOf(body.date_to)]; + expect(body.date_from).toBe(formatFilterDate(openedMinute - 60 * MINUTE_MS)); + + // * Verify the just-written entry is inside the range, which ends with the current minute + await expect(serverLogs.row(marker)).toBeVisible(); + + // * Verify the duration field shows the range rather than a preset + await expect(serverLogs.durationValue).toHaveValue(/–/); + + // # Clear the range + await serverLogs.selectDuration('All time'); + + // * Verify the duration field is back to all time + await expect(serverLogs.durationValue).toHaveValue('All time'); + }, + ); + /** * @objective Verify the selected log format is remembered across a page reload. */ diff --git a/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.scss b/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.scss new file mode 100644 index 00000000000..275f03b43e5 --- /dev/null +++ b/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.scss @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.ServerLogsCustomRangeModal { + &__bound { + min-width: 0; + padding: 0; + border: 0; + margin: 0 0 var(--spacing-l); + + legend { + width: auto; + padding: 0; + border: 0; + margin: 0 0 var(--spacing-xs); + color: var(--center-channel-color); + font-size: var(--font-size-100); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-100); + } + } +} diff --git a/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.tsx b/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.tsx new file mode 100644 index 00000000000..4e1ba465ebb --- /dev/null +++ b/webapp/channels/src/components/admin_console/server_logs/custom_range_modal.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Moment} from 'moment-timezone'; +import moment from 'moment-timezone'; +import React, {useEffect, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {GenericModal} from '@mattermost/components'; + +import {getCurrentTimezone, getCurrentTimezoneLabel} from 'mattermost-redux/selectors/entities/timezone'; + +import DateTimeInput from 'components/datetime_input/datetime_input'; + +import type {CustomTimeRange} from './types'; + +import './custom_range_modal.scss'; + +const MINUTE_MS = 60 * 1000; +const DEFAULT_RANGE_MS = 60 * MINUTE_MS; + +type Props = { + initialRange: CustomTimeRange | null; + onConfirm: (range: CustomTimeRange) => void; + onExited: () => void; +}; + +// The current minute, moving on while the dialog stays open so it never caps a time at the minute it opened +function useCurrentMinute(timezone: string) { + const [now, setNow] = useState(() => moment.tz(timezone).startOf('minute')); + + useEffect(() => { + let timeout: ReturnType; + const scheduleNextMinute = () => { + timeout = setTimeout(() => { + setNow(moment.tz(timezone).startOf('minute')); + scheduleNextMinute(); + }, MINUTE_MS - (Date.now() % MINUTE_MS)); + }; + scheduleNextMinute(); + + return () => clearTimeout(timeout); + }, [timezone]); + + return now; +} + +export default function CustomRangeModal({initialRange, onConfirm, onExited}: Props) { + const intl = useIntl(); + const timezone = useSelector(getCurrentTimezone); + const timezoneLabel = useSelector(getCurrentTimezoneLabel); + + // The pickers clamp both bounds to this, so the end can't be in the future + const now = useCurrentMinute(timezone); + const [from, setFrom] = useState(() => (initialRange ? moment.tz(initialRange.from, timezone) : now.clone().subtract(DEFAULT_RANGE_MS, 'ms'))); + const [to, setTo] = useState(() => (initialRange ? moment.tz(initialRange.to, timezone).startOf('minute') : now)); + + // Keeps Escape for an open date or time popup instead of closing the modal. One flag per + // input, so one popup closing can't clear the flag while the other is opening + const [isFromInteracting, setIsFromInteracting] = useState(false); + const [isToInteracting, setIsToInteracting] = useState(false); + + const [hasFromTimeError, setHasFromTimeError] = useState(false); + const [hasToTimeError, setHasToTimeError] = useState(false); + + const isOrdered = from.isBefore(to); + const isValid = isOrdered && !hasFromTimeError && !hasToTimeError; + + // The pickers work in whole minutes, so the end covers everything logged during its minute + const handleConfirm = () => { + if (isValid) { + onConfirm({from: from.valueOf(), to: to.clone().endOf('minute').valueOf()}); + } + }; + + return ( + + } + modalSubheaderText={ + + } + confirmButtonText={ + + } + handleConfirm={handleConfirm} + handleCancel={onExited} + onExited={onExited} + isConfirmDisabled={!isValid} + errorText={isOrdered ? undefined : intl.formatMessage({id: 'admin.logs.customRange.errorOrder', defaultMessage: 'The start must be before the end.'})} + autoCloseOnConfirmButton={false} + compassDesign={true} + keyboardEscape={!isFromInteracting && !isToInteracting} + enforceFocus={false} + bodyOverflowVisible={true} + > +
+ + + + time && setFrom(time)} + timezone={timezone} + setIsInteracting={setIsFromInteracting} + onTimeInputError={setHasFromTimeError} + allowPastDates={true} + manualTimeEntry={true} + maxDateTime={now} + /> +
+
+ + + + time && setTo(time)} + timezone={timezone} + setIsInteracting={setIsToInteracting} + onTimeInputError={setHasToTimeError} + allowPastDates={true} + manualTimeEntry={true} + maxDateTime={now} + /> +
+
+ ); +} diff --git a/webapp/channels/src/components/admin_console/server_logs/log_list.scss b/webapp/channels/src/components/admin_console/server_logs/log_list.scss index 25e7f75455f..66bd4cacce3 100644 --- a/webapp/channels/src/components/admin_console/server_logs/log_list.scss +++ b/webapp/channels/src/components/admin_console/server_logs/log_list.scss @@ -15,6 +15,16 @@ white-space: normal; } +// Also in the portal, above the live tail intervals it explains +#serverLogsLiveTailUnavailable { + max-width: 220px; + padding: 8px 20px 4px; + margin: 0; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + line-height: 16px; +} + .LogViewer { @include log_viewer.viewer-shell; @@ -29,6 +39,16 @@ .inputWithMenu input.form-control { width: 150px; } + + // Fits a range spanning two days, e.g. 'Oct 26, 11:00 PM – Oct 27, 4:15 AM' + .LogViewer__duration--custom input.form-control { + width: 270px; + } + + // Fits a range with years, e.g. 'Sep 30, 2025, 12:45 PM – Oct 31, 2026, 12:45 PM' + .LogViewer__duration--custom-year input.form-control { + width: 380px; + } } &__filters-spacer { diff --git a/webapp/channels/src/components/admin_console/server_logs/log_list.test.tsx b/webapp/channels/src/components/admin_console/server_logs/log_list.test.tsx index 632118e550d..b118d64c122 100644 --- a/webapp/channels/src/components/admin_console/server_logs/log_list.test.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/log_list.test.tsx @@ -5,6 +5,7 @@ import React, {useState} from 'react'; import {LogLevelEnum, LogNodeKey} from '@mattermost/types/admin'; +import {fakeNow} from 'tests/helpers/date'; import {renderWithContext, fireEvent, screen, waitFor, within} from 'tests/react_testing_utils'; import LogList, {loadPrefs} from './log_list'; @@ -61,10 +62,11 @@ const baseProps = { pollIntervals: [5000], pollIntervalLabels: {5000: {id: 'test.pollInterval.5s', defaultMessage: 'Every 5 seconds'}}, timePresets: [], - activeTimePreset: null, + timeRange: null, timeRangeStart: null, onTimePreset: jest.fn(), - onClearTimePreset: jest.fn(), + onCustomRange: jest.fn(), + onClearTimeRange: jest.fn(), nodes: [] as string[], selectedNode: '', onSelectedNodeChange: jest.fn(), @@ -771,14 +773,14 @@ describe('components/admin_console/server_logs/LogList', () => { ]; test('should drop entries older than the active time range', () => { - renderListWith({logs: timeRangeLogs, activeTimePreset: 5, timeRangeStart}); + renderListWith({logs: timeRangeLogs, timeRange: 5, timeRangeStart}); expect(rowMessages().sort()).toEqual(['Failed to fetch logs from node', 'recent', 'unparseable']); expect(document.querySelector('.LogViewer__footer-info')).toHaveTextContent('of 3'); }); test('should keep an entry exactly at the cutoff', () => { - renderListWith({logs: [makeLog('edge', '2026-03-12T10:05:00.000Z')], activeTimePreset: 5, timeRangeStart}); + renderListWith({logs: [makeLog('edge', '2026-03-12T10:05:00.000Z')], timeRange: 5, timeRangeStart}); expect(rowMessages()).toEqual(['edge']); }); @@ -791,7 +793,7 @@ describe('components/admin_console/server_logs/LogList', () => { makeLog('old utc', '2026-03-12 10:04:59.000 Z'), makeLog('recent utc', '2026-03-12 10:05:45.000 Z'), ], - activeTimePreset: 5, + timeRange: 5, timeRangeStart, }); @@ -799,7 +801,7 @@ describe('components/admin_console/server_logs/LogList', () => { }); test('should count only entries in the time range per level', () => { - renderListWith({logs: timeRangeLogs, activeTimePreset: 5, timeRangeStart}); + renderListWith({logs: timeRangeLogs, timeRange: 5, timeRangeStart}); openLevelsMenu(); expect(levelMenuItem('Info')).toHaveTextContent('Info2'); @@ -807,20 +809,20 @@ describe('components/admin_console/server_logs/LogList', () => { }); test('should count only entries in the time range as search matches', () => { - renderListWith({logs: timeRangeLogs, activeTimePreset: 5, timeRangeStart, search: 'e'}); + renderListWith({logs: timeRangeLogs, timeRange: 5, timeRangeStart, search: 'e'}); expect(screen.getByText(/of 3 entries matching your filters$/)).toBeInTheDocument(); }); test('should show every entry without a time range', () => { - renderListWith({logs: timeRangeLogs, activeTimePreset: null}); + renderListWith({logs: timeRangeLogs, timeRange: null}); expect(rowMessages()).toHaveLength(4); }); test('should still hide a failed node when errors are turned off', () => { pinAscendingSort({enabledLevels: ['info']}); - renderListWith({logs: timeRangeLogs, activeTimePreset: 5, timeRangeStart}); + renderListWith({logs: timeRangeLogs, timeRange: 5, timeRangeStart}); expect(rowMessages()).not.toContain('Failed to fetch logs from node'); expect(rowMessages().sort()).toEqual(['recent', 'unparseable']); @@ -829,20 +831,20 @@ describe('components/admin_console/server_logs/LogList', () => { test('should only keep an entry older than the time range when the server marked it as a failed node', () => { // Same message as the failed-node entry, but logged by the node itself const logged = makeLog('Failed to fetch logs from node', '2026-03-12T09:00:00.000Z', LogLevelEnum.ERROR); - renderListWith({logs: [logged, makeLog('recent', '2026-03-12T10:05:30.000Z')], activeTimePreset: 5, timeRangeStart}); + renderListWith({logs: [logged, makeLog('recent', '2026-03-12T10:05:30.000Z')], timeRange: 5, timeRangeStart}); expect(rowMessages()).toEqual(['recent']); }); test('should offer to clear a time range that filters out every entry', () => { - const onClearTimePreset = jest.fn(); - renderListWith({logs: [makeLog('old', '2026-03-12T10:04:59.000Z')], activeTimePreset: 5, timeRangeStart, onClearTimePreset}); + const onClearTimeRange = jest.fn(); + renderListWith({logs: [makeLog('old', '2026-03-12T10:04:59.000Z')], timeRange: 5, timeRangeStart, onClearTimeRange}); expect(screen.getByText(/No logs match your filters/)).toBeInTheDocument(); expect(screen.queryByText(/No logs found/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', {name: 'Clear filters'})); - expect(onClearTimePreset).toHaveBeenCalledTimes(1); + expect(onClearTimeRange).toHaveBeenCalledTimes(1); }); test('should go back to the first page when the time range changes', () => { @@ -857,7 +859,7 @@ describe('components/admin_console/server_logs/LogList', () => { , ); @@ -873,7 +875,7 @@ describe('components/admin_console/server_logs/LogList', () => { ], nodes: ['node-a', 'node-b'], selectedNode: 'node-a', - activeTimePreset: 5, + timeRange: 5, timeRangeStart, }); @@ -881,6 +883,106 @@ describe('components/admin_console/server_logs/LogList', () => { }); }); + describe('custom time range', () => { + beforeEach(() => { + pinAscendingSort(); + fakeNow('2026-03-12T10:10:00.000Z'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const customRange = { + from: Date.parse('2026-03-12T10:00:00.000Z'), + to: Date.parse('2026-03-12T10:30:00.000Z'), + }; + + // The failed-node entry is outside the range on purpose + const rangeLogs = [ + makeLog('before', '2026-03-12T09:59:59.000Z'), + makeLog('start', '2026-03-12T10:00:00.000Z'), + makeLog('inside', '2026-03-12 12:15:00.000 +02:00'), + makeLog('end', '2026-03-12T10:30:00.000Z'), + makeLog('after', '2026-03-12T10:30:01.000Z'), + makeLog('unparseable', 'not a timestamp'), + makeFailedNodeLog('node-b', '2026-03-12 11:00:00.000 +00:00'), + ]; + + test('should keep only entries within both bounds, inclusive', () => { + renderListWith({logs: rangeLogs, timeRange: customRange}); + + expect(rowMessages()).toEqual(['start', 'inside', 'end', 'unparseable', 'Failed to fetch logs from node']); + }); + + test('should show the range in the duration field', () => { + renderListWith({timeRange: customRange}); + + // Intl separates the parts with thin and narrow no-break spaces + expect((screen.getByLabelText('Duration') as HTMLInputElement).value.replace(/\s/g, ' ')).toBe('Mar 12, 10:00 – 10:30 AM'); + expect(screen.getByLabelText('Open menu to select a time range')).not.toHaveClass('LogViewer__duration--custom-year'); + }); + + test('should mark the custom range as the selected duration', () => { + renderListWith({timeRange: customRange}); + + fireEvent.click(screen.getByLabelText('Open menu to select a time range')); + + expect(screen.getByRole('menuitemradio', {name: 'Custom range…'})).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'All time'})).toHaveAttribute('aria-checked', 'false'); + }); + + test('should offer to clear a custom range that filters out every entry', () => { + const onClearTimeRange = jest.fn(); + renderListWith({logs: [makeLog('after', '2026-03-12T11:00:00.000Z')], timeRange: customRange, onClearTimeRange}); + + expect(screen.getByText(/No logs match your filters/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'Clear filters'})); + expect(onClearTimeRange).toHaveBeenCalledTimes(1); + }); + + test('should not offer live tail intervals while a custom range is active', () => { + renderListWith({timeRange: customRange}); + + fireEvent.click(screen.getByLabelText('Open menu to turn live tail on or off')); + + const interval = screen.getByRole('menuitemradio', {name: /^Every 5 seconds/}); + expect(interval).toHaveAttribute('aria-disabled', 'true'); + expect(interval).toHaveAccessibleDescription('Not available with a custom time range'); + }); + + test('should show the year of a range from an earlier year', () => { + renderListWith({timeRange: { + from: Date.parse('2025-12-31T23:00:00.000Z'), + to: Date.parse('2026-01-01T01:00:00.000Z'), + }}); + + expect((screen.getByLabelText('Duration') as HTMLInputElement).value.replace(/\s/g, ' ')).toBe('Dec 31, 2025, 11:00 PM – Jan 1, 2026, 1:00 AM'); + + // The longer label gets a wider field + expect(screen.getByLabelText('Open menu to select a time range')).toHaveClass('LogViewer__duration--custom-year'); + }); + + test('should decide on the year in the user\'s timezone', () => { + // Already 2027 in Tokyo, still 2026 in UTC + fakeNow('2026-12-31T16:00:00.000Z'); + renderWithContext( + , + {entities: {users: { + currentUserId: 'user1', + profiles: {user1: {id: 'user1', timezone: {useAutomaticTimezone: 'false', automaticTimezone: '', manualTimezone: 'Asia/Tokyo'}}}, + }}}, + ); + + expect((screen.getByLabelText('Duration') as HTMLInputElement).value.replace(/\s/g, ' ')).toBe('Dec 31, 2026, 11:00 – 11:30 PM'); + }); + }); + describe('keyboard shortcuts', () => { beforeEach(pinAscendingSort); @@ -1070,7 +1172,7 @@ describe('components/admin_console/server_logs/LogList', () => { }); test('should drop entries that age out when the parent moves the time range on', () => { - const props = {activeTimePreset: 5, timePresets: [{label: {id: 'test.time.5m', defaultMessage: 'Last 5 minutes'}, minutes: 5}]}; + const props = {timeRange: 5, timePresets: [{label: {id: 'test.time.5m', defaultMessage: 'Last 5 minutes'}, minutes: 5}]}; const {update} = renderUpdatable([first, makeLog('recent', '2026-03-12T10:04:00.000Z')], {...props, timeRangeStart: Date.parse('2026-03-12T10:00:00.000Z')}); expect(rowMessages()).toEqual(['recent', 'msg 1']); diff --git a/webapp/channels/src/components/admin_console/server_logs/log_list.tsx b/webapp/channels/src/components/admin_console/server_logs/log_list.tsx index e9d6c1e3b7b..8226f81a81b 100644 --- a/webapp/channels/src/components/admin_console/server_logs/log_list.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/log_list.tsx @@ -1,10 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import classNames from 'classnames'; import debounce from 'lodash/debounce'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {MessageDescriptor} from 'react-intl'; import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import {ArrowDownIcon, ArrowUpIcon, DownloadOutlineIcon, RefreshIcon} from '@mattermost/compass-icons/components'; import {Button} from '@mattermost/compass-ui/components/button'; @@ -14,8 +16,11 @@ import {WithTooltip} from '@mattermost/shared/components/tooltip'; import {LogNodeKey} from '@mattermost/types/admin'; import {General} from 'mattermost-redux/constants'; +import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; import {logEntryKey, logEntryTime} from 'mattermost-redux/utils/log_utils'; +import {isUseMilitaryTime} from 'selectors/preferences'; + import ExternalLink from 'components/external_link'; import * as Menu from 'components/menu'; import Input from 'components/widgets/inputs/input/input'; @@ -23,9 +28,10 @@ import LoadingSpinner from 'components/widgets/loading/loading_spinner'; import {useLatest} from 'hooks/useLatest'; +import CustomRangeModal from './custom_range_modal'; import KeyboardHints from './keyboard_hints'; import LogRow from './log_row'; -import type {LogObjectWithAdditionalInfo} from './types'; +import type {CustomTimeRange, LogObjectWithAdditionalInfo, TimeRange} from './types'; import {LEVEL_GROUPS, levelGroup, newestLogTimes} from './utils'; import './log_list.scss'; @@ -62,12 +68,13 @@ type Props = { pollIntervalLabels: Record; timePresets: readonly TimePreset[]; - activeTimePreset: number | null; + timeRange: TimeRange; // Where the active time range starts, moved on by the parent with every fetch timeRangeStart: number | null; onTimePreset: (minutes: number) => void; - onClearTimePreset: () => void; + onCustomRange: (range: CustomTimeRange) => void; + onClearTimeRange: () => void; // Rendered alongside the other filters so both viewers share one filter row logFormatMenu?: React.ReactNode; @@ -94,6 +101,7 @@ const LEVEL_LABELS = defineMessages({ const messages = defineMessages({ allTime: {id: 'admin.logs.time.allTime', defaultMessage: 'All time'}, + customRange: {id: 'admin.logs.time.custom', defaultMessage: 'Custom range…'}, allLevels: {id: 'admin.logs.allLevels', defaultMessage: 'All levels'}, liveTail: {id: 'admin.logs.liveTail', defaultMessage: 'Live tail'}, liveTailOff: {id: 'admin.logs.liveTail.off', defaultMessage: 'Off'}, @@ -103,6 +111,8 @@ const messages = defineMessages({ const PREFS_KEY = 'mm_admin_logs_prefs'; +const LIVE_TAIL_UNAVAILABLE_ID = 'serverLogsLiveTailUnavailable'; + type StoredPrefs = { pageSize: number; wrapText: boolean; @@ -175,7 +185,7 @@ function isNewerThan(log: LogObjectWithAdditionalInfo, times: NodeTimes): boolea // Older nodes ignore the server's date filter, and a node switch shows before its fetch returns. // Returns the same array when nothing is dropped, so a moved cutoff only re-sorts when it matters -function scopeLogs(logs: LogObjectWithAdditionalInfo[], selectedNode: string, cutoff: number | null) { +function scopeLogs(logs: LogObjectWithAdditionalInfo[], selectedNode: string, cutoff: number | null, end: number | null) { if (!selectedNode && cutoff === null) { return logs; } @@ -189,7 +199,7 @@ function scopeLogs(logs: LogObjectWithAdditionalInfo[], selectedNode: string, cu return true; } const time = logEntryTime(log); - return Number.isNaN(time) || time >= cutoff; + return Number.isNaN(time) || (time >= cutoff && (end === null || time <= end)); }); return scoped.length === logs.length ? logs : scoped; } @@ -220,10 +230,14 @@ export default function LogList({ onReload, downloadUrl, liveTailEnabled, onToggleLiveTail, pollInterval, onPollIntervalChange, pollIntervals, pollIntervalLabels, - timePresets, activeTimePreset, timeRangeStart, onTimePreset, onClearTimePreset, + timePresets, timeRange, timeRangeStart, onTimePreset, onCustomRange, onClearTimeRange, logFormatMenu, }: Props) { + const customRange = typeof timeRange === 'number' ? null : timeRange; const intl = useIntl(); + const timezone = useSelector(getCurrentTimezone); + const isMilitaryTime = useSelector(isUseMilitaryTime); + const [showCustomRangeModal, setShowCustomRangeModal] = useState(false); const initialPrefs = useMemo(() => loadPrefs(), []); const [page, setPage] = useState(0); @@ -300,7 +314,13 @@ export default function LogList({ setTail((prev) => (prev?.highlight && prev.highlight.page !== page ? {...prev, highlight: null} : prev)); }, [page]); - const inRangeLogs = useMemo(() => scopeLogs(logs, selectedNode, activeTimePreset === null ? null : timeRangeStart), [logs, selectedNode, activeTimePreset, timeRangeStart]); + // A custom range is fixed, so it doesn't wait on the parent's fetch to move its start + const inRangeLogs = useMemo(() => { + if (customRange) { + return scopeLogs(logs, selectedNode, customRange.from, customRange.to); + } + return scopeLogs(logs, selectedNode, timeRange === null ? null : timeRangeStart, null); + }, [logs, selectedNode, timeRange, timeRangeStart, customRange]); const {scopedLogs, heldLogs} = useMemo(() => { if (!tail) { @@ -376,7 +396,7 @@ export default function LogList({ setPage(0); setExpandedKey(null); setFocusedKey(null); - }, [search, enabledLevels, sortAsc, selectedNode, activeTimePreset]); + }, [search, enabledLevels, sortAsc, selectedNode, timeRange]); // Clamp the page when a reload or poll shrinks the dataset useEffect(() => { @@ -494,7 +514,20 @@ export default function LogList({ } }, [visibleLogs, rowKeys, focusedIndex, focusRow]); - const activePresetLabel = timePresets.find((preset) => preset.minutes === activeTimePreset)?.label; + const activePresetLabel = timePresets.find((preset) => preset.minutes === timeRange)?.label; + + // The end is never in the future, so a start in this year means the whole range is + const yearOf = (time: number) => intl.formatDate(time, {year: 'numeric', timeZone: timezone}); + const showsYear = customRange !== null && yearOf(customRange.from) !== yearOf(Date.now()); + const durationValue = customRange ? intl.formatDateTimeRange(customRange.from, customRange.to, { + year: showsYear ? 'numeric' : undefined, + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hourCycle: isMilitaryTime ? 'h23' : 'h12', + timeZone: timezone, + }) : intl.formatMessage(activePresetLabel ?? messages.allTime); const enabledLevelCount = LEVEL_GROUPS.filter((level) => enabledLevels.has(level)).length; // One selector for on/off and the interval, so picking an interval turns polling on @@ -663,6 +696,14 @@ export default function LogList({ /> ), }} + menuHeader={customRange && ( +

+ +

+ )} menu={{ id: 'serverLogsLiveTailMenu', 'aria-label': intl.formatMessage({id: 'admin.logs.liveTail.dropdownAriaLabel', defaultMessage: 'Live tail menu'}), @@ -683,6 +724,8 @@ export default function LogList({ key={interval} id={`serverLogsLiveTail-${interval}`} role='menuitemradio' + disabled={customRange !== null} + aria-describedby={customRange ? LIVE_TAIL_UNAVAILABLE_ID : undefined} aria-checked={liveTailEnabled && interval === pollInterval} forceCloseOnSelect={true} labels={} @@ -695,14 +738,17 @@ export default function LogList({ } /> @@ -717,25 +763,45 @@ export default function LogList({ } - trailingElements={activeTimePreset === null && } - onClick={onClearTimePreset} + trailingElements={timeRange === null && } + onClick={onClearTimeRange} /> {timePresets.map((preset) => ( } - trailingElements={activeTimePreset === preset.minutes && } + trailingElements={timeRange === preset.minutes && } onClick={() => onTimePreset(preset.minutes)} /> ))} + + } + trailingElements={customRange !== null && } + onClick={() => setShowCustomRangeModal(true)} + /> + {showCustomRangeModal && ( + { + onCustomRange(range); + setShowCustomRangeModal(false); + }} + onExited={() => setShowCustomRangeModal(false)} + /> + )} {logFormatMenu} @@ -877,7 +943,7 @@ export default function LogList({ {/* Empty state — kept outside the list so it only owns rows */} {visibleLogs.length === 0 && !loading && (
- {search || !allLevelsEnabled || selectedNode || activeTimePreset !== null ? ( + {search || !allLevelsEnabled || selectedNode || timeRange !== null ? ( diff --git a/webapp/channels/src/components/admin_console/server_logs/logs.test.tsx b/webapp/channels/src/components/admin_console/server_logs/logs.test.tsx index 11a36d5da09..3c05e5da6cf 100644 --- a/webapp/channels/src/components/admin_console/server_logs/logs.test.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/logs.test.tsx @@ -5,7 +5,8 @@ import React from 'react'; import {LogLevelEnum, LogNodeKey} from '@mattermost/types/admin'; -import {renderWithContext, userEvent, screen, waitFor, within} from 'tests/react_testing_utils'; +import {fakeNow} from 'tests/helpers/date'; +import {act, renderWithContext, userEvent, screen, waitFor, within} from 'tests/react_testing_utils'; import Logs from './logs'; import type {LogObjectWithAdditionalInfo} from './types'; @@ -286,11 +287,6 @@ describe('components/admin_console/server_logs/Logs fetching', () => { jest.useRealTimers(); }); - // Fakes only the clock, so the promises and timers the tests wait on keep running - function fakeNow(now: string) { - jest.useFakeTimers({now: new Date(now), doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'setImmediate', 'clearImmediate', 'queueMicrotask', 'nextTick', 'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback', 'cancelIdleCallback', 'performance', 'hrtime']}); - } - test('should send the enabled levels with the query', async () => { const getLogs = jest.fn().mockResolvedValue({}); renderLogs(getLogs); @@ -334,6 +330,141 @@ describe('components/admin_console/server_logs/Logs fetching', () => { expect(await screen.findByText(/No logs found/)).toBeInTheDocument(); }); + function rangeTimeInput(bound: 'From' | 'To') { + return within(screen.getByRole('group', {name: bound})).getByLabelText('Time'); + } + + async function typeRangeTime(bound: 'From' | 'To', time: string) { + await userEvent.clear(rangeTimeInput(bound)); + await userEvent.type(rangeTimeInput(bound), time); + await userEvent.tab(); + } + + test('should send both bounds of a custom range and show it in the duration field', async () => { + fakeNow('2026-10-20T12:00:30Z'); + const getLogs = jest.fn().mockResolvedValue({}); + renderLogs(getLogs); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + + // Defaults to the last hour, ending at the current minute + expect(rangeTimeInput('From')).toHaveValue('11:00 AM'); + expect(rangeTimeInput('To')).toHaveValue('12:00 PM'); + expect(screen.getByText(/Times are in your timezone/)).toBeInTheDocument(); + + await typeRangeTime('From', '11:12'); + await userEvent.click(screen.getByRole('button', {name: 'Apply'})); + + // The end covers its whole minute + await waitFor(() => expect(getLogs).toHaveBeenLastCalledWith(expect.objectContaining({ + dateFrom: '2026-10-20 11:12:00.000 +00:00', + dateTo: '2026-10-20 12:00:59.999 +00:00', + }))); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect((screen.getByLabelText('Duration') as HTMLInputElement).value.replace(/\s/g, ' ')).toBe('Oct 20, 11:12 AM – 12:00 PM'); + + await pickMenuItem('Open menu to select a time range', 'All time'); + + await waitFor(() => expect(getLogs).toHaveBeenLastCalledWith(expect.objectContaining({dateFrom: '', dateTo: ''}))); + }); + + test('should not apply a custom range that ends before it starts', async () => { + fakeNow('2026-10-20T12:00:30Z'); + const getLogs = jest.fn().mockResolvedValue({}); + renderLogs(getLogs); + await waitFor(() => expect(getLogs).toHaveBeenCalledTimes(1)); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + await typeRangeTime('To', '10:00'); + + expect(screen.getByText('The start must be before the end.')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Apply'})).toBeDisabled(); + expect(getLogs).toHaveBeenCalledTimes(1); + }); + + test('should not let a custom range end in the future', async () => { + fakeNow('2026-10-20T12:00:30Z'); + renderLogs(jest.fn().mockResolvedValue({})); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + await typeRangeTime('To', '13:00'); + + expect(rangeTimeInput('To')).toHaveValue('12:00 PM'); + }); + + test('should let a custom range end in a minute that started after the dialog opened', async () => { + // Moments before the next minute, so the dialog's clock ticks over soon + fakeNow('2026-10-20T12:00:59.950Z'); + renderLogs(jest.fn().mockResolvedValue({})); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + expect(rangeTimeInput('To')).toHaveValue('12:00 PM'); + + jest.setSystemTime(new Date('2026-10-20T12:01:10Z')); + await act(() => new Promise((resolve) => setTimeout(resolve, 100))); + await typeRangeTime('To', '12:01'); + + expect(rangeTimeInput('To')).toHaveValue('12:01 PM'); + }); + + test('should not apply a custom range while a time entry is invalid', async () => { + fakeNow('2026-10-20T12:00:30Z'); + renderLogs(jest.fn().mockResolvedValue({})); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + await typeRangeTime('From', 'soon'); + expect(screen.getByRole('button', {name: 'Apply'})).toBeDisabled(); + + await typeRangeTime('From', '11:30'); + expect(screen.getByRole('button', {name: 'Apply'})).toBeEnabled(); + + await userEvent.clear(rangeTimeInput('To')); + await userEvent.tab(); + expect(screen.getByRole('button', {name: 'Apply'})).toBeDisabled(); + }); + + test('should not apply a custom range when Apply is clicked straight after typing an invalid time', async () => { + fakeNow('2026-10-20T12:00:30Z'); + const getLogs = jest.fn().mockResolvedValue({}); + renderLogs(getLogs); + await waitFor(() => expect(getLogs).toHaveBeenCalledTimes(1)); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + await userEvent.clear(rangeTimeInput('To')); + await userEvent.type(rangeTimeInput('To'), 'abc'); + + // A browser blurs the time on mousedown and clicks on mouseup, with only microtasks in between. + // Outside act, so React schedules work as it would there instead of flushing everything per event + const actEnvironment = (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT; + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = false; + try { + rangeTimeInput('To').blur(); + await Promise.resolve(); + screen.getByRole('button', {name: 'Apply'}).click(); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = actEnvironment; + } + + expect(screen.getByRole('dialog', {name: 'Custom time range'})).toBeInTheDocument(); + expect(rangeTimeInput('To')).toHaveClass('error'); + expect(screen.getByRole('button', {name: 'Apply'})).toBeDisabled(); + expect(getLogs).toHaveBeenCalledTimes(1); + }); + + test('should turn off live tail when a custom range is applied', async () => { + fakeNow('2026-10-20T12:00:30Z'); + renderLogs(jest.fn().mockResolvedValue({})); + + await pickMenuItem('Open menu to turn live tail on or off', 'Every 10 seconds'); + expect(screen.getByLabelText('Live tail')).toHaveValue('Every 10 seconds'); + + await pickMenuItem('Open menu to select a time range', 'Custom range'); + await userEvent.click(screen.getByRole('button', {name: 'Apply'})); + + expect(screen.getByLabelText('Live tail')).toHaveValue('Off'); + }); + test('should not start a fetch until the previous one has finished', async () => { const first = deferred(); const getLogs = jest.fn().mockReturnValueOnce(first.promise).mockResolvedValue({}); diff --git a/webapp/channels/src/components/admin_console/server_logs/logs.tsx b/webapp/channels/src/components/admin_console/server_logs/logs.tsx index 4c28ea3825e..94b96ea1c86 100644 --- a/webapp/channels/src/components/admin_console/server_logs/logs.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/logs.tsx @@ -17,7 +17,7 @@ import AdminHeader from 'components/widgets/admin_console/admin_header'; import LogFormatMenu from './log_format_menu'; import LogList, {loadPrefs} from './log_list'; import PlainLogList from './plain_log_list'; -import type {LogObjectWithAdditionalInfo} from './types'; +import type {CustomTimeRange, LogObjectWithAdditionalInfo, TimeRange} from './types'; import useLogPolling from './use_log_polling'; import {LEVEL_GROUPS, groupLevels, presetStartMs} from './utils'; @@ -109,7 +109,7 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl const [initialPrefs] = useState(loadPrefs); const [enabledLevels, setEnabledLevels] = useState(() => new Set(initialPrefs.enabledLevels)); const [selectedNode, setSelectedNode] = useState(initialPrefs.node); - const [activeTimePreset, setActiveTimePreset] = useState(null); + const [timeRange, setTimeRange] = useState(null); const [plainPage, setPlainPage] = useState(0); const [liveTailEnabled, setLiveTailEnabled] = useState(false); const [pollInterval, setPollInterval] = useState(30000); @@ -152,7 +152,15 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl setLoading(true); } - const rangeStart = activeTimePreset === null ? null : presetStartMs(activeTimePreset); + // A preset has no end, so the server ends the range at its own clock rather than the browser's + let rangeStart: number | null = null; + let rangeEnd: number | null = null; + if (typeof timeRange === 'number') { + rangeStart = presetStartMs(timeRange); + } else if (timeRange) { + rangeStart = timeRange.from; + rangeEnd = timeRange.to; + } setTimeRangeStart(rangeStart); const fetchLogs = async () => { @@ -174,12 +182,11 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl const since = lastFetchStartRef.current === null ? rangeStart : Math.max(lastFetchStartRef.current - POLL_OVERLAP_MS, rangeStart ?? 0); const startedAt = Date.now(); - // No end date, so the server ends the range at its own clock rather than the browser's const query = { serverNames: selectedNode ? [selectedNode] : ALL_SERVER_NAMES, logLevels, dateFrom: since === null ? '' : formatFilterDate(new Date(since)), - dateTo: '', + dateTo: rangeEnd === null ? '' : formatFilterDate(new Date(rangeEnd)), search, }; const result = await (silent ? actions.getNewLogs(query) : actions.getLogs(query)); @@ -197,7 +204,7 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl }); fetchQueueRef.current = fetched.catch(() => undefined); return fetched; - }, [isPlainLogs, plainPage, activeTimePreset, enabledLevels, selectedNode, search, actions]); + }, [isPlainLogs, plainPage, timeRange, enabledLevels, selectedNode, search, actions]); // reload's deps are the query's inputs, so this also covers every filter and page change useEffect(() => { @@ -227,7 +234,13 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl } }, []); - const clearTimePreset = useCallback(() => setActiveTimePreset(null), []); + const clearTimeRange = useCallback(() => setTimeRange(null), []); + + // A range that ends in the past has nothing new to tail + const applyCustomRange = useCallback((range: CustomTimeRange) => { + setTimeRange(range); + setLiveTailEnabled(false); + }, []); const logFormatMenu = configIsPlainLogs ? null : ( ); diff --git a/webapp/channels/src/components/admin_console/server_logs/types.ts b/webapp/channels/src/components/admin_console/server_logs/types.ts index ac0fa4c9d9b..f70aebcdc59 100644 --- a/webapp/channels/src/components/admin_console/server_logs/types.ts +++ b/webapp/channels/src/components/admin_console/server_logs/types.ts @@ -7,3 +7,12 @@ export type LogObjectWithAdditionalInfo = LogObject & { [LogNodeKey]?: string; [key: string]: unknown; }; + +// Absolute bounds in epoch milliseconds, both inclusive +export type CustomTimeRange = { + from: number; + to: number; +}; + +// A preset's length in minutes, or an absolute range +export type TimeRange = number | CustomTimeRange | null; diff --git a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx index afd5826e3f9..06f43476f29 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx @@ -4,6 +4,7 @@ import moment from 'moment-timezone'; import React from 'react'; +import {fakeNow} from 'tests/helpers/date'; import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import * as timezoneUtils from 'utils/timezone'; @@ -153,6 +154,67 @@ describe('components/datetime_input/DateTimeInput', () => { expect(baseProps.handleChange).toHaveBeenCalled(); }); + + test('should keep the chosen time when picking today with past dates allowed', async () => { + // The calendar decides which day is today from the real clock + fakeNow('2025-06-08T10:00:00Z'); + mockGetCurrentMomentForTimezone.mockReturnValue(moment.tz('2025-06-08T10:00:00Z', 'UTC')); + const handleChange = jest.fn(); + + try { + renderWithContext( + , + ); + + await userEvent.click(screen.getByText('Date').closest('.date-time-input')!); + await userEvent.click(screen.getByText('8')); // June 8th, today + + expect(handleChange).toHaveBeenCalled(); + expect(handleChange.mock.lastCall[0].toISOString()).toBe('2025-06-08T09:12:00.000Z'); + } finally { + jest.useRealTimers(); + } + }); + + test('should restore a cleared time when the selected day is picked again', async () => { + fakeNow('2025-06-08T10:00:00Z'); + mockGetCurrentMomentForTimezone.mockReturnValue(moment.tz('2025-06-08T10:00:00Z', 'UTC')); + const onTimeInputError = jest.fn(); + + try { + // handleChange doesn't update the time, so picking the same day leaves the prop unchanged + renderWithContext( + , + ); + + const timeInput = screen.getByLabelText('Time'); + await userEvent.clear(timeInput); + await userEvent.tab(); + expect(timeInput).toHaveClass('error'); + expect(onTimeInputError).toHaveBeenLastCalledWith(true); + + await userEvent.click(screen.getByText('Date').closest('.date-time-input')!); + await userEvent.click(screen.getByText('8')); // June 8th, already selected + + expect(timeInput).toHaveValue('9:12 AM'); + expect(timeInput).not.toHaveClass('error'); + expect(onTimeInputError).toHaveBeenLastCalledWith(false); + } finally { + jest.useRealTimers(); + } + }); }); describe('timezone handling', () => { diff --git a/webapp/channels/src/components/datetime_input/datetime_input.tsx b/webapp/channels/src/components/datetime_input/datetime_input.tsx index f80e55abe8f..1600bd9dfc7 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.tsx @@ -14,6 +14,7 @@ import {isUseMilitaryTime} from 'selectors/preferences'; import DatePicker from 'components/date_picker'; import * as Menu from 'components/menu'; +import {useLatest} from 'hooks/useLatest'; import Constants from 'utils/constants'; import {formatDateForDisplay} from 'utils/date_utils'; import {relativeFormatDate} from 'utils/datetime'; @@ -117,8 +118,12 @@ type TimeInputManualProps = { timezone?: string; isMilitaryTime: boolean; onTimeChange: (time: Moment) => void; + onTimeInputError?: (hasError: boolean) => void; minDateTime?: Moment; maxDateTime?: Moment; + + // Changes each time a day is picked, so picking the day already selected still restores a cleared time + dayPickCount: number; }; const TimeInputManual: React.FC = ({ @@ -126,14 +131,25 @@ const TimeInputManual: React.FC = ({ timezone, isMilitaryTime, onTimeChange, + onTimeInputError, minDateTime, maxDateTime, + dayPickCount, }) => { const {formatMessage} = useIntl(); const [timeInputValue, setTimeInputValue] = useState(''); - const [timeInputError, setTimeInputError] = useState(false); + const [timeInputError, setTimeInputErrorState] = useState(false); const timeInputRef = useRef(null); + // Reported from the same handler as the change, not an effect, so the parent hears about it as soon as a valid + // time would be. An effect's update can land after a click that blurred the input, letting that click act on it. + // Through a ref, so a new callback each render doesn't re-run the sync below and reset what's typed + const onTimeInputErrorRef = useLatest(onTimeInputError); + const setTimeInputError = useCallback((hasError: boolean) => { + setTimeInputErrorState(hasError); + onTimeInputErrorRef.current?.(hasError); + }, [onTimeInputErrorRef]); + // Sync input value with time prop changes useEffect(() => { if (time) { @@ -142,18 +158,20 @@ const TimeInputManual: React.FC = ({ } else { setTimeInputValue(''); } - }, [time, isMilitaryTime]); + setTimeInputError(false); + }, [time, isMilitaryTime, dayPickCount, setTimeInputError]); const handleTimeInputChange = useCallback((event: React.ChangeEvent) => { setTimeInputValue(event.target.value); setTimeInputError(false); // Clear error as user types - }, []); + }, [setTimeInputError]); const handleTimeInputBlur = useCallback(() => { const parsed = parseTimeString(timeInputValue); if (!parsed) { - if (timeInputValue.trim() !== '') { + // A cleared field still holds the previous time, so it's an error unless there is none + if (timeInputValue.trim() !== '' || time) { setTimeInputError(true); } return; @@ -191,7 +209,7 @@ const TimeInputManual: React.FC = ({ // Valid time - update (no auto-advance, no exclusion checking) onTimeChange(targetMoment); setTimeInputError(false); - }, [timeInputValue, time, timezone, onTimeChange, minDateTime, maxDateTime]); + }, [timeInputValue, time, timezone, onTimeChange, minDateTime, maxDateTime, setTimeInputError]); const handleTimeInputKeyDown = useCallback((event: React.KeyboardEvent) => { if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) { @@ -230,6 +248,9 @@ type Props = { timePickerInterval?: number; allowPastDates?: boolean; manualTimeEntry?: boolean; + + // Called when the manually entered time becomes invalid or valid again. The time keeps its last valid value meanwhile + onTimeInputError?: (hasError: boolean) => void; minDateTime?: Moment; maxDateTime?: Moment; }; @@ -243,6 +264,7 @@ const DateTimeInputContainer: React.FC = ({ timePickerInterval, allowPastDates = false, manualTimeEntry = false, + onTimeInputError, minDateTime, maxDateTime, }: Props) => { @@ -254,6 +276,7 @@ const DateTimeInputContainer: React.FC = ({ const [isPopperOpen, setIsPopperOpen] = useState(false); const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false); const [menuWidth, setMenuWidth] = useState('200px'); + const [dayPickCount, setDayPickCount] = useState(0); const {formatMessage} = useIntl(); const timeContainerRef = useRef(null); @@ -363,10 +386,11 @@ const DateTimeInputContainer: React.FC = ({ getRoundedTime(nowInTimezone, timePickerInterval || 60); } + // Past dates are allowed, so today has no earliest time and keeps the one already chosen let result: Moment; - if (modifiers.today) { + if (modifiers.today && !allowPastDates) { const baseTime = getCurrentMomentForTimezone(timezone); - if (!allowPastDates && isBeforeTime(baseTime, effectiveTime)) { + if (isBeforeTime(baseTime, effectiveTime)) { baseTime.hour(effectiveTime.hours()); baseTime.minute(effectiveTime.minutes()); } @@ -397,6 +421,7 @@ const DateTimeInputContainer: React.FC = ({ } handleChange(result); + setDayPickCount((count) => count + 1); handlePopperOpenState(false); }; @@ -484,8 +509,10 @@ const DateTimeInputContainer: React.FC = ({ timezone={timezone} isMilitaryTime={isMilitaryTime} onTimeChange={handleTimeChange} + onTimeInputError={onTimeInputError} minDateTime={minDateTime} maxDateTime={maxDateTime} + dayPickCount={dayPickCount} /> ) : ( void => { }; }; +// Fakes only the clock, so the promises and timers a test waits on keep running. +// Undo with jest.useRealTimers(). +export function fakeNow(now: string) { + jest.useFakeTimers({now: new Date(now), doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'setImmediate', 'clearImmediate', 'queueMicrotask', 'nextTick', 'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback', 'cancelIdleCallback', 'performance', 'hrtime']}); +} + export const unixTimestampFromNow = (daysFromNow: number) => { const now = new Date(); return Math.ceil(new Date(now.getTime() + (daysFromNow * 24 * 60 * 60 * 1000)).getTime());