feat: add new expanded state to log options menu (#110725)
* feat: add new expanded state to log options menu
This commit is contained in:
@@ -1011,6 +1011,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
hasData && (
|
hasData && (
|
||||||
<div className={styles.logRowsWrapper} data-testid="logRows">
|
<div className={styles.logRowsWrapper} data-testid="logRows">
|
||||||
<ControlledLogRows
|
<ControlledLogRows
|
||||||
|
ref={logsContainerRef}
|
||||||
logsTableFrames={props.logsFrames}
|
logsTableFrames={props.logsFrames}
|
||||||
width={width}
|
width={width}
|
||||||
updatePanelState={updatePanelState}
|
updatePanelState={updatePanelState}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
@@ -8,7 +8,7 @@ import { LogsTableWrap } from '../../explore/Logs/LogsTableWrap';
|
|||||||
|
|
||||||
import { LogRowsComponentProps } from './ControlledLogRows';
|
import { LogRowsComponentProps } from './ControlledLogRows';
|
||||||
import { useLogListContext } from './panel/LogListContext';
|
import { useLogListContext } from './panel/LogListContext';
|
||||||
import { LogListControls } from './panel/LogListControls';
|
import { CONTROLS_WIDTH, CONTROLS_WIDTH_EXPANDED, LogListControls } from './panel/LogListControls';
|
||||||
|
|
||||||
export const ControlledLogsTable = ({
|
export const ControlledLogsTable = ({
|
||||||
loading,
|
loading,
|
||||||
@@ -26,8 +26,9 @@ export const ControlledLogsTable = ({
|
|||||||
visualisationType,
|
visualisationType,
|
||||||
...rest
|
...rest
|
||||||
}: LogRowsComponentProps) => {
|
}: LogRowsComponentProps) => {
|
||||||
const { sortOrder } = useLogListContext();
|
const { sortOrder, controlsExpanded } = useLogListContext();
|
||||||
const eventBus = useMemo(() => new EventBusSrv(), []);
|
const eventBus = useMemo(() => new EventBusSrv(), []);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
@@ -37,8 +38,11 @@ export const ControlledLogsTable = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableWidthExpandedControls = width - (CONTROLS_WIDTH_EXPANDED + 12);
|
||||||
|
const tableWidth = width - (CONTROLS_WIDTH + 12);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.logRowsContainer}>
|
<div ref={ref} className={styles.logRowsContainer}>
|
||||||
<LogListControls eventBus={eventBus} visualisationType={visualisationType} />
|
<LogListControls eventBus={eventBus} visualisationType={visualisationType} />
|
||||||
<div className={styles.logRows} data-testid="logRowsTable">
|
<div className={styles.logRows} data-testid="logRowsTable">
|
||||||
{/* Width should be full width minus logs navigation and padding */}
|
{/* Width should be full width minus logs navigation and padding */}
|
||||||
@@ -47,7 +51,7 @@ export const ControlledLogsTable = ({
|
|||||||
range={range}
|
range={range}
|
||||||
splitOpen={splitOpen}
|
splitOpen={splitOpen}
|
||||||
timeZone={rest.timeZone}
|
timeZone={rest.timeZone}
|
||||||
width={width - 45}
|
width={controlsExpanded ? tableWidthExpandedControls : tableWidth}
|
||||||
logsFrames={logsTableFrames ?? []}
|
logsFrames={logsTableFrames ?? []}
|
||||||
onClickFilterLabel={onClickFilterLabel}
|
onClickFilterLabel={onClickFilterLabel}
|
||||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from '
|
|||||||
|
|
||||||
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
|
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
|
||||||
closeDetails: () => void;
|
closeDetails: () => void;
|
||||||
|
controlsExpanded: boolean;
|
||||||
detailsDisplayed: (log: LogListModel) => boolean;
|
detailsDisplayed: (log: LogListModel) => boolean;
|
||||||
detailsMode: LogLineDetailsMode;
|
detailsMode: LogLineDetailsMode;
|
||||||
detailsWidth: number;
|
detailsWidth: number;
|
||||||
@@ -51,6 +52,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
|
|||||||
hasSampledLogs?: boolean;
|
hasSampledLogs?: boolean;
|
||||||
hasUnescapedContent: boolean;
|
hasUnescapedContent: boolean;
|
||||||
logLineMenuCustomItems?: LogLineMenuCustomItem[];
|
logLineMenuCustomItems?: LogLineMenuCustomItem[];
|
||||||
|
setControlsExpanded: (expanded: boolean) => void;
|
||||||
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
||||||
setDetailsMode: (mode: LogLineDetailsMode) => void;
|
setDetailsMode: (mode: LogLineDetailsMode) => void;
|
||||||
setDetailsWidth: (width: number) => void;
|
setDetailsWidth: (width: number) => void;
|
||||||
@@ -76,6 +78,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
|
|||||||
export const LogListContext = createContext<LogListContextData>({
|
export const LogListContext = createContext<LogListContextData>({
|
||||||
app: CoreApp.Unknown,
|
app: CoreApp.Unknown,
|
||||||
closeDetails: () => {},
|
closeDetails: () => {},
|
||||||
|
controlsExpanded: false,
|
||||||
dedupStrategy: LogsDedupStrategy.none,
|
dedupStrategy: LogsDedupStrategy.none,
|
||||||
detailsDisplayed: () => false,
|
detailsDisplayed: () => false,
|
||||||
detailsMode: 'sidebar',
|
detailsMode: 'sidebar',
|
||||||
@@ -88,6 +91,7 @@ export const LogListContext = createContext<LogListContextData>({
|
|||||||
fontSize: 'default',
|
fontSize: 'default',
|
||||||
hasUnescapedContent: false,
|
hasUnescapedContent: false,
|
||||||
noInteractions: false,
|
noInteractions: false,
|
||||||
|
setControlsExpanded: () => {},
|
||||||
setDedupStrategy: () => {},
|
setDedupStrategy: () => {},
|
||||||
setDetailsMode: () => {},
|
setDetailsMode: () => {},
|
||||||
setDetailsWidth: () => {},
|
setDetailsWidth: () => {},
|
||||||
@@ -393,6 +397,13 @@ export const LogListContextProvider = ({
|
|||||||
}));
|
}));
|
||||||
}, [timestampResolution]);
|
}, [timestampResolution]);
|
||||||
|
|
||||||
|
const controlsExpandedFromStore = store.getBool(
|
||||||
|
`${logOptionsStorageKey}.controlsExpanded`,
|
||||||
|
getDefaultControlsExpandedMode(containerElement ?? null)
|
||||||
|
);
|
||||||
|
// If the user has a large viewport, show the expanded state by default
|
||||||
|
const [controlsExpanded, setControlsExpanded] = useState<boolean>(controlsExpandedFromStore);
|
||||||
|
|
||||||
const detailsDisplayed = useCallback(
|
const detailsDisplayed = useCallback(
|
||||||
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
|
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
|
||||||
[showDetails]
|
[showDetails]
|
||||||
@@ -594,6 +605,7 @@ export const LogListContextProvider = ({
|
|||||||
value={{
|
value={{
|
||||||
app,
|
app,
|
||||||
closeDetails,
|
closeDetails,
|
||||||
|
controlsExpanded,
|
||||||
detailsDisplayed,
|
detailsDisplayed,
|
||||||
dedupStrategy: logListState.dedupStrategy,
|
dedupStrategy: logListState.dedupStrategy,
|
||||||
detailsMode,
|
detailsMode,
|
||||||
@@ -628,6 +640,7 @@ export const LogListContextProvider = ({
|
|||||||
pinLineButtonTooltipTitle,
|
pinLineButtonTooltipTitle,
|
||||||
pinnedLogs: logListState.pinnedLogs,
|
pinnedLogs: logListState.pinnedLogs,
|
||||||
prettifyJSON,
|
prettifyJSON,
|
||||||
|
setControlsExpanded,
|
||||||
setDedupStrategy,
|
setDedupStrategy,
|
||||||
setDetailsMode,
|
setDetailsMode,
|
||||||
setDetailsWidth,
|
setDetailsWidth,
|
||||||
@@ -754,3 +767,8 @@ export function getDefaultDetailsMode(container: HTMLDivElement | undefined): Lo
|
|||||||
const width = container?.clientWidth ?? window.innerWidth;
|
const width = container?.clientWidth ?? window.innerWidth;
|
||||||
return width > 1440 ? 'sidebar' : 'inline';
|
return width > 1440 ? 'sidebar' : 'inline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultControlsExpandedMode(container: HTMLDivElement | null): boolean {
|
||||||
|
const width = container?.clientWidth ?? window.innerWidth;
|
||||||
|
return width > 1200;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,35 @@ import { LogListContextProvider } from './LogListContext';
|
|||||||
import { LogListControls } from './LogListControls';
|
import { LogListControls } from './LogListControls';
|
||||||
import { ScrollToLogsEvent } from './virtualization';
|
import { ScrollToLogsEvent } from './virtualization';
|
||||||
|
|
||||||
|
const FILTER_LEVELS_LABEL_COPY = 'Filter levels';
|
||||||
|
const SCROLL_BOTTOM_LABEL_COPY = 'Scroll to bottom';
|
||||||
|
const SCROLL_TOP_LABEL_COPY = 'Scroll to top';
|
||||||
|
const OLDEST_LOGS_LABEL_COPY = 'Oldest logs first';
|
||||||
|
const DEDUPE_LABEL_COPY = 'Deduplication';
|
||||||
|
const SHOW_TIMESTAMP_LABEL_COPY = 'Show timestamps';
|
||||||
|
const WRAP_LINES_LABEL_COPY = 'Wrap lines';
|
||||||
|
const WRAP_JSON_TOOLTIP_COPY = 'Enable line wrapping and prettify JSON';
|
||||||
|
const WRAP_JSON_LABEL_COPY = 'Wrap JSON';
|
||||||
|
const WRAP_DISABLE_LABEL_COPY = 'Disable line wrapping';
|
||||||
|
const ENABLE_HIGHLIGHTING_LABEL_COPY = 'Enable highlighting';
|
||||||
|
const EXPANDED_LABEL_COPY = 'Expanded';
|
||||||
|
const COLLAPSED_LABEL_COPY = 'Collapsed';
|
||||||
|
const SHOW_UNIQUE_LABELS_LABEL_COPY = 'Show unique labels';
|
||||||
|
const HIDE_UNIQUE_LABELS_LABEL_COPY = 'Hide unique labels';
|
||||||
|
const EXPAND_JSON_LOGS_LABEL_COPY = 'Expand JSON logs';
|
||||||
|
const COLLAPSE_JSON_LOGS_LABEL_COPY = 'Collapse JSON logs';
|
||||||
|
const ESCAPE_NEWLINES_TOOLTIP_COPY = 'Fix incorrectly escaped newline and tab sequences in log lines';
|
||||||
|
const REMOVE_ESCAPE_NEWLINES_LABEL_COPY = 'Remove escaping';
|
||||||
|
const TIMESTAMP_LABEL_COPY = 'Log timestamps';
|
||||||
|
const TIMESTAMP_HIDE_LABEL_COPY = 'Hide timestamps';
|
||||||
|
const FONT_SIZE_LARGE_LABEL_COPY = 'Large font';
|
||||||
|
const FONT_SIZE_LARGE_TOOLTIP_COPY = 'Set large font';
|
||||||
|
const FONT_SIZE_SMALL_LABEL_COPY = 'Small font';
|
||||||
|
const FONT_SIZE_SMALL_TOOLTIP_COPY = 'Set small font';
|
||||||
|
const DOWNLOAD_LOGS_LABEL_COPY = 'Download logs';
|
||||||
|
|
||||||
|
const OLDEST_LOGS_LABEL_REGEX = /oldest logs first/;
|
||||||
|
|
||||||
jest.mock('../../utils', () => ({
|
jest.mock('../../utils', () => ({
|
||||||
...jest.requireActual('../../utils'),
|
...jest.requireActual('../../utils'),
|
||||||
downloadLogs: jest.fn(),
|
downloadLogs: jest.fn(),
|
||||||
@@ -42,6 +71,13 @@ const contextProps = {
|
|||||||
openAssistantByLog: () => {},
|
openAssistantByLog: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assertExpandedOptionsCopyVisible = () => {
|
||||||
|
expect(screen.getByText(EXPANDED_LABEL_COPY)).toBeVisible();
|
||||||
|
expect(screen.getByText(SCROLL_BOTTOM_LABEL_COPY)).toBeVisible();
|
||||||
|
expect(screen.getByText(OLDEST_LOGS_LABEL_COPY)).toBeVisible();
|
||||||
|
expect(screen.getByText(DEDUPE_LABEL_COPY)).toBeVisible();
|
||||||
|
expect(screen.getByText(SCROLL_TOP_LABEL_COPY)).toBeVisible();
|
||||||
|
};
|
||||||
describe('LogListControls', () => {
|
describe('LogListControls', () => {
|
||||||
test('Renders without errors', () => {
|
test('Renders without errors', () => {
|
||||||
render(
|
render(
|
||||||
@@ -49,20 +85,18 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/oldest logs first/)).toBeInTheDocument();
|
expect(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument();
|
expect(screen.getByLabelText(DEDUPE_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument();
|
expect(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument();
|
expect(screen.getByLabelText(WRAP_LINES_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument();
|
expect(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(
|
expect(screen.queryByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY)).not.toBeInTheDocument();
|
||||||
screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')
|
expect(screen.queryByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renders legacy controls', () => {
|
test('Renders legacy controls', () => {
|
||||||
@@ -71,8 +105,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Show unique labels')).toBeInTheDocument();
|
expect(screen.getByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Expand JSON logs')).toBeInTheDocument();
|
expect(screen.getByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])(
|
test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])(
|
||||||
@@ -83,14 +117,14 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/oldest logs first/)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(OLDEST_LOGS_LABEL_REGEX)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Deduplication')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(DEDUPE_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Show timestamps')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Wrap lines')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(WRAP_LINES_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Enable highlighting')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,20 +134,18 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/oldest logs first/)).toBeInTheDocument();
|
expect(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument();
|
expect(screen.getByLabelText(DEDUPE_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument();
|
expect(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument();
|
expect(screen.getByLabelText(WRAP_LINES_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument();
|
expect(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
expect(
|
expect(screen.queryByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY)).not.toBeInTheDocument();
|
||||||
screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')
|
expect(screen.queryByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Allows to scroll', async () => {
|
test('Allows to scroll', async () => {
|
||||||
@@ -124,8 +156,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={eventBus} />
|
<LogListControls eventBus={eventBus} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Scroll to bottom'));
|
await userEvent.click(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByLabelText('Scroll to top'));
|
await userEvent.click(screen.getByLabelText(SCROLL_TOP_LABEL_COPY));
|
||||||
expect(eventBus.publish).toHaveBeenCalledTimes(2);
|
expect(eventBus.publish).toHaveBeenCalledTimes(2);
|
||||||
expect(eventBus.publish).toHaveBeenCalledWith(
|
expect(eventBus.publish).toHaveBeenCalledWith(
|
||||||
new ScrollToLogsEvent({
|
new ScrollToLogsEvent({
|
||||||
@@ -139,6 +171,45 @@ describe('LogListControls', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Expands options', async () => {
|
||||||
|
render(
|
||||||
|
<LogListContextProvider {...contextProps} sortOrder={LogsSortOrder.Ascending}>
|
||||||
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
|
// Initial state should be collapsed
|
||||||
|
expect(screen.getByLabelText(COLLAPSED_LABEL_COPY)).toBeVisible();
|
||||||
|
// Expanded label should not be visible
|
||||||
|
expect(screen.queryByText(EXPANDED_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
|
// Expand options
|
||||||
|
await userEvent.click(screen.getByLabelText(COLLAPSED_LABEL_COPY));
|
||||||
|
// Verify that the label (state) is not collapsed
|
||||||
|
expect(screen.queryByLabelText(COLLAPSED_LABEL_COPY)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(EXPANDED_LABEL_COPY)).toBeVisible();
|
||||||
|
// Verify the expanded labels are rendered
|
||||||
|
assertExpandedOptionsCopyVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Expands options shown by default with container width > 1200', async () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const divSpy = jest.spyOn(div, 'clientWidth', 'get');
|
||||||
|
//@ts-expect-error
|
||||||
|
divSpy['clientWidth'] = 1201;
|
||||||
|
render(
|
||||||
|
//@ts-expect-error
|
||||||
|
<LogListContextProvider {...contextProps} sortOrder={LogsSortOrder.Ascending} containerElement={divSpy}>
|
||||||
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
|
</LogListContextProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the expanded labels are rendered
|
||||||
|
assertExpandedOptionsCopyVisible();
|
||||||
|
// Collapse options
|
||||||
|
await userEvent.click(screen.getByLabelText(EXPANDED_LABEL_COPY));
|
||||||
|
// State should be collapsed
|
||||||
|
expect(screen.getByLabelText(COLLAPSED_LABEL_COPY)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('Controls sort order', async () => {
|
test('Controls sort order', async () => {
|
||||||
const onLogOptionsChange = jest.fn();
|
const onLogOptionsChange = jest.fn();
|
||||||
render(
|
render(
|
||||||
@@ -150,7 +221,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText(/oldest logs first/));
|
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
|
||||||
});
|
});
|
||||||
@@ -162,7 +233,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Deduplication'));
|
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Numbers'));
|
await userEvent.click(screen.getByText('Numbers'));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers);
|
||||||
@@ -175,7 +246,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Display levels'));
|
await userEvent.click(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY));
|
||||||
expect(await screen.findByText('All levels')).toBeVisible();
|
expect(await screen.findByText('All levels')).toBeVisible();
|
||||||
expect(screen.getByText('Info')).toBeVisible();
|
expect(screen.getByText('Info')).toBeVisible();
|
||||||
expect(screen.getByText('Debug')).toBeVisible();
|
expect(screen.getByText('Debug')).toBeVisible();
|
||||||
@@ -194,7 +265,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Show timestamps'));
|
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
||||||
});
|
});
|
||||||
@@ -206,7 +277,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Wrap lines'));
|
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
||||||
});
|
});
|
||||||
@@ -227,21 +298,21 @@ describe('LogListControls', () => {
|
|||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log line wrapping'));
|
await userEvent.click(screen.getByLabelText('Wrap disabled'));
|
||||||
await userEvent.click(screen.getByText('Enable line wrapping'));
|
await userEvent.click(screen.getByText('Enable line wrapping'));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log line wrapping'));
|
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Enable line wrapping and prettify JSON'));
|
await userEvent.click(screen.getByText(WRAP_JSON_TOOLTIP_COPY));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(4);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(4);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log line wrapping'));
|
await userEvent.click(screen.getByLabelText(WRAP_JSON_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Disable line wrapping'));
|
await userEvent.click(screen.getByText(WRAP_DISABLE_LABEL_COPY));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
|
||||||
@@ -262,19 +333,19 @@ describe('LogListControls', () => {
|
|||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log timestamps'));
|
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Show millisecond timestamps'));
|
await userEvent.click(screen.getByText('Show millisecond timestamps'));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log timestamps'));
|
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Show nanosecond timestamps'));
|
await userEvent.click(screen.getByText('Show nanosecond timestamps'));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Log timestamps'));
|
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Hide timestamps'));
|
await userEvent.click(screen.getByText(TIMESTAMP_HIDE_LABEL_COPY));
|
||||||
|
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(3);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(3);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', false);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', false);
|
||||||
@@ -289,7 +360,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Enable highlighting'));
|
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
|
||||||
});
|
});
|
||||||
@@ -300,13 +371,13 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Show unique labels'));
|
await userEvent.click(screen.getByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY));
|
||||||
rerender(
|
rerender(
|
||||||
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}>
|
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}>
|
||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Hide unique labels'));
|
expect(screen.getByLabelText(HIDE_UNIQUE_LABELS_LABEL_COPY));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Controls Expand JSON logs', async () => {
|
test('Controls Expand JSON logs', async () => {
|
||||||
@@ -315,13 +386,13 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Expand JSON logs'));
|
await userEvent.click(screen.getByLabelText(EXPAND_JSON_LOGS_LABEL_COPY));
|
||||||
rerender(
|
rerender(
|
||||||
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
|
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
|
||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('Collapse JSON logs'));
|
expect(screen.getByLabelText(COLLAPSE_JSON_LOGS_LABEL_COPY));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Controls font size', async () => {
|
test('Controls font size', async () => {
|
||||||
@@ -333,11 +404,11 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Use small font size'));
|
await userEvent.click(screen.getByLabelText(FONT_SIZE_LARGE_LABEL_COPY));
|
||||||
await screen.findByLabelText('Use default font size');
|
await screen.findByLabelText(FONT_SIZE_LARGE_TOOLTIP_COPY);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Use default font size'));
|
await userEvent.click(screen.getByLabelText(FONT_SIZE_SMALL_LABEL_COPY));
|
||||||
await screen.findByLabelText('Use small font size');
|
await screen.findByLabelText(FONT_SIZE_SMALL_TOOLTIP_COPY);
|
||||||
|
|
||||||
config.featureToggles.newLogsPanel = originalValue;
|
config.featureToggles.newLogsPanel = originalValue;
|
||||||
});
|
});
|
||||||
@@ -353,7 +424,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Download logs'));
|
await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY));
|
||||||
await userEvent.click(await screen.findByText(label));
|
await userEvent.click(await screen.findByText(label));
|
||||||
expect(downloadLogs).toHaveBeenCalledTimes(1);
|
expect(downloadLogs).toHaveBeenCalledTimes(1);
|
||||||
expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined);
|
expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined);
|
||||||
@@ -371,7 +442,7 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Download logs'));
|
await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY));
|
||||||
await userEvent.click(await screen.findByText('txt'));
|
await userEvent.click(await screen.findByText('txt'));
|
||||||
expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined);
|
expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined);
|
||||||
});
|
});
|
||||||
@@ -383,12 +454,12 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Fix incorrectly escaped newline and tab sequences in log lines'));
|
await userEvent.click(screen.getByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY));
|
||||||
rerender(
|
rerender(
|
||||||
<LogListContextProvider {...contextProps} logs={[log]}>
|
<LogListContextProvider {...contextProps} logs={[log]}>
|
||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByLabelText('Remove escaping'));
|
await userEvent.click(screen.getByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { capitalize } from 'lodash';
|
import { capitalize } from 'lodash';
|
||||||
import { MouseEvent, useCallback, useMemo } from 'react';
|
import { MouseEvent, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { CoreApp, EventBus, LogLevel, LogsDedupDescription, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
import {
|
||||||
|
CoreApp,
|
||||||
|
EventBus,
|
||||||
|
LogLevel,
|
||||||
|
LogsDedupDescription,
|
||||||
|
LogsDedupStrategy,
|
||||||
|
LogsSortOrder,
|
||||||
|
store,
|
||||||
|
} from '@grafana/data';
|
||||||
import { GrafanaTheme2 } from '@grafana/data/';
|
import { GrafanaTheme2 } from '@grafana/data/';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { Dropdown, Icon, IconButton, Menu, Tooltip, useStyles2 } from '@grafana/ui';
|
import { Dropdown, Menu, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { LogsVisualisationType } from '../../../explore/Logs/Logs';
|
import { LogsVisualisationType } from '../../../explore/Logs/Logs';
|
||||||
import { DownloadFormat } from '../../utils';
|
import { DownloadFormat } from '../../utils';
|
||||||
|
|
||||||
import { useLogListContext } from './LogListContext';
|
import { useLogListContext } from './LogListContext';
|
||||||
|
import { LogListControlsOption, LogListControlsSelectOption } from './LogListControlsOption';
|
||||||
import { useLogListSearchContext } from './LogListSearchContext';
|
import { useLogListSearchContext } from './LogListSearchContext';
|
||||||
import { ScrollToLogsEvent } from './virtualization';
|
import { ScrollToLogsEvent } from './virtualization';
|
||||||
|
|
||||||
@@ -38,9 +47,9 @@ const FILTER_LEVELS: LogLevel[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) => {
|
export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const {
|
const {
|
||||||
app,
|
app,
|
||||||
|
controlsExpanded,
|
||||||
dedupStrategy,
|
dedupStrategy,
|
||||||
downloadLogs,
|
downloadLogs,
|
||||||
filterLevels,
|
filterLevels,
|
||||||
@@ -48,6 +57,7 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
forceEscape,
|
forceEscape,
|
||||||
hasUnescapedContent,
|
hasUnescapedContent,
|
||||||
prettifyJSON,
|
prettifyJSON,
|
||||||
|
setControlsExpanded,
|
||||||
setDedupStrategy,
|
setDedupStrategy,
|
||||||
setFilterLevels,
|
setFilterLevels,
|
||||||
setFontSize,
|
setFontSize,
|
||||||
@@ -63,9 +73,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
syntaxHighlighting,
|
syntaxHighlighting,
|
||||||
wrapLogMessage,
|
wrapLogMessage,
|
||||||
|
logOptionsStorageKey,
|
||||||
} = useLogListContext();
|
} = useLogListContext();
|
||||||
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
|
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
|
||||||
|
|
||||||
|
const styles = useStyles2(getStyles, controlsExpanded);
|
||||||
|
|
||||||
const onScrollToTopClick = useCallback(() => {
|
const onScrollToTopClick = useCallback(() => {
|
||||||
reportInteraction('logs_log_list_controls_scroll_top_clicked');
|
reportInteraction('logs_log_list_controls_scroll_top_clicked');
|
||||||
eventBus.publish(
|
eventBus.publish(
|
||||||
@@ -84,6 +97,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
);
|
);
|
||||||
}, [eventBus]);
|
}, [eventBus]);
|
||||||
|
|
||||||
|
const onExpandControlsClick = useCallback(() => {
|
||||||
|
reportInteraction('logs_log_list_controls_expand_controls_clicked');
|
||||||
|
setControlsExpanded(!controlsExpanded);
|
||||||
|
store.set(`${logOptionsStorageKey}.controlsExpanded`, !controlsExpanded);
|
||||||
|
}, [controlsExpanded, logOptionsStorageKey, setControlsExpanded]);
|
||||||
|
|
||||||
const onForceEscapeClick = useCallback(() => {
|
const onForceEscapeClick = useCallback(() => {
|
||||||
reportInteraction('logs_log_list_controls_force_escape_clicked');
|
reportInteraction('logs_log_list_controls_force_escape_clicked');
|
||||||
setForceEscape(!forceEscape);
|
setForceEscape(!forceEscape);
|
||||||
@@ -242,22 +261,47 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.navContainer}>
|
<div className={styles.navContainer}>
|
||||||
{visualisationType === 'logs' && (
|
<>
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
name="arrow-down"
|
expanded={controlsExpanded}
|
||||||
className={styles.controlButton}
|
name="arrow-from-right"
|
||||||
|
className={cx(styles.controlButton, styles.controlsExpandedButton)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={onScrollToBottomClick}
|
onClick={onExpandControlsClick}
|
||||||
tooltip={t('logs.logs-controls.scroll-bottom', 'Scroll to bottom')}
|
label={
|
||||||
|
controlsExpanded
|
||||||
|
? t('logs.logs-controls.label.collapse', 'Expanded')
|
||||||
|
: t('logs.logs-controls.label.expand', 'Collapsed')
|
||||||
|
}
|
||||||
|
tooltip={
|
||||||
|
controlsExpanded ? t('logs.logs-controls.collapse', 'Collapse') : t('logs.logs-controls.expand', 'Expand')
|
||||||
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
)}
|
{visualisationType === 'logs' && (
|
||||||
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
|
name="arrow-down"
|
||||||
|
className={styles.controlButton}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onScrollToBottomClick}
|
||||||
|
tooltip={t('logs.logs-controls.scroll-bottom', 'Scroll to bottom')}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
{!inDashboard ? (
|
{!inDashboard ? (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={sortOrder === LogsSortOrder.Descending ? 'sort-amount-up' : 'sort-amount-down'}
|
name={sortOrder === LogsSortOrder.Descending ? 'sort-amount-up' : 'sort-amount-down'}
|
||||||
className={styles.controlButton}
|
className={styles.controlButton}
|
||||||
onClick={onSortOrderClick}
|
onClick={onSortOrderClick}
|
||||||
|
label={
|
||||||
|
sortOrder === LogsSortOrder.Descending
|
||||||
|
? t('logs.logs-controls.labels.newest-first', 'Newest logs first')
|
||||||
|
: t('logs.logs-controls.labels.oldest-first', 'Oldest logs first')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
sortOrder === LogsSortOrder.Descending
|
sortOrder === LogsSortOrder.Descending
|
||||||
? t('logs.logs-controls.newest-first', 'Sorted by newest logs first - Click to show oldest first')
|
? t('logs.logs-controls.newest-first', 'Sorted by newest logs first - Click to show oldest first')
|
||||||
@@ -269,10 +313,16 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
<>
|
<>
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
{config.featureToggles.newLogsPanel && (
|
{config.featureToggles.newLogsPanel && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={'search'}
|
name={'search'}
|
||||||
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
||||||
onClick={searchVisible ? hideSearch : showSearch}
|
onClick={searchVisible ? hideSearch : showSearch}
|
||||||
|
label={
|
||||||
|
searchVisible
|
||||||
|
? t('logs.logs-controls.labels.hide-search', 'Close search')
|
||||||
|
: t('logs.logs-controls.labels.show-search', 'Search logs')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
searchVisible
|
searchVisible
|
||||||
? t('logs.logs-controls.hide-search', 'Close search')
|
? t('logs.logs-controls.hide-search', 'Close search')
|
||||||
@@ -282,7 +332,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Dropdown overlay={deduplicationMenu} placement="auto-end">
|
<Dropdown overlay={deduplicationMenu} placement="auto-end">
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={'filter'}
|
name={'filter'}
|
||||||
className={
|
className={
|
||||||
dedupStrategy !== LogsDedupStrategy.none ? styles.controlButtonActive : styles.controlButton
|
dedupStrategy !== LogsDedupStrategy.none ? styles.controlButtonActive : styles.controlButton
|
||||||
@@ -292,20 +343,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={'gf-logs'}
|
name={'gf-logs'}
|
||||||
className={
|
className={
|
||||||
filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton
|
filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton
|
||||||
}
|
}
|
||||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
label={t('logs.logs-controls.filter-levels', 'Filter levels')}
|
||||||
|
tooltip={t('logs.logs-controls.tooltip.filter-level', 'Filter logs result by level')}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
{config.featureToggles.newLogsPanel ? (
|
{config.featureToggles.newLogsPanel ? (
|
||||||
<TimestampResolutionButton />
|
<TimestampResolutionButton expanded={controlsExpanded} />
|
||||||
) : (
|
) : (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="clock-nine"
|
name="clock-nine"
|
||||||
aria-pressed={showTime}
|
aria-pressed={showTime}
|
||||||
className={showTime ? styles.controlButtonActive : styles.controlButton}
|
className={showTime ? styles.controlButtonActive : styles.controlButton}
|
||||||
@@ -320,7 +374,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
)}
|
)}
|
||||||
{/* When this is used in a Plugin context, app is unknown */}
|
{/* When this is used in a Plugin context, app is unknown */}
|
||||||
{showUniqueLabels !== undefined && app !== CoreApp.Unknown && (
|
{showUniqueLabels !== undefined && app !== CoreApp.Unknown && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="tag-alt"
|
name="tag-alt"
|
||||||
aria-pressed={showUniqueLabels}
|
aria-pressed={showUniqueLabels}
|
||||||
className={showUniqueLabels ? styles.controlButtonActive : styles.controlButton}
|
className={showUniqueLabels ? styles.controlButtonActive : styles.controlButton}
|
||||||
@@ -334,9 +389,10 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{config.featureToggles.newLogsPanel ? (
|
{config.featureToggles.newLogsPanel ? (
|
||||||
<WrapLogMessageButton />
|
<WrapLogMessageButton expanded={controlsExpanded} />
|
||||||
) : (
|
) : (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="wrap-text"
|
name="wrap-text"
|
||||||
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
|
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
|
||||||
aria-pressed={wrapLogMessage}
|
aria-pressed={wrapLogMessage}
|
||||||
@@ -350,7 +406,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && (
|
{prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="brackets-curly"
|
name="brackets-curly"
|
||||||
aria-pressed={prettifyJSON}
|
aria-pressed={prettifyJSON}
|
||||||
className={prettifyJSON ? styles.controlButtonActive : styles.controlButton}
|
className={prettifyJSON ? styles.controlButtonActive : styles.controlButton}
|
||||||
@@ -364,39 +421,57 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{syntaxHighlighting !== undefined && (
|
{syntaxHighlighting !== undefined && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="brackets-curly"
|
name="brackets-curly"
|
||||||
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
|
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
|
||||||
aria-pressed={syntaxHighlighting}
|
aria-pressed={syntaxHighlighting}
|
||||||
onClick={onSyntaxHightlightingClick}
|
onClick={onSyntaxHightlightingClick}
|
||||||
|
label={
|
||||||
|
syntaxHighlighting
|
||||||
|
? t('logs.logs-controls.label.disable-highlighting', 'Highlight text')
|
||||||
|
: t('logs.logs-controls.label.enable-highlighting', 'Plain text')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
syntaxHighlighting
|
syntaxHighlighting
|
||||||
? t('logs.logs-controls.disable-highlighting', 'Disable highlighting')
|
? t('logs.logs-controls.tooltip.disable-highlighting', 'Disable highlighting')
|
||||||
: t('logs.logs-controls.enable-highlighting', 'Enable highlighting')
|
: t('logs.logs-controls.tooltip.enable-highlighting', 'Enable highlighting')
|
||||||
}
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{config.featureToggles.newLogsPanel && (
|
{config.featureToggles.newLogsPanel && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="text-fields"
|
name="text-fields"
|
||||||
className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton}
|
className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton}
|
||||||
aria-pressed={Boolean(fontSize)}
|
aria-pressed={Boolean(fontSize)}
|
||||||
onClick={onFontSizeClick}
|
onClick={onFontSizeClick}
|
||||||
|
label={
|
||||||
|
fontSize === 'default'
|
||||||
|
? t('logs.logs-controls.labels.font-large', 'Large font')
|
||||||
|
: t('logs.logs-controls.labels.font-small', 'Small font')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
fontSize === 'default'
|
fontSize === 'default'
|
||||||
? t('logs.logs-controls.font-size-default', 'Use small font size')
|
? t('logs.logs-controls.font-small', 'Set small font')
|
||||||
: t('logs.logs-controls.font-size-small', 'Use default font size')
|
: t('logs.logs-controls.font-large', 'Set large font')
|
||||||
}
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasUnescapedContent && (
|
{hasUnescapedContent && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="enter"
|
name="enter"
|
||||||
aria-pressed={forceEscape}
|
aria-pressed={forceEscape}
|
||||||
className={forceEscape ? styles.controlButtonActive : styles.controlButton}
|
className={forceEscape ? styles.controlButtonActive : styles.controlButton}
|
||||||
onClick={onForceEscapeClick}
|
onClick={onForceEscapeClick}
|
||||||
|
label={
|
||||||
|
forceEscape
|
||||||
|
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
||||||
|
: t('logs.logs-controls.label.escape-newlines', 'Escape newlines')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
forceEscape
|
forceEscape
|
||||||
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
||||||
@@ -414,10 +489,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
<>
|
<>
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
<Dropdown overlay={downloadMenu} placement="auto-end">
|
<Dropdown overlay={downloadMenu} placement="auto-end">
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="download-alt"
|
name="download-alt"
|
||||||
className={styles.controlButton}
|
className={styles.controlButton}
|
||||||
tooltip={t('logs.logs-controls.download', 'Download logs')}
|
label={t('logs.logs-controls.download', 'Download logs')}
|
||||||
|
tooltip={t('logs.logs-controls.tooltip.download', 'Download')}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -427,10 +504,16 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{config.featureToggles.newLogsPanel && (
|
{config.featureToggles.newLogsPanel && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={'search'}
|
name={'search'}
|
||||||
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
className={searchVisible ? styles.controlButtonActive : styles.controlButton}
|
||||||
onClick={searchVisible ? hideSearch : showSearch}
|
onClick={searchVisible ? hideSearch : showSearch}
|
||||||
|
label={
|
||||||
|
searchVisible
|
||||||
|
? t('logs.logs-controls.labels.hide-search', 'Close search')
|
||||||
|
: t('logs.logs-controls.labels.show-search', 'Search logs')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
searchVisible
|
searchVisible
|
||||||
? t('logs.logs-controls.hide-search', 'Close search')
|
? t('logs.logs-controls.hide-search', 'Close search')
|
||||||
@@ -440,19 +523,27 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name={'gf-logs'}
|
name={'gf-logs'}
|
||||||
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
||||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
label={t('logs.logs-controls.filter-levels', 'Filter levels')}
|
||||||
|
tooltip={t('logs.logs-controls.tooltip.filter-level', 'Filter logs result by level')}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{visualisationType === 'logs' && hasUnescapedContent && (
|
{visualisationType === 'logs' && hasUnescapedContent && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="enter"
|
name="enter"
|
||||||
aria-pressed={forceEscape}
|
aria-pressed={forceEscape}
|
||||||
className={forceEscape ? styles.controlButtonActive : styles.controlButton}
|
className={forceEscape ? styles.controlButtonActive : styles.controlButton}
|
||||||
onClick={onForceEscapeClick}
|
onClick={onForceEscapeClick}
|
||||||
|
label={
|
||||||
|
forceEscape
|
||||||
|
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
||||||
|
: t('logs.logs-controls.label.escape-newlines', 'Escape newlines')
|
||||||
|
}
|
||||||
tooltip={
|
tooltip={
|
||||||
forceEscape
|
forceEscape
|
||||||
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
|
||||||
@@ -467,7 +558,9 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{visualisationType === 'logs' && (
|
{visualisationType === 'logs' && (
|
||||||
<IconButton
|
<LogListControlsOption
|
||||||
|
stickToBottom={true}
|
||||||
|
expanded={controlsExpanded}
|
||||||
name="arrow-up"
|
name="arrow-up"
|
||||||
data-testid="scrollToTop"
|
data-testid="scrollToTop"
|
||||||
className={styles.scrollToTopButton}
|
className={styles.scrollToTopButton}
|
||||||
@@ -481,8 +574,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TimestampResolutionButton = () => {
|
interface LogSelectOptionProps {
|
||||||
const styles = useStyles2(getStyles);
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimestampResolutionButton = ({ expanded }: LogSelectOptionProps) => {
|
||||||
|
const styles = useStyles2(getWrapButtonStyles, expanded);
|
||||||
const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext();
|
const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext();
|
||||||
|
|
||||||
const hide = useCallback(() => {
|
const hide = useCallback(() => {
|
||||||
@@ -533,33 +630,32 @@ const TimestampResolutionButton = () => {
|
|||||||
[hide, showMs, showNs, showTime, styles.menuItemActive, timestampResolution]
|
[hide, showMs, showNs, showTime, styles.menuItemActive, timestampResolution]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const labelText = !showTime
|
||||||
|
? t('logs.logs-controls.timestamp.label-hide', 'Hide timestamps')
|
||||||
|
: timestampResolution === 'ms'
|
||||||
|
? t('logs.logs-controls.timestamp.label-ms', 'Display ms')
|
||||||
|
: t('logs.logs-controls.timestamp.label-ns', 'Display ns');
|
||||||
|
|
||||||
|
const customTagText =
|
||||||
|
timestampResolution === 'ms'
|
||||||
|
? t('logs.logs-controls.resolution-ms', 'ms')
|
||||||
|
: t('logs.logs-controls.resolution-ns', 'ns');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown overlay={timestampMenu} placement="auto-end">
|
<LogListControlsSelectOption
|
||||||
<div>
|
expanded={expanded}
|
||||||
<Tooltip content={t('logs.logs-controls.timestamp.label', 'Log timestamps')}>
|
name={'clock-nine'}
|
||||||
<button
|
isActive={showTime}
|
||||||
aria-pressed={showTime}
|
dropdown={timestampMenu}
|
||||||
aria-label={t('logs.logs-controls.timestamp.label', 'Log timestamps')}
|
tooltip={t('logs.logs-controls.timestamp.tooltip', 'Set timestamp format')}
|
||||||
className={`${styles.customControlButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
|
label={labelText}
|
||||||
type="button"
|
buttonAriaLabel={t('logs.logs-controls.timestamp.label', 'Log timestamps')}
|
||||||
>
|
customTagText={customTagText}
|
||||||
<Icon name="clock-nine" size="lg" className={styles.customControlIcon} />
|
/>
|
||||||
{showTime && (
|
|
||||||
<span className={styles.customControlTag}>
|
|
||||||
{timestampResolution === 'ms'
|
|
||||||
? t('logs.logs-controls.resolution-ms', 'ms')
|
|
||||||
: t('logs.logs-controls.resolution-ns', 'ns')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const WrapLogMessageButton = ({ expanded }: LogSelectOptionProps) => {
|
||||||
const WrapLogMessageButton = () => {
|
const styles = useStyles2(getWrapButtonStyles, expanded);
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const { prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage } = useLogListContext();
|
const { prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage } = useLogListContext();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -622,39 +718,62 @@ const WrapLogMessageButton = () => {
|
|||||||
[disable, prettifyJSON, styles.menuItemActive, wrap, wrapAndPrettify, wrapLogMessage]
|
[disable, prettifyJSON, styles.menuItemActive, wrap, wrapAndPrettify, wrapLogMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wrapStateText = !wrapLogMessage
|
||||||
|
? t('logs.logs-controls.line-wrapping.state.hide', 'Wrap disabled')
|
||||||
|
: wrapLogMessage && !prettifyJSON
|
||||||
|
? t('logs.logs-controls.line-wrapping.state.wrap', 'Wrap lines')
|
||||||
|
: t('logs.logs-controls.line-wrapping.state.json', 'Wrap JSON');
|
||||||
|
|
||||||
|
const tooltip = t('logs.logs-controls.line-wrapping.tooltip', 'Set line wrap');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown overlay={wrappingMenu} placement="auto-end">
|
<LogListControlsSelectOption
|
||||||
<div>
|
expanded={expanded}
|
||||||
<Tooltip content={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')}>
|
name={'wrap-text'}
|
||||||
<button
|
isActive={wrapLogMessage}
|
||||||
aria-label={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')}
|
dropdown={wrappingMenu}
|
||||||
aria-pressed={wrapLogMessage}
|
tooltip={tooltip}
|
||||||
className={`${styles.customControlButton} ${wrapLogMessage ? styles.controlButtonActive : styles.controlButton}`}
|
label={wrapStateText}
|
||||||
type="button"
|
buttonAriaLabel={tooltip}
|
||||||
>
|
customTagText={'+'}
|
||||||
<Icon name="wrap-text" size="lg" className={styles.customControlIcon} />
|
/>
|
||||||
{prettifyJSON && <span className={styles.customControlTag}>+</span>}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getWrapButtonStyles = (theme: GrafanaTheme2, expanded: boolean) => {
|
||||||
|
return {
|
||||||
|
menuItemActive: css({
|
||||||
|
'&:before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: theme.spacing(0.5),
|
||||||
|
height: `calc(100% - ${theme.spacing(1)})`,
|
||||||
|
width: '2px',
|
||||||
|
backgroundColor: theme.colors.warning.main,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTROLS_WIDTH = 35;
|
||||||
|
export const CONTROLS_WIDTH_EXPANDED = 176;
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => {
|
||||||
return {
|
return {
|
||||||
navContainer: css({
|
navContainer: css({
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flex: '1 0 auto',
|
||||||
gap: theme.spacing(3),
|
gap: theme.spacing(3),
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
width: theme.spacing(4),
|
width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : CONTROLS_WIDTH,
|
||||||
paddingTop: theme.spacing(0.75),
|
paddingTop: theme.spacing(0.75),
|
||||||
paddingLeft: theme.spacing(1),
|
paddingLeft: theme.spacing(1),
|
||||||
borderLeft: `solid 1px ${theme.colors.border.medium}`,
|
borderLeft: `solid 1px ${theme.colors.border.medium}`,
|
||||||
overflow: 'hidden',
|
|
||||||
minWidth: theme.spacing(4),
|
minWidth: theme.spacing(4),
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
}),
|
}),
|
||||||
scrollToTopButton: css({
|
scrollToTopButton: css({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -662,6 +781,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
height: theme.spacing(2),
|
height: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
|
controlsExpandedButton: css({
|
||||||
|
transform: !controlsExpanded ? 'rotate(180deg)' : '',
|
||||||
|
}),
|
||||||
controlButton: css({
|
controlButton: css({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
@@ -685,7 +807,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
borderRadius: theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
bottom: theme.spacing(-1),
|
bottom: theme.spacing(-1),
|
||||||
backgroundImage: theme.colors.gradients.brandHorizontal,
|
backgroundImage: theme.colors.gradients.brandHorizontal,
|
||||||
width: '95%',
|
width: theme.spacing(2.25),
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -700,32 +822,5 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
backgroundColor: theme.colors.warning.main,
|
backgroundColor: theme.colors.warning.main,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
customControlButton: css({
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 0,
|
|
||||||
margin: 0,
|
|
||||||
boxShadow: 'none',
|
|
||||||
border: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
background: 'transparent',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 0,
|
|
||||||
overflow: 'visible',
|
|
||||||
width: '100%',
|
|
||||||
}),
|
|
||||||
customControlIcon: css({
|
|
||||||
verticalAlign: 'baseline',
|
|
||||||
}),
|
|
||||||
customControlTag: css({
|
|
||||||
color: theme.colors.primary.text,
|
|
||||||
fontSize: 10,
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: -4,
|
|
||||||
right: 1,
|
|
||||||
lineHeight: '10px',
|
|
||||||
backgroundColor: theme.colors.background.primary,
|
|
||||||
paddingLeft: 2,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Dropdown, Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
interface LogControlOptionProps {
|
||||||
|
label?: string;
|
||||||
|
expanded: boolean;
|
||||||
|
tooltip: string;
|
||||||
|
stickToBottom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = React.ComponentProps<typeof IconButton> & LogControlOptionProps;
|
||||||
|
|
||||||
|
export const LogListControlsOption = React.forwardRef<HTMLButtonElement, Props>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
stickToBottom,
|
||||||
|
expanded,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
className: iconButtonClassName,
|
||||||
|
name: iconButtonName,
|
||||||
|
...iconButtonProps
|
||||||
|
}: Props,
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const styles = useStyles2(getStyles, expanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${stickToBottom ? styles.marginTopAuto : ''}`}>
|
||||||
|
<label className={styles.label}>
|
||||||
|
<span className={styles.labelText}>{label ?? tooltip}</span>
|
||||||
|
<span className={styles.iconContainer}>
|
||||||
|
<IconButton
|
||||||
|
name={iconButtonName}
|
||||||
|
tooltip={tooltip}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
ref={ref}
|
||||||
|
{...iconButtonProps}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LogControlSelectOptionProps {
|
||||||
|
label?: string;
|
||||||
|
expanded: boolean;
|
||||||
|
tooltip: string;
|
||||||
|
stickToBottom?: boolean;
|
||||||
|
dropdown: JSX.Element;
|
||||||
|
isActive: boolean;
|
||||||
|
customTagText: string;
|
||||||
|
buttonAriaLabel: string;
|
||||||
|
}
|
||||||
|
export type SelectProps = React.ComponentProps<typeof Icon> & LogControlSelectOptionProps;
|
||||||
|
|
||||||
|
export const LogListControlsSelectOption = React.forwardRef<SVGElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
stickToBottom,
|
||||||
|
expanded,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
className: iconButtonClassName,
|
||||||
|
name: iconButtonName,
|
||||||
|
dropdown,
|
||||||
|
isActive: isActive,
|
||||||
|
customTagText,
|
||||||
|
buttonAriaLabel,
|
||||||
|
...iconButtonProps
|
||||||
|
}: SelectProps,
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const styles = useStyles2(getStyles, expanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<label className={styles.label}>
|
||||||
|
<span className={styles.labelText}>{label ?? tooltip}</span>
|
||||||
|
<span>
|
||||||
|
<Dropdown overlay={dropdown} placement="auto-end">
|
||||||
|
<div className={styles.iconContainer}>
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<button
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-label={buttonAriaLabel}
|
||||||
|
className={`${styles.customControlButton} ${isActive ? styles.controlButtonActive : styles.controlButton}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
{...iconButtonProps}
|
||||||
|
ref={ref}
|
||||||
|
name={iconButtonName}
|
||||||
|
size="lg"
|
||||||
|
className={styles.customControlIcon}
|
||||||
|
/>
|
||||||
|
{isActive && <span className={styles.customControlTag}>{customTagText}</span>}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
LogListControlsSelectOption.displayName = 'LogListControlsSelectOption';
|
||||||
|
const getStyles = (theme: GrafanaTheme2, expanded: boolean) => {
|
||||||
|
const hoverSize = '26';
|
||||||
|
return {
|
||||||
|
customControlTag: css({
|
||||||
|
color: theme.colors.primary.text,
|
||||||
|
fontSize: 10,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -4,
|
||||||
|
right: 1,
|
||||||
|
lineHeight: '10px',
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
|
paddingLeft: 2,
|
||||||
|
}),
|
||||||
|
customControlIcon: css({
|
||||||
|
verticalAlign: 'baseline',
|
||||||
|
}),
|
||||||
|
customControlButton: css({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 0,
|
||||||
|
margin: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
background: 'transparent',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
controlButtonActive: css({
|
||||||
|
margin: 0,
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
height: theme.spacing(2),
|
||||||
|
'&:hover': {
|
||||||
|
'&:before': {
|
||||||
|
backgroundColor: theme.colors.action.hover,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&:before': {
|
||||||
|
zIndex: -1,
|
||||||
|
position: 'absolute',
|
||||||
|
opacity: 0,
|
||||||
|
width: `${hoverSize}px`,
|
||||||
|
height: `${hoverSize}px`,
|
||||||
|
borderRadius: theme.shape.radius.default,
|
||||||
|
content: '""',
|
||||||
|
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||||
|
transitionDuration: '0.2s',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transitionProperty: 'opacity',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&:after': {
|
||||||
|
display: 'block',
|
||||||
|
content: '" "',
|
||||||
|
position: 'absolute',
|
||||||
|
height: 2,
|
||||||
|
borderRadius: theme.shape.radius.default,
|
||||||
|
bottom: theme.spacing(-1),
|
||||||
|
backgroundImage: theme.colors.gradients.brandHorizontal,
|
||||||
|
width: theme.spacing(2.25),
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
controlButton: css({
|
||||||
|
margin: 0,
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
height: theme.spacing(2),
|
||||||
|
}),
|
||||||
|
marginTopAuto: css({
|
||||||
|
marginTop: 'auto',
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
labelText: css({
|
||||||
|
display: expanded ? 'block' : 'none',
|
||||||
|
}),
|
||||||
|
iconContainer: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '16px',
|
||||||
|
}),
|
||||||
|
container: css({
|
||||||
|
fontSize: theme.typography.pxToRem(12),
|
||||||
|
height: theme.spacing(2),
|
||||||
|
width: 'auto',
|
||||||
|
}),
|
||||||
|
label: css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: expanded ? 'space-between' : 'center',
|
||||||
|
marginRight: expanded ? '2.5px' : 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
LogListControlsOption.displayName = 'LogListControlsOption';
|
||||||
@@ -3,7 +3,6 @@ import { createContext, useContext } from 'react';
|
|||||||
import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||||
import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils';
|
import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils';
|
||||||
|
|
||||||
import { LogLineDetailsMode } from '../LogLineDetails';
|
|
||||||
import { LogListContextData, Props } from '../LogListContext';
|
import { LogListContextData, Props } from '../LogListContext';
|
||||||
import { LogListModel } from '../processing';
|
import { LogListModel } from '../processing';
|
||||||
|
|
||||||
@@ -49,11 +48,11 @@ export const LogListContext = createContext<LogListContextData>({
|
|||||||
toggleDetails: () => {},
|
toggleDetails: () => {},
|
||||||
wrapLogMessage: false,
|
wrapLogMessage: false,
|
||||||
detailsMode: 'sidebar',
|
detailsMode: 'sidebar',
|
||||||
setDetailsMode: function (mode: LogLineDetailsMode): void {
|
setDetailsMode: () => {},
|
||||||
throw new Error('Function not implemented.');
|
|
||||||
},
|
|
||||||
isAssistantAvailable: false,
|
isAssistantAvailable: false,
|
||||||
openAssistantByLog: () => {},
|
openAssistantByLog: () => {},
|
||||||
|
controlsExpanded: false,
|
||||||
|
setControlsExpanded: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useLogListContextData = (key: keyof LogListContextData) => {
|
export const useLogListContextData = (key: keyof LogListContextData) => {
|
||||||
@@ -110,8 +109,10 @@ export const defaultValue: LogListContextData = {
|
|||||||
sortOrder: LogsSortOrder.Ascending,
|
sortOrder: LogsSortOrder.Ascending,
|
||||||
wrapLogMessage: false,
|
wrapLogMessage: false,
|
||||||
isAssistantAvailable: false,
|
isAssistantAvailable: false,
|
||||||
openAssistantByLog: () => {},
|
openAssistantByLog: jest.fn(),
|
||||||
timestampResolution: 'ns',
|
timestampResolution: 'ns',
|
||||||
|
controlsExpanded: false,
|
||||||
|
setControlsExpanded: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultProps: Props = {
|
export const defaultProps: Props = {
|
||||||
|
|||||||
@@ -9633,10 +9633,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logs-controls": {
|
"logs-controls": {
|
||||||
|
"collapse": "Collapse",
|
||||||
"deduplication": "Deduplication",
|
"deduplication": "Deduplication",
|
||||||
"disable-highlighting": "Disable highlighting",
|
|
||||||
"disable-prettify-json": "Collapse JSON logs",
|
"disable-prettify-json": "Collapse JSON logs",
|
||||||
"display-level": "Display levels",
|
|
||||||
"display-level-all": "All levels",
|
"display-level-all": "All levels",
|
||||||
"download": "Download logs",
|
"download": "Download logs",
|
||||||
"download-logs": {
|
"download-logs": {
|
||||||
@@ -9644,18 +9643,39 @@
|
|||||||
"json": "json",
|
"json": "json",
|
||||||
"txt": "txt"
|
"txt": "txt"
|
||||||
},
|
},
|
||||||
"enable-highlighting": "Enable highlighting",
|
|
||||||
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
|
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
|
||||||
"font-size-default": "Use small font size",
|
"expand": "Expand",
|
||||||
"font-size-small": "Use default font size",
|
"filter-levels": "Filter levels",
|
||||||
|
"font-large": "Set large font",
|
||||||
|
"font-small": "Set small font",
|
||||||
"hide-search": "Close search",
|
"hide-search": "Close search",
|
||||||
"hide-timestamps": "Hide timestamps",
|
"hide-timestamps": "Hide timestamps",
|
||||||
"hide-unique-labels": "Hide unique labels",
|
"hide-unique-labels": "Hide unique labels",
|
||||||
|
"label": {
|
||||||
|
"collapse": "Expanded",
|
||||||
|
"disable-highlighting": "Highlight text",
|
||||||
|
"enable-highlighting": "Plain text",
|
||||||
|
"escape-newlines": "Escape newlines",
|
||||||
|
"expand": "Collapsed"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"font-large": "Large font",
|
||||||
|
"font-small": "Small font",
|
||||||
|
"hide-search": "Close search",
|
||||||
|
"newest-first": "Newest logs first",
|
||||||
|
"oldest-first": "Oldest logs first",
|
||||||
|
"show-search": "Search logs"
|
||||||
|
},
|
||||||
"line-wrapping": {
|
"line-wrapping": {
|
||||||
"enable": "Enable line wrapping",
|
"enable": "Enable line wrapping",
|
||||||
"enable-prettify": "Enable line wrapping and prettify JSON",
|
"enable-prettify": "Enable line wrapping and prettify JSON",
|
||||||
"hide": "Disable line wrapping",
|
"hide": "Disable line wrapping",
|
||||||
"label": "Log line wrapping"
|
"state": {
|
||||||
|
"hide": "Wrap disabled",
|
||||||
|
"json": "Wrap JSON",
|
||||||
|
"wrap": "Wrap lines"
|
||||||
|
},
|
||||||
|
"tooltip": "Set line wrap"
|
||||||
},
|
},
|
||||||
"newest-first": "Sorted by newest logs first - Click to show oldest first",
|
"newest-first": "Sorted by newest logs first - Click to show oldest first",
|
||||||
"oldest-first": "Sorted by oldest logs first - Click to show newest first",
|
"oldest-first": "Sorted by oldest logs first - Click to show newest first",
|
||||||
@@ -9671,8 +9691,18 @@
|
|||||||
"timestamp": {
|
"timestamp": {
|
||||||
"hide": "Hide timestamps",
|
"hide": "Hide timestamps",
|
||||||
"label": "Log timestamps",
|
"label": "Log timestamps",
|
||||||
|
"label-hide": "Hide timestamps",
|
||||||
|
"label-ms": "Display ms",
|
||||||
|
"label-ns": "Display ns",
|
||||||
"milliseconds": "Show millisecond timestamps",
|
"milliseconds": "Show millisecond timestamps",
|
||||||
"nanoseconds": "Show nanosecond timestamps"
|
"nanoseconds": "Show nanosecond timestamps",
|
||||||
|
"tooltip": "Set timestamp format"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"disable-highlighting": "Disable highlighting",
|
||||||
|
"download": "Download",
|
||||||
|
"enable-highlighting": "Enable highlighting",
|
||||||
|
"filter-level": "Filter logs result by level"
|
||||||
},
|
},
|
||||||
"unwrap-lines": "Unwrap lines",
|
"unwrap-lines": "Unwrap lines",
|
||||||
"wrap-lines": "Wrap lines"
|
"wrap-lines": "Wrap lines"
|
||||||
|
|||||||
Reference in New Issue
Block a user