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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user