[MM-71403] Add a custom from/to time range to the Server Logs viewer (#39509)

* [MM-71403] Add a custom from/to time range to the Server Logs viewer

Adds a "Custom range…" option to the Duration menu that opens a dialog
with From and To date and time pickers in the admin's timezone. Both
bounds are sent to the logs query, the end covering its whole minute,
and the client-side time backstop now applies the upper bound too.
Live tail is turned off and unavailable while a custom range is set.

Picking today in DateTimeInput no longer resets the chosen time to now
when past dates are allowed.

* [MM-71403] Trim the custom range docs note

* [MM-71403] Block invalid range times and pick the year in the user's timezone

- Keep Apply disabled while a typed time is invalid or cleared. DateTimeInput
  reports manual time errors through a new optional onTimeInputError callback.
  A cleared field that still holds a time now shows the error border, also in
  app forms.
- Decide whether the range label shows the year in the user's timezone.
- Pin the clock in the custom range tests.
- Use Compass tokens in the custom range modal styles.

* [MM-71403] Fix the three defects found by automated QA

- Custom range dialog: the cap on both pickers now follows the current
  minute while the dialog stays open, instead of staying at the minute it
  was opened. Typing the minute it now is no longer snaps back.
- Duration field: a range shown with years gets a wider field, so a
  cross-year label like 'Dec 31, 2025, 11:00 PM – Jan 1, 2026, 1:00 AM'
  is no longer cut off.
- DateTimeInput: picking the day that is already selected now restores a
  cleared manual time and clears its error, like picking another day.
  Before, it only reset when the stored time changed, which a same-day
  pick doesn't do in app forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpCfx5tK9UfjDiH2sPNKG1

* [MM-71403] Block Apply when an invalid time is clicked away from

The manual time field passed its error up from an effect. An update made
in an effect waits for a scheduler task, so a click on Apply that blurred
an unparseable time could land first and apply the previous range. The
error is now reported from the handler that finds it, like a valid time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpCfx5tK9UfjDiH2sPNKG1

* [MM-71403] Check the exact bounds a custom range sends in the e2e test

The custom range test only checked that both dates were well formed, so a
later end would still pass. It now checks the end is the last millisecond
of the minute the dialog opened in, and the start an hour before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpCfx5tK9UfjDiH2sPNKG1

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ben SchumacherandClaude Opus 5.5 authored and GitHub committed 2026-11-02 17:28:57 +01:00
1 parent 621cfdf469
commit 47b81751a4
15 files changed
+793 -58

No files matched your search

@@ -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.
</Note>
@@ -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');
}
@@ -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.
*/
@@ -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);
}
}
}
@@ -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<typeof setTimeout>;
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<Moment>(() => (initialRange ? moment.tz(initialRange.from, timezone) : now.clone().subtract(DEFAULT_RANGE_MS, 'ms')));
const [to, setTo] = useState<Moment>(() => (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 (
<GenericModal
id='serverLogsCustomRangeModal'
className='ServerLogsCustomRangeModal'
ariaLabel={intl.formatMessage({id: 'admin.logs.customRange.title', defaultMessage: 'Custom time range'})}
modalHeaderText={
<FormattedMessage
id='admin.logs.customRange.title'
defaultMessage='Custom time range'
/>
}
modalSubheaderText={
<FormattedMessage
id='admin.logs.customRange.timezone'
defaultMessage='Times are in your timezone: {timezone}'
values={{timezone: timezoneLabel}}
/>
}
confirmButtonText={
<FormattedMessage
id='admin.logs.customRange.apply'
defaultMessage='Apply'
/>
}
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}
>
<fieldset className='ServerLogsCustomRangeModal__bound'>
<legend>
<FormattedMessage
id='admin.logs.customRange.from'
defaultMessage='From'
/>
</legend>
<DateTimeInput
time={from}
handleChange={(time) => time && setFrom(time)}
timezone={timezone}
setIsInteracting={setIsFromInteracting}
onTimeInputError={setHasFromTimeError}
allowPastDates={true}
manualTimeEntry={true}
maxDateTime={now}
/>
</fieldset>
<fieldset className='ServerLogsCustomRangeModal__bound'>
<legend>
<FormattedMessage
id='admin.logs.customRange.to'
defaultMessage='To'
/>
</legend>
<DateTimeInput
time={to}
handleChange={(time) => time && setTo(time)}
timezone={timezone}
setIsInteracting={setIsToInteracting}
onTimeInputError={setHasToTimeError}
allowPastDates={true}
manualTimeEntry={true}
maxDateTime={now}
/>
</fieldset>
</GenericModal>
);
}
@@ -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 {
@@ -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', () => {
<StatefulLogList
{...baseProps}
logs={makeLogs(60)}
activeTimePreset={15}
timeRange={15}
/>,
);
@@ -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(
<StatefulLogList
{...baseProps}
logs={[]}
timeRange={{from: Date.parse('2026-12-31T14:00:00.000Z'), to: Date.parse('2026-12-31T14:30:00.000Z')}}
/>,
{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']);
@@ -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<number, MessageDescriptor>;
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 && (
<p id={LIVE_TAIL_UNAVAILABLE_ID}>
<FormattedMessage
id='admin.logs.liveTail.customRange'
defaultMessage='Not available with a custom time range'
/>
</p>
)}
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={<FormattedMessage {...pollIntervalLabels[interval]}/>}
@@ -695,14 +738,17 @@ export default function LogList({
<Menu.Container
menuButton={{
id: 'serverLogsDurationMenuButton',
class: 'inputWithMenu',
class: classNames('inputWithMenu', {
'LogViewer__duration--custom': customRange,
'LogViewer__duration--custom-year': showsYear,
}),
'aria-label': intl.formatMessage({id: 'admin.logs.duration.menuButtonAriaLabel', defaultMessage: 'Open menu to select a time range'}),
as: 'div',
children: (
<Input
label={intl.formatMessage({id: 'admin.logs.duration', defaultMessage: 'Duration'})}
name='serverLogsDuration'
value={intl.formatMessage(activePresetLabel ?? messages.allTime)}
value={durationValue}
readOnly={true}
inputSuffix={<i className='icon icon-chevron-down'/>}
/>
@@ -717,25 +763,45 @@ export default function LogList({
<Menu.Item
id='serverLogsDuration-all'
role='menuitemradio'
aria-checked={activeTimePreset === null}
aria-checked={timeRange === null}
forceCloseOnSelect={true}
labels={<FormattedMessage {...messages.allTime}/>}
trailingElements={activeTimePreset === null && <i className='icon icon-check'/>}
onClick={onClearTimePreset}
trailingElements={timeRange === null && <i className='icon icon-check'/>}
onClick={onClearTimeRange}
/>
{timePresets.map((preset) => (
<Menu.Item
key={preset.minutes}
id={`serverLogsDuration-${preset.minutes}`}
role='menuitemradio'
aria-checked={activeTimePreset === preset.minutes}
aria-checked={timeRange === preset.minutes}
forceCloseOnSelect={true}
labels={<FormattedMessage {...preset.label}/>}
trailingElements={activeTimePreset === preset.minutes && <i className='icon icon-check'/>}
trailingElements={timeRange === preset.minutes && <i className='icon icon-check'/>}
onClick={() => onTimePreset(preset.minutes)}
/>
))}
<Menu.Separator/>
<Menu.Item
id='serverLogsDuration-custom'
role='menuitemradio'
aria-checked={customRange !== null}
forceCloseOnSelect={true}
labels={<FormattedMessage {...messages.customRange}/>}
trailingElements={customRange !== null && <i className='icon icon-check'/>}
onClick={() => setShowCustomRangeModal(true)}
/>
</Menu.Container>
{showCustomRangeModal && (
<CustomRangeModal
initialRange={customRange}
onConfirm={(range) => {
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 && (
<div className='LogViewer__placeholder'>
{search || !allLevelsEnabled || selectedNode || activeTimePreset !== null ? (
{search || !allLevelsEnabled || selectedNode || timeRange !== null ? (
<FormattedMessage
id='admin.logs.noMatchingLogs'
defaultMessage='No logs match your filters. {clearLink}'
@@ -889,7 +955,7 @@ export default function LogList({
clearSearch();
enableAllLevels();
onSelectedNodeChange('');
onClearTimePreset();
onClearTimeRange();
}}
type='button'
>
@@ -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({});
@@ -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<number | null>(null);
const [timeRange, setTimeRange] = useState<TimeRange>(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 : (
<LogFormatMenu
@@ -270,10 +283,11 @@ export default function Logs({logs, logNodes, truncatedLogNodes, plainLogs, isPl
pollIntervals={POLL_INTERVALS}
pollIntervalLabels={POLL_INTERVAL_LABELS}
timePresets={TIME_PRESETS}
activeTimePreset={activeTimePreset}
timeRange={timeRange}
timeRangeStart={timeRangeStart}
onTimePreset={setActiveTimePreset}
onClearTimePreset={clearTimePreset}
onTimePreset={setTimeRange}
onCustomRange={applyCustomRange}
onClearTimeRange={clearTimeRange}
logFormatMenu={logFormatMenu}
/>
);
@@ -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;
@@ -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(
<DateTimeInput
{...baseProps}
time={moment.tz('2025-06-07T09:12:00Z', 'UTC')}
handleChange={handleChange}
allowPastDates={true}
manualTimeEntry={true}
/>,
);
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(
<DateTimeInput
{...baseProps}
time={moment.tz('2025-06-08T09:12:00Z', 'UTC')}
allowPastDates={true}
manualTimeEntry={true}
onTimeInputError={onTimeInputError}
/>,
);
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', () => {
@@ -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<TimeInputManualProps> = ({
@@ -126,14 +131,25 @@ const TimeInputManual: React.FC<TimeInputManualProps> = ({
timezone,
isMilitaryTime,
onTimeChange,
onTimeInputError,
minDateTime,
maxDateTime,
dayPickCount,
}) => {
const {formatMessage} = useIntl();
const [timeInputValue, setTimeInputValue] = useState<string>('');
const [timeInputError, setTimeInputError] = useState<boolean>(false);
const [timeInputError, setTimeInputErrorState] = useState<boolean>(false);
const timeInputRef = useRef<HTMLInputElement>(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<TimeInputManualProps> = ({
} else {
setTimeInputValue('');
}
}, [time, isMilitaryTime]);
setTimeInputError(false);
}, [time, isMilitaryTime, dayPickCount, setTimeInputError]);
const handleTimeInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
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<TimeInputManualProps> = ({
// 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<HTMLInputElement>) => {
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<Props> = ({
timePickerInterval,
allowPastDates = false,
manualTimeEntry = false,
onTimeInputError,
minDateTime,
maxDateTime,
}: Props) => {
@@ -254,6 +276,7 @@ const DateTimeInputContainer: React.FC<Props> = ({
const [isPopperOpen, setIsPopperOpen] = useState(false);
const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<string>('200px');
const [dayPickCount, setDayPickCount] = useState(0);
const {formatMessage} = useIntl();
const timeContainerRef = useRef<HTMLDivElement>(null);
@@ -363,10 +386,11 @@ const DateTimeInputContainer: React.FC<Props> = ({
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<Props> = ({
}
handleChange(result);
setDayPickCount((count) => count + 1);
handlePopperOpenState(false);
};
@@ -484,8 +509,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
timezone={timezone}
isMilitaryTime={isMilitaryTime}
onTimeChange={handleTimeChange}
onTimeInputError={onTimeInputError}
minDateTime={minDateTime}
maxDateTime={maxDateTime}
dayPickCount={dayPickCount}
/>
) : (
<Menu.Container
+32
View File
@@ -9591,6 +9591,30 @@
"defaultMessage": "Copy to clipboard",
"description": "Accessible label for the icon button that copies a field value of a log entry on the System Console Server Logs page."
},
"admin.logs.customRange.apply": {
"defaultMessage": "Apply",
"description": "Confirm button of the custom time range dialog on the System Console Server Logs page; applies the selected start and end as the log filter."
},
"admin.logs.customRange.errorOrder": {
"defaultMessage": "The start must be before the end.",
"description": "Error in the custom time range dialog on the System Console Server Logs page when the selected start is not before the selected end."
},
"admin.logs.customRange.from": {
"defaultMessage": "From",
"description": "Heading above the start date and time pickers in the custom time range dialog on the System Console Server Logs page; the range starts here."
},
"admin.logs.customRange.timezone": {
"defaultMessage": "Times are in your timezone: {timezone}",
"description": "Subheading of the custom time range dialog on the System Console Server Logs page. {timezone} is the timezone from the user display settings, e.g. \"(UTC-05:00) Eastern Time (US & Canada)\"."
},
"admin.logs.customRange.title": {
"defaultMessage": "Custom time range",
"description": "Title of the dialog for picking a start and end date and time to filter the System Console Server Logs page."
},
"admin.logs.customRange.to": {
"defaultMessage": "To",
"description": "Heading above the end date and time pickers in the custom time range dialog on the System Console Server Logs page; the range ends here."
},
"admin.logs.detail.caller": {
"defaultMessage": "Caller",
"description": "Field label in an expanded log entry on the System Console Server Logs page; the source file and line that wrote the log follows."
@@ -9723,6 +9747,10 @@
"defaultMessage": "Live tail",
"description": "Label of the live tail selector on the System Console Server Logs page; when on, new log entries are fetched automatically."
},
"admin.logs.liveTail.customRange": {
"defaultMessage": "Not available with a custom time range",
"description": "Explanation in the live tail menu on the System Console Server Logs page for why automatic refresh intervals are disabled while a custom time range with a fixed end is selected."
},
"admin.logs.liveTail.dropdownAriaLabel": {
"defaultMessage": "Live tail menu",
"description": "Accessible label for the live tail dropdown menu on the System Console Server Logs page."
@@ -9891,6 +9919,10 @@
"defaultMessage": "All time",
"description": "Option in the time range filter on the System Console Server Logs page; shows entries regardless of age."
},
"admin.logs.time.custom": {
"defaultMessage": "Custom range…",
"description": "Option in the time range filter on the System Console Server Logs page; opens a dialog to pick a start and end date and time."
},
"admin.logs.title": {
"defaultMessage": "Server Logs",
"description": "Page title of the Server Logs page in the System Console."
@@ -28,6 +28,12 @@ export const fakeDate = (expected: Date): () => 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());