New Logs Panel: Display nanoseconds if present (#109210)

* processing: display nanoseconds if present

* Nanoseconds: add control in log line menu

* virtualization: adapt to timestamp format

* Translations

* LogLine: update test

* virtualization: update test

* LogLineMenu: update test

* LogListControls: add third state for new logs panel timestamps

* Logs Panel: expose as a new panel option

* Translations

* Logs Panel: set timestamp resolution from panel config

* Module: add default value

* LogLine: update test

* Spelling

* chore: rename setter

* LogListControls: use custom button for resolution

* Translations

* Translations

* Prettier

* Logs Panel: conditionally show timestamp and details options

* Add integration test
This commit is contained in:
Matias Chomicki
2025-08-08 17:13:01 +00:00
committed by GitHub
parent 5e4dd1c907
commit f77e8839d9
17 changed files with 412 additions and 113 deletions
@@ -45,6 +45,7 @@ export interface Options {
showTime: boolean;
sortOrder: common.LogsSortOrder;
syntaxHighlighting?: boolean;
timestampResolution?: ('ms' | 'ns');
wrapLogMessage: boolean;
}
@@ -79,6 +79,38 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
expect(screen.getByText('log message 1')).toBeInTheDocument();
});
test('Renders a log line with millisecond timestamps', () => {
log.timestamp = '2025-08-06 11:35:19.504';
render(
<LogListContext.Provider
value={{
...defaultValue,
timestampResolution: 'ms',
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
);
expect(screen.getByText('2025-08-06 11:35:19.504')).toBeInTheDocument();
});
test('Renders a log line with nanosecond timestamps', () => {
log.timestamp = '2025-08-06 11:35:19.504';
log.timeEpochMs = 1754472919504;
log.timeEpochNs = '1754472919504133766';
render(
<LogListContext.Provider
value={{
...defaultValue,
timestampResolution: 'ns',
}}
>
<LogLine {...defaultProps} />
</LogListContext.Provider>
);
expect(screen.getByText('2025-08-06 11:35:19.504133766')).toBeInTheDocument();
});
test('Renders a log line with displayed fields', () => {
render(
<LogListContextProvider {...contextProps}>
@@ -99,6 +99,7 @@ const LogLineComponent = memo(
fontSize,
hasLogsWithErrors,
hasSampledLogs,
timestampResolution,
onLogLineHover,
} = useLogListContext();
const [collapsed, setCollapsed] = useState<boolean | undefined>(
@@ -211,6 +212,7 @@ const LogLineComponent = memo(
log={log}
showTime={showTime}
styles={styles}
timestampResolution={timestampResolution}
wrapLogMessage={wrapLogMessage}
/>
</div>
@@ -248,18 +250,25 @@ const LogLineComponent = memo(
);
LogLineComponent.displayName = 'LogLineComponent';
export type LogLineTimestampResolution = 'ms' | 'ns';
interface LogProps {
displayedFields: string[];
log: LogListModel;
showTime: boolean;
styles: LogLineStyles;
timestampResolution: LogLineTimestampResolution;
wrapLogMessage: boolean;
}
const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
const Log = memo(({ displayedFields, log, showTime, styles, timestampResolution, wrapLogMessage }: LogProps) => {
return (
<>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
{showTime && (
<span className={`${styles.timestamp} level-${log.logLevel} field`}>
{timestampResolution === 'ms' ? log.timestamp : log.timestampNs}
</span>
)}
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
}
@@ -134,23 +134,23 @@ export const LogLineMenu = ({ log, styles }: Props) => {
</Menu>
),
[
enableLogDetails,
toggleLogDetails,
copyLinkToLogLine,
copyLogLine,
detailsDisplayed,
enableLogDetails,
isAssistantAvailable,
log,
logLineMenuCustomItems,
onPermalinkClick,
onPinLine,
onUnpinLine,
openAssistantByLog,
pinned,
shouldlogSupportsContext,
showContext,
pinned,
onPinLine,
togglePinning,
onUnpinLine,
showFirstDivider,
copyLogLine,
onPermalinkClick,
copyLinkToLogLine,
logLineMenuCustomItems,
isAssistantAvailable,
openAssistantByLog,
toggleLogDetails,
togglePinning,
]
);
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder, store } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { createLogRow } from '../mocks/logRow';
@@ -36,6 +36,14 @@ jest.mock('../../utils', () => ({
enablePopoverMenu: jest.fn(),
}));
const originalFlagValue = config.featureToggles.newLogsPanel;
beforeAll(() => {
config.featureToggles.newLogsPanel = true;
});
afterAll(() => {
config.featureToggles.newLogsPanel = originalFlagValue;
});
describe('LogList', () => {
let logs: LogRowModel[], defaultProps: Props;
beforeEach(() => {
@@ -344,6 +352,25 @@ describe('LogList', () => {
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(screen.getByText('some text')).toBeInTheDocument();
});
test('Allows to toggle between ms and ns precision timestamps', async () => {
logs = [createLogRow({ uid: '1', timeEpochMs: 1754472919504, timeEpochNs: '1754472919504133766' })];
render(<LogList {...defaultProps} showTime showControls logs={logs} />);
expect(screen.getByText('2025-08-06 03:35:19.504')).toBeInTheDocument();
expect(screen.getByLabelText('Show nanosecond timestamps')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Show nanosecond timestamps'));
expect(screen.getByText('2025-08-06 03:35:19.504133766')).toBeInTheDocument();
expect(screen.getByLabelText('Hide timestamps')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Hide timestamps'));
expect(screen.queryByText(/2025-08-06 03:35:19/)).not.toBeInTheDocument();
expect(screen.getByLabelText('Show millisecond timestamps')).toBeInTheDocument();
});
});
describe('Interactions', () => {
beforeEach(() => {
@@ -24,7 +24,7 @@ import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll';
import { getGridTemplateColumns } from './LogLine';
import { getGridTemplateColumns, LogLineTimestampResolution } from './LogLine';
import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
@@ -80,6 +80,7 @@ export interface Props {
showTime: boolean;
sortOrder: LogsSortOrder;
timeRange: TimeRange;
timestampResolution?: LogLineTimestampResolution;
timeZone: string;
syntaxHighlighting?: boolean;
wrapLogMessage: boolean;
@@ -148,6 +149,7 @@ export const LogList = ({
sortOrder,
syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true,
timeRange,
timestampResolution,
timeZone,
wrapLogMessage,
}: Props) => {
@@ -189,6 +191,7 @@ export const LogList = ({
showTime={showTime}
sortOrder={sortOrder}
syntaxHighlighting={syntaxHighlighting}
timestampResolution={timestampResolution}
wrapLogMessage={wrapLogMessage}
>
<LogListSearchContextProvider>
@@ -241,6 +244,7 @@ const LogListComponent = ({
showDetails,
showTime,
sortOrder,
timestampResolution,
toggleDetails,
wrapLogMessage,
} = useLogListContext();
@@ -253,8 +257,11 @@ const LogListComponent = ({
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualization = useMemo(() => new LogLineVirtualization(theme, fontSize), [theme, fontSize]);
const dimensions = useMemo(
() => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, virtualization, wrapLogMessage]
() =>
wrapLogMessage
? []
: virtualization.calculateFieldDimensions(processedLogs, displayedFields, timestampResolution),
[displayedFields, processedLogs, timestampResolution, virtualization, wrapLogMessage]
);
const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime });
const widthContainer = wrapperRef.current ?? containerElement;
@@ -11,7 +11,12 @@ import {
useState,
} from 'react';
import { createContext as createAssistantContext, ItemDataType, useAssistant } from '@grafana/assistant';
import {
createContext as createAssistantContext,
ItemDataType,
OpenAssistantProps,
useAssistant,
} from '@grafana/assistant';
import {
CoreApp,
DataFrame,
@@ -30,6 +35,7 @@ import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import { getDisplayedFieldsForLogs } from '../otel/formats';
import { LogLineTimestampResolution } from './LogLine';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListFontSize } from './LogList';
@@ -62,8 +68,10 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
setShowTime: (showTime: boolean) => void;
setShowUniqueLabels: (showUniqueLabels: boolean) => void;
setSortOrder: (sortOrder: LogsSortOrder) => void;
setTimestampResolution: (format: LogLineTimestampResolution) => void;
setWrapLogMessage: (showTime: boolean) => void;
showDetails: LogListModel[];
timestampResolution: LogLineTimestampResolution;
toggleDetails: (log: LogListModel) => void;
isAssistantAvailable: boolean;
openAssistantByLog: ((log: LogListModel) => void) | undefined;
@@ -97,11 +105,13 @@ export const LogListContext = createContext<LogListContextData>({
setShowUniqueLabels: () => {},
setSortOrder: () => {},
setSyntaxHighlighting: () => {},
setTimestampResolution: () => {},
setWrapLogMessage: () => {},
showDetails: [],
showTime: true,
sortOrder: LogsSortOrder.Ascending,
syntaxHighlighting: true,
timestampResolution: 'ns',
toggleDetails: () => {},
wrapLogMessage: false,
isAssistantAvailable: false,
@@ -139,6 +149,7 @@ export type LogListState = Pick<
| 'showTime'
| 'sortOrder'
| 'syntaxHighlighting'
| 'timestampResolution'
| 'wrapLogMessage'
>;
@@ -183,6 +194,7 @@ export interface Props {
showTime: boolean;
sortOrder: LogsSortOrder;
syntaxHighlighting?: boolean;
timestampResolution?: LogLineTimestampResolution;
wrapLogMessage: boolean;
}
@@ -226,6 +238,9 @@ export const LogListContextProvider = ({
showUniqueLabels,
sortOrder,
syntaxHighlighting,
timestampResolution = logOptionsStorageKey
? (store.get(`${logOptionsStorageKey}.timestampResolution`) ?? 'ms')
: 'ms',
wrapLogMessage,
}: Props) => {
const [logListState, setLogListState] = useState<LogListState>({
@@ -240,6 +255,7 @@ export const LogListContextProvider = ({
showUniqueLabels,
sortOrder,
syntaxHighlighting,
timestampResolution,
wrapLogMessage,
});
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
@@ -247,47 +263,6 @@ export const LogListContextProvider = ({
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
const [isAssistantAvailable, openAssistant] = useAssistant();
const openAssistantByLog = useCallback(
async (log: LogListModel) => {
if (!openAssistant) {
return;
}
const datasource = await getDataSourceSrv().get(log.datasourceUid);
const context = [];
if (datasource) {
context.push(
createAssistantContext(ItemDataType.Datasource, {
datasourceUid: datasource.uid,
datasourceName: datasource.name,
datasourceType: datasource.type,
img: datasource.meta?.info?.logos?.small,
})
);
}
openAssistant({
prompt: `${t('logs.log-line-menu.log-line-explainer', 'Explain this log line in a concise way')}:
\`\`\`
${log.entry.replaceAll('`', '\\`')}
\`\`\`
`,
context: [
...context,
createAssistantContext(ItemDataType.Structured, {
title: t('logs.log-line-menu.log-line', 'Log line'),
data: {
labels: log.labels,
value: log.entry,
timestamp: log.timestamp,
},
}),
],
});
},
[openAssistant]
);
useEffect(() => {
if (noInteractions) {
return;
@@ -302,11 +277,13 @@ ${log.entry.replaceAll('`', '\\`')}
detailsWidth,
detailsMode,
withDisplayedFields: displayedFields.length > 0,
timestampResolution: logListState.timestampResolution,
});
// Just once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// OTel displayed fields
useEffect(() => {
if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) {
return;
@@ -317,6 +294,7 @@ ${log.entry.replaceAll('`', '\\`')}
}
}, [displayedFields.length, logs, setDisplayedFields]);
// Sync state
useEffect(() => {
// Props are updated in the context only of the panel is being externally controlled.
if (showControls && app !== CoreApp.PanelEditor) {
@@ -345,6 +323,7 @@ ${log.entry.replaceAll('`', '\\`')}
wrapLogMessage,
]);
// Sync filter levels
useEffect(() => {
if (filterLevels === undefined) {
return;
@@ -357,16 +336,19 @@ ${log.entry.replaceAll('`', '\\`')}
});
}, [filterLevels]);
// Sync font size
useEffect(() => {
setLogListState((logListState) => ({ ...logListState, fontSize }));
}, [fontSize]);
// Sync pinned logs
useEffect(() => {
if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) {
setLogListState({ ...logListState, pinnedLogs });
}
}, [logListState, pinnedLogs]);
// Sync show details
useEffect(() => {
if (!showDetails.length) {
return;
@@ -379,6 +361,7 @@ ${log.entry.replaceAll('`', '\\`')}
}
}, [logs, showDetails]);
// Sync log details width
useEffect(() => {
const handleResize = debounce(() => {
setDetailsWidthState((detailsWidth) => getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth));
@@ -390,6 +373,14 @@ ${log.entry.replaceAll('`', '\\`')}
};
}, [containerElement, logOptionsStorageKey]);
// Sync timestamp resolution
useEffect(() => {
setLogListState((state) => ({
...state,
timestampResolution,
}));
}, [timestampResolution]);
const detailsDisplayed = useCallback(
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
[showDetails]
@@ -438,10 +429,16 @@ ${log.entry.replaceAll('`', '\\`')}
const setShowTime = useCallback(
(showTime: boolean) => {
setLogListState({ ...logListState, showTime });
const newTimestampFormat = showTime === false ? 'ms' : logListState.timestampResolution;
setLogListState({
...logListState,
showTime,
timestampResolution: newTimestampFormat,
});
onLogOptionsChange?.('showTime', showTime);
if (logOptionsStorageKey) {
store.set(`${logOptionsStorageKey}.showTime`, showTime);
store.set(`${logOptionsStorageKey}.timestampResolution`, newTimestampFormat);
}
},
[logListState, logOptionsStorageKey, onLogOptionsChange]
@@ -552,6 +549,29 @@ ${log.entry.replaceAll('`', '\\`')}
[containerElement, logOptionsStorageKey]
);
const setTimestampResolution = useCallback(
(timestampResolution: LogLineTimestampResolution) => {
if (logOptionsStorageKey) {
store.set(`${logOptionsStorageKey}.timestampResolution`, timestampResolution);
}
setLogListState((state) => ({
...state,
timestampResolution,
}));
},
[logOptionsStorageKey]
);
const openAssistantByLog = useCallback(
(log: LogListModel) => {
if (!openAssistant) {
return;
}
handleOpenAssistant(openAssistant, log);
},
[openAssistant]
);
const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]);
const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]);
const hasUnescapedContent = useMemo(() => logs.some((r) => r.hasUnescapedContent), [logs]);
@@ -609,12 +629,14 @@ ${log.entry.replaceAll('`', '\\`')}
setShowUniqueLabels,
setSortOrder,
setSyntaxHighlighting,
setTimestampResolution,
setWrapLogMessage,
showDetails,
showTime: logListState.showTime,
showUniqueLabels: logListState.showUniqueLabels,
sortOrder: logListState.sortOrder,
syntaxHighlighting: logListState.syntaxHighlighting,
timestampResolution: logListState.timestampResolution,
toggleDetails,
wrapLogMessage: logListState.wrapLogMessage,
isAssistantAvailable,
@@ -686,3 +708,37 @@ const reportInteractionOnce = (interactionName: string, properties?: Record<stri
sessionStorage.setItem(key, '1');
reportInteraction(interactionName, properties);
};
async function handleOpenAssistant(openAssistant: (props: OpenAssistantProps) => void, log: LogListModel) {
const datasource = await getDataSourceSrv().get(log.datasourceUid);
const context = [];
if (datasource) {
context.push(
createAssistantContext(ItemDataType.Datasource, {
datasourceUid: datasource.uid,
datasourceName: datasource.name,
datasourceType: datasource.type,
img: datasource.meta?.info?.logos?.small,
})
);
}
openAssistant({
prompt: `${t('logs.log-line-menu.log-line-explainer', 'Explain this log line in a concise way')}:
\`\`\`
${log.entry.replaceAll('`', '\\`')}
\`\`\`
`,
context: [
...context,
createAssistantContext(ItemDataType.Structured, {
title: t('logs.log-line-menu.log-line', 'Log line'),
data: {
labels: log.labels,
value: log.entry,
timestamp: log.timestamp,
},
}),
],
});
}
@@ -6,11 +6,12 @@ import { CoreApp, EventBus, LogLevel, LogsDedupDescription, LogsDedupStrategy, L
import { GrafanaTheme2 } from '@grafana/data/';
import { t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui';
import { Dropdown, Icon, IconButton, Menu, Tooltip, useStyles2 } from '@grafana/ui';
import { LogsVisualisationType } from '../../../explore/Logs/Logs';
import { DownloadFormat } from '../../utils';
import { LogLineTimestampResolution } from './LogLine';
import { useLogListContext } from './LogListContext';
import { useLogListSearchContext } from './LogListSearchContext';
import { ScrollToLogsEvent } from './virtualization';
@@ -280,18 +281,22 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/>
</Dropdown>
<div className={styles.divider} />
<IconButton
name="clock-nine"
aria-pressed={showTime}
className={showTime ? styles.controlButtonActive : styles.controlButton}
onClick={onShowTimestampsClick}
tooltip={
showTime
? t('logs.logs-controls.hide-timestamps', 'Hide timestamps')
: t('logs.logs-controls.show-timestamps', 'Show timestamps')
}
size="lg"
/>
{config.featureToggles.newLogsPanel ? (
<TimestampResolutionButton />
) : (
<IconButton
name="clock-nine"
aria-pressed={showTime}
className={showTime ? styles.controlButtonActive : styles.controlButton}
onClick={onShowTimestampsClick}
tooltip={
showTime
? t('logs.logs-controls.hide-timestamps', 'Hide timestamps')
: t('logs.logs-controls.show-timestamps', 'Show timestamps')
}
size="lg"
/>
)}
{/* When this is used in a Plugin context, app is unknown */}
{showUniqueLabels !== undefined && app !== CoreApp.Unknown && (
<IconButton
@@ -451,6 +456,47 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
);
};
const TimestampResolutionButton = () => {
const styles = useStyles2(getStyles);
const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext();
const onShowTimestampsClick = useCallback(() => {
if (!config.featureToggles.newLogsPanel) {
reportInteraction('logs_log_list_controls_show_time_clicked', {
show_time: !showTime,
});
setShowTime(!showTime);
return;
}
if (!showTime || timestampResolution === 'ns') {
setShowTime(!showTime);
} else if (timestampResolution === 'ms') {
setTimestampResolution('ns');
}
}, [setShowTime, setTimestampResolution, showTime, timestampResolution]);
return (
<Tooltip content={getTimestampTooltip(showTime, timestampResolution)}>
<button
aria-label={getTimestampTooltip(showTime, timestampResolution)}
aria-pressed={showTime}
className={`${styles.timestampResolutionButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
type="button"
onClick={onShowTimestampsClick}
>
<Icon name="clock-nine" size="lg" className={styles.timestampResolutionIcon} />
{showTime && (
<span className={styles.resolutionText}>
{timestampResolution === 'ms'
? t('logs.logs-controls.resolution-ms', 'ms')
: t('logs.logs-controls.resolution-ns', 'ns')}
</span>
)}
</button>
</Tooltip>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
navContainer: css({
@@ -510,5 +556,41 @@ const getStyles = (theme: GrafanaTheme2) => {
backgroundColor: theme.colors.warning.main,
},
}),
timestampResolutionButton: css({
position: 'relative',
zIndex: 0,
margin: 0,
boxShadow: 'none',
border: 'none',
display: 'inline-flex',
background: 'transparent',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
overflow: 'visible',
}),
timestampResolutionIcon: css({
verticalAlign: 'baseline',
}),
resolutionText: css({
color: theme.colors.text.primary,
fontSize: 10,
position: 'absolute',
bottom: -4,
right: 0,
lineHeight: '10px',
backgroundColor: theme.colors.background.elevated,
paddingLeft: 2,
}),
};
};
function getTimestampTooltip(showTime: boolean, timestampResolution: LogLineTimestampResolution) {
if (!showTime) {
return t('logs.logs-controls.show-ms-timestamps', 'Show millisecond timestamps');
}
if (timestampResolution === 'ms') {
return t('logs.logs-controls.show-ns-timestamps', 'Show nanosecond timestamps');
}
return t('logs.logs-controls.hide-timestamps', 'Hide timestamps');
}
@@ -39,11 +39,13 @@ export const LogListContext = createContext<LogListContextData>({
setShowUniqueLabels: () => {},
setSortOrder: () => {},
setSyntaxHighlighting: () => {},
setTimestampResolution: () => {},
setWrapLogMessage: () => {},
showDetails: [],
showTime: true,
sortOrder: LogsSortOrder.Ascending,
syntaxHighlighting: true,
timestampResolution: 'ns',
toggleDetails: () => {},
wrapLogMessage: false,
detailsMode: 'sidebar',
@@ -87,6 +89,7 @@ export const defaultValue: LogListContextData = {
setSortOrder: jest.fn(),
setPrettifyJSON: jest.fn(),
setSyntaxHighlighting: jest.fn(),
setTimestampResolution: jest.fn(),
setWrapLogMessage: jest.fn(),
closeDetails: jest.fn(),
detailsDisplayed: jest.fn(),
@@ -108,6 +111,7 @@ export const defaultValue: LogListContextData = {
wrapLogMessage: false,
isAssistantAvailable: false,
openAssistantByLog: () => {},
timestampResolution: 'ns',
};
export const defaultProps: Props = {
@@ -130,6 +134,7 @@ export const defaultProps: Props = {
showTime: true,
sortOrder: LogsSortOrder.Descending,
syntaxHighlighting: true,
timestampResolution: 'ms',
wrapLogMessage: true,
};
@@ -155,6 +160,7 @@ export const LogListContextProvider = ({
showTime = true,
sortOrder = LogsSortOrder.Descending,
syntaxHighlighting = true,
timestampResolution = 'ms',
wrapLogMessage = true,
}: Partial<Props> & { showDetails?: LogListModel[] }) => {
const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log));
@@ -198,6 +204,7 @@ export const LogListContextProvider = ({
showTime,
sortOrder,
syntaxHighlighting,
timestampResolution,
wrapLogMessage,
}}
>
@@ -2,7 +2,16 @@ import ansicolor from 'ansicolor';
import { parse, stringify } from 'lossless-json';
import Prism, { Grammar } from 'prismjs';
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder, textUtil } from '@grafana/data';
import {
DataFrame,
dateTimeFormat,
Labels,
LogLevel,
LogRowModel,
LogsSortOrder,
systemDateFormats,
textUtil,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
@@ -90,7 +99,8 @@ export class LogListModel implements LogRowModel {
this._grammar = grammar;
this.timestamp = dateTimeFormat(log.timeEpochMs, {
timeZone,
defaultWithMS: true,
// YYYY-MM-DD HH:mm:ss.SSS
format: systemDateFormats.fullDateMS,
});
this._virtualization = virtualization;
this._wrapLogMessage = wrapLogMessage;
@@ -158,6 +168,11 @@ export class LogListModel implements LogRowModel {
return checkLogsSampled(this);
}
get timestampNs(): string {
let suffix = this.timeEpochNs.substring(this.timeEpochMs.toString().length);
return this.timestamp + suffix;
}
getDisplayedFieldValue(fieldName: string, stripAnsi = false): string {
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return stripAnsi ? ansicolor.strip(this.body) : this.body;
@@ -234,7 +249,14 @@ export const preProcessLogs = (
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) =>
preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage })
preProcessLog(log, {
escape,
getFieldLinks,
grammar,
timeZone,
virtualization,
wrapLogMessage,
})
);
};
@@ -282,7 +282,7 @@ describe('Virtualization', () => {
describe('calculateFieldDimensions', () => {
test('Measures displayed fields including the log line body', () => {
expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME])).toEqual([
expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME], 'ms')).toEqual([
{
field: 'timestamp',
width: 23,
@@ -301,6 +301,19 @@ describe('Virtualization', () => {
},
]);
});
test('Measures nanosecond timestamps', () => {
expect(virtualization.calculateFieldDimensions([log], [], 'ns')).toEqual([
{
field: 'timestamp',
width: 29,
},
{
field: 'level',
width: 4,
},
]);
});
});
describe('With small font size', () => {
@@ -2,6 +2,7 @@ import ansicolor from 'ansicolor';
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { LogLineTimestampResolution } from './LogLine';
import { LOG_LINE_DETAILS_HEIGHT, LogLineDetailsMode } from './LogLineDetails';
import { LogListFontSize } from './LogList';
import { LogListModel } from './processing';
@@ -176,7 +177,11 @@ export class LogLineVirtualization {
};
};
calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => {
calculateFieldDimensions = (
logs: LogListModel[],
displayedFields: string[] = [],
timestampResolution: LogLineTimestampResolution
) => {
if (!logs.length) {
return [];
}
@@ -184,7 +189,7 @@ export class LogLineVirtualization {
let levelWidth = 0;
const fieldWidths: Record<string, number> = {};
for (let i = 0; i < logs.length; i++) {
let width = this.measureTextWidth(logs[i].timestamp);
let width = this.measureTextWidth(timestampResolution === 'ms' ? logs[i].timestamp : logs[i].timestampNs);
if (width > timestampWidth) {
timestampWidth = Math.round(width);
}
@@ -125,6 +125,9 @@ interface LogsPanelProps extends PanelProps<Options> {
*
* Set the mode used by the Log Details panel. Displayed as a sidebar, or inline below the log line. Defaults to "inline".
* detailsMode?: 'inline' | 'sidebar'
*
* When showing timestamps, toggle between showing nanoseconds or milliseconds.
* timestampResolution?: 'ms' | 'ns'
*/
}
interface LogsPermalinkUrlState {
@@ -166,6 +169,7 @@ export const LogsPanel = ({
syntaxHighlighting,
detailsMode: detailsModeProp,
noInteractions,
timestampResolution,
...options
},
id,
@@ -607,6 +611,7 @@ export const LogsPanel = ({
logOptionsStorageKey={storageKey}
syntaxHighlighting={syntaxHighlighting}
timeRange={data.timeRange}
timestampResolution={timestampResolution}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
+52 -32
View File
@@ -7,11 +7,11 @@ import { Options } from './panelcfg.gen';
import { LogsPanelSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(LogsPanel)
.setPanelOptions((builder) => {
.setPanelOptions((builder, context) => {
const category = [t('logs.category-logs', 'Logs')];
builder.addBooleanSwitch({
path: 'showTime',
name: t('logs.name-time', 'Time'),
name: t('logs.name-time', 'Show timestamps'),
category,
description: '',
defaultValue: false,
@@ -33,6 +33,23 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
description: '',
defaultValue: false,
});
} else if (context.options?.showTime) {
builder.addRadio({
path: 'timestampResolution',
name: t('logs.timestamp-format', 'Timestamp resolution'),
category,
description: '',
defaultValue: 'ms',
settings: {
options: [
{ value: 'ms', label: t('logs.logs.timestamp-resolution.label-milliseconds', 'Milliseconds') },
{
value: 'ns',
label: t('logs.logs.timestamp-resolution.label-nanoseconds', 'Nanoseconds'),
},
],
},
});
}
builder.addBooleanSwitch({
@@ -63,24 +80,42 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
});
}
builder
.addBooleanSwitch({
path: 'enableLogDetails',
name: t('logs.name-enable-log-details', 'Enable log details'),
builder.addBooleanSwitch({
path: 'enableLogDetails',
name: t('logs.name-enable-log-details', 'Enable log details'),
category,
description: '',
defaultValue: true,
});
if (config.featureToggles.newLogsPanel && context.options?.enableLogDetails) {
builder.addRadio({
path: 'detailsMode',
name: t('logs.name-details-mode', 'Log Details panel mode'),
category,
description: '',
defaultValue: true,
})
.addBooleanSwitch({
path: 'enableInfiniteScrolling',
name: t('logs.name-enable-infinite-scrolling', 'Enable infinite scrolling'),
category,
description: t(
'logs.description-enable-infinite-scrolling',
'Experimental. Request more results by scrolling to the bottom of the logs list.'
),
defaultValue: false,
settings: {
options: [
{ value: 'inline', label: t('logs.name-details-options.label-inline', 'Inline') },
{
value: 'sidebar',
label: t('logs.name-details-options.label-sidebar', 'Sidebar'),
},
],
},
});
}
builder.addBooleanSwitch({
path: 'enableInfiniteScrolling',
name: t('logs.name-enable-infinite-scrolling', 'Enable infinite scrolling'),
category,
description: t(
'logs.description-enable-infinite-scrolling',
'Experimental. Request more results by scrolling to the bottom of the logs list.'
),
defaultValue: false,
});
if (config.featureToggles.newLogsPanel) {
builder
@@ -108,21 +143,6 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
},
],
},
})
.addRadio({
path: 'detailsMode',
name: t('logs.name-details-mode', 'Log Details panel mode'),
category,
description: '',
settings: {
options: [
{ value: 'inline', label: t('logs.name-details-options.label-inline', 'Inline') },
{
value: 'sidebar',
label: t('logs.name-details-options.label-sidebar', 'Sidebar'),
},
],
},
});
}
@@ -42,6 +42,7 @@ composableKinds: PanelCfg: {
noInteractions?: bool
fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")
// TODO: figure out how to define callbacks
onClickFilterLabel?: _
onClickFilterOutLabel?: _
@@ -43,6 +43,7 @@ export interface Options {
showTime: boolean;
sortOrder: common.LogsSortOrder;
syntaxHighlighting?: boolean;
timestampResolution?: ('ms' | 'ns');
wrapLogMessage: boolean;
}
+12 -1
View File
@@ -9543,6 +9543,12 @@
"total-bytes-processed": "Total bytes processed"
}
},
"logs": {
"timestamp-resolution": {
"label-milliseconds": "Milliseconds",
"label-nanoseconds": "Nanoseconds"
}
},
"logs-controls": {
"deduplication": "Deduplication",
"disable-highlighting": "Disable highlighting",
@@ -9566,8 +9572,12 @@
"oldest-first": "Sorted by oldest logs first - Click to show newest first",
"prettify-json": "Expand JSON logs",
"remove-escaping": "Remove escaping",
"resolution-ms": "ms",
"resolution-ns": "ns",
"scroll-bottom": "Scroll to bottom",
"scroll-top": "Scroll to top",
"show-ms-timestamps": "Show millisecond timestamps",
"show-ns-timestamps": "Show nanosecond timestamps",
"show-search": "Search in logs result",
"show-timestamps": "Show timestamps",
"show-unique-labels": "Show unique labels",
@@ -9600,7 +9610,7 @@
"name-order": "Order",
"name-prettify-json": "Prettify JSON",
"name-show-controls": "Show controls",
"name-time": "Time",
"name-time": "Show timestamps",
"name-unique-labels": "Unique labels",
"name-wrap-lines": "Wrap lines",
"order-options": {
@@ -9616,6 +9626,7 @@
"line-contains": "Add as line contains filter",
"line-contains-not": "Add as line does not contain filter"
},
"timestamp-format": "Timestamp resolution",
"un-themed-log-details": {
"aria-label-data-links": "Data links",
"aria-label-fields": "Fields",