Skip to content

Commit da3add0

Browse files
committed
[FEATURE] add column settings to logs table
Signed-off-by: Gabriel Bernal <gbernal@redhat.com>
1 parent 7e81d71 commit da3add0

25 files changed

Lines changed: 2065 additions & 239 deletions

logstable/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"types": "lib/index.d.ts",
2727
"dependencies": {
2828
"ansi_up": "^6.0.0",
29-
"dompurify": "^3.4.8"
29+
"dompurify": "^3.4.8",
30+
"immer": "^10.1.1"
3031
},
3132
"peerDependencies": {
3233
"@emotion/react": "^11.7.1",

logstable/schemas/logstable.cue

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,17 @@ kind: "LogsTable"
2121
spec: close({
2222
allowWrap?: bool
2323
enableDetails?: bool
24-
showTime?: bool
25-
selection?: common.#selection
26-
actions?: common.#actions
24+
// Deprecated: use columns instead. Only effective when columns is not set.
25+
showTime?: bool
26+
columns?: [...close({
27+
name: string
28+
header?: string
29+
enableSorting?: bool
30+
sort?: "asc" | "desc"
31+
sortMode?: "alphabetical" | "numeric" | "timestamp"
32+
allowWrap?: bool
33+
width?: number
34+
})]
35+
selection?: common.#selection
36+
actions?: common.#actions
2737
})

logstable/src/LogsTable.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// limitations under the License.
1313

1414
import { PanelPlugin } from '@perses-dev/plugin-system';
15+
import { LogsTableColumnsEditor } from './LogsTableColumnsEditor';
1516
import { LogsTableComponent } from './LogsTableComponent';
1617
import { LogsTableItemSelectionActionsEditor } from './LogsTableItemSelectionActionsEditor';
1718
import { LogsTableSettingsEditor } from './LogsTableSettingsEditor';
@@ -22,11 +23,11 @@ export const LogsTable: PanelPlugin<LogsTableOptions, LogsTableProps> = {
2223
PanelComponent: LogsTableComponent,
2324
panelOptionsEditorComponents: [
2425
{ label: 'Settings', content: LogsTableSettingsEditor },
26+
{ label: 'Columns', content: LogsTableColumnsEditor },
2527
{ label: 'Item Actions', content: LogsTableItemSelectionActionsEditor },
2628
],
2729
supportedQueryTypes: ['LogQuery'],
2830
createInitialOptions: () => ({
29-
showTime: true,
3031
allowWrap: true,
3132
enableDetails: true,
3233
}),
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { render, screen, fireEvent } from '@testing-library/react';
15+
import { LogsTableColumnsEditor } from './LogsTableColumnsEditor';
16+
import { LogsTableOptions, LogsColumnDefinition } from './model';
17+
18+
const createProps = (columns?: LogsColumnDefinition[]): { value: LogsTableOptions; onChange: jest.Mock } => {
19+
const value: LogsTableOptions = {
20+
allowWrap: true,
21+
enableDetails: true,
22+
columns,
23+
};
24+
const onChange = jest.fn();
25+
return { value, onChange };
26+
};
27+
28+
describe('LogsTableColumnsEditor', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('should render the description text about default columns', () => {
34+
const props = createProps();
35+
render(<LogsTableColumnsEditor {...props} />);
36+
expect(screen.getByText(/Timestamp and Log line are shown by default/i)).toBeInTheDocument();
37+
});
38+
39+
it('should render columns when provided', () => {
40+
const props = createProps([{ name: 'service', header: 'Service' }, { name: 'level' }]);
41+
render(<LogsTableColumnsEditor {...props} />);
42+
expect(screen.getByText('Service')).toBeInTheDocument();
43+
expect(screen.getByText('level')).toBeInTheDocument();
44+
});
45+
46+
it('should pre-populate with timestamp and line columns on first add', () => {
47+
const props = createProps(undefined);
48+
render(<LogsTableColumnsEditor {...props} />);
49+
fireEvent.click(screen.getByRole('button', { name: /add column/i }));
50+
expect(props.onChange).toHaveBeenCalledTimes(1);
51+
const newValue = props.onChange.mock.calls[0][0];
52+
expect(newValue.columns).toHaveLength(2);
53+
expect(newValue.columns[0]).toEqual({
54+
name: 'timestamp',
55+
header: 'Timestamp',
56+
sortMode: 'timestamp',
57+
sort: 'desc',
58+
});
59+
expect(newValue.columns[1]).toEqual({ name: 'line', header: 'Log line', allowWrap: true, enableSorting: false });
60+
});
61+
62+
it('should call onChange with new column appended when add is clicked', () => {
63+
const props = createProps([{ name: 'existing' }]);
64+
render(<LogsTableColumnsEditor {...props} />);
65+
fireEvent.click(screen.getByRole('button', { name: /add column/i }));
66+
expect(props.onChange).toHaveBeenCalledTimes(1);
67+
const newValue = props.onChange.mock.calls[0][0];
68+
expect(newValue.columns).toHaveLength(2);
69+
expect(newValue.columns[1]).toEqual({ name: '' });
70+
});
71+
72+
it('should call onChange with column removed when delete is clicked', () => {
73+
const props = createProps([{ name: 'first' }, { name: 'second' }]);
74+
render(<LogsTableColumnsEditor {...props} />);
75+
const deleteButtons = screen.getAllByLabelText('Delete column');
76+
fireEvent.click(deleteButtons[0]!);
77+
expect(props.onChange).toHaveBeenCalledTimes(1);
78+
const newValue = props.onChange.mock.calls[0][0];
79+
expect(newValue.columns).toHaveLength(1);
80+
expect(newValue.columns[0].name).toBe('second');
81+
});
82+
83+
it('should call onChange with columns reordered when move up is clicked', () => {
84+
const props = createProps([{ name: 'first' }, { name: 'second' }]);
85+
render(<LogsTableColumnsEditor {...props} />);
86+
const moveUpButtons = screen.getAllByLabelText('Move column up');
87+
fireEvent.click(moveUpButtons[1]!); // move second column up
88+
expect(props.onChange).toHaveBeenCalledTimes(1);
89+
const newValue = props.onChange.mock.calls[0][0];
90+
expect(newValue.columns[0].name).toBe('second');
91+
expect(newValue.columns[1].name).toBe('first');
92+
});
93+
94+
it('should call onChange with columns reordered when move down is clicked', () => {
95+
const props = createProps([{ name: 'first' }, { name: 'second' }]);
96+
render(<LogsTableColumnsEditor {...props} />);
97+
const moveDownButtons = screen.getAllByLabelText('Move column down');
98+
fireEvent.click(moveDownButtons[0]!); // move first column down
99+
expect(props.onChange).toHaveBeenCalledTimes(1);
100+
const newValue = props.onChange.mock.calls[0][0];
101+
expect(newValue.columns[0].name).toBe('second');
102+
expect(newValue.columns[1].name).toBe('first');
103+
});
104+
105+
it('should render wrap content checkbox for each column', () => {
106+
const props = createProps([{ name: 'col1' }, { name: 'col2' }]);
107+
render(<LogsTableColumnsEditor {...props} />);
108+
const wrapCheckboxes = screen.getAllByLabelText('Wrap content');
109+
expect(wrapCheckboxes).toHaveLength(2);
110+
});
111+
112+
it('should have wrap content unchecked by default', () => {
113+
const props = createProps([{ name: 'col1' }]);
114+
render(<LogsTableColumnsEditor {...props} />);
115+
const wrapCheckbox = screen.getByLabelText('Wrap content');
116+
expect(wrapCheckbox).not.toBeChecked();
117+
});
118+
119+
it('should have wrap content checked when allowWrap is true', () => {
120+
const props = createProps([{ name: 'col1', allowWrap: true }]);
121+
render(<LogsTableColumnsEditor {...props} />);
122+
const wrapCheckbox = screen.getByLabelText('Wrap content');
123+
expect(wrapCheckbox).toBeChecked();
124+
});
125+
126+
it('should render column name field with helper text', () => {
127+
const props = createProps([{ name: 'service' }]);
128+
render(<LogsTableColumnsEditor {...props} />);
129+
expect(screen.getByText(/Use 'timestamp', 'line', or a label key/)).toBeInTheDocument();
130+
});
131+
132+
it('should display "New column" as display name for empty column', () => {
133+
const props = createProps([{ name: '' }]);
134+
render(<LogsTableColumnsEditor {...props} />);
135+
expect(screen.getByText('New column')).toBeInTheDocument();
136+
});
137+
138+
it('should render with empty columns array when columns is undefined', () => {
139+
const props = createProps(undefined);
140+
render(<LogsTableColumnsEditor {...props} />);
141+
expect(screen.getByRole('button', { name: /add column/i })).toBeInTheDocument();
142+
expect(screen.queryByLabelText('Enable sorting')).not.toBeInTheDocument();
143+
});
144+
145+
it('should render sort mode options including Alphabetical, Numeric, and Timestamp', () => {
146+
const props = createProps([{ name: 'col1' }]);
147+
render(<LogsTableColumnsEditor {...props} />);
148+
// The sort mode select should be present
149+
expect(screen.getByLabelText('Sort mode')).toBeInTheDocument();
150+
});
151+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { Checkbox, FormControlLabel, Stack, TextField } from '@mui/material';
15+
import { OptionsEditorProps } from '@perses-dev/plugin-system';
16+
import { produce } from 'immer';
17+
import { ReactElement, useCallback } from 'react';
18+
import { ColumnsEditor } from './components/ColumnsEditor';
19+
import { LogsTableOptions, LogsColumnDefinition, LogsColumnSortMode } from './model';
20+
21+
const SORT_MODE_LABELS: Record<LogsColumnSortMode, string> = {
22+
alphabetical: 'Alphabetical',
23+
numeric: 'Numeric',
24+
timestamp: 'Timestamp',
25+
};
26+
27+
export function LogsTableColumnsEditor(props: OptionsEditorProps<LogsTableOptions>): ReactElement {
28+
const { value, onChange } = props;
29+
const columns = value.columns ?? [];
30+
31+
const updateColumns = useCallback(
32+
(recipe: (draft: LogsColumnDefinition[]) => void) => {
33+
onChange(
34+
produce(value, (draft) => {
35+
if (!draft.columns) draft.columns = [];
36+
recipe(draft.columns);
37+
})
38+
);
39+
},
40+
[value, onChange]
41+
);
42+
43+
const handleAddColumn = useCallback(() => {
44+
updateColumns((cols) => {
45+
if (cols.length === 0) {
46+
cols.push(
47+
{ name: 'timestamp', header: 'Timestamp', sortMode: 'timestamp', sort: 'desc' },
48+
{ name: 'line', header: 'Log line', allowWrap: true, enableSorting: false }
49+
);
50+
} else {
51+
cols.push({ name: '' });
52+
}
53+
});
54+
}, [updateColumns]);
55+
56+
const handleRemoveColumn = useCallback(
57+
(index: number) => updateColumns((cols) => cols.splice(index, 1)),
58+
[updateColumns]
59+
);
60+
61+
const handleUpdateColumn = useCallback(
62+
(index: number, updater: (draft: LogsColumnDefinition) => void) => {
63+
updateColumns((cols) => {
64+
if (cols[index]) updater(cols[index]);
65+
});
66+
},
67+
[updateColumns]
68+
);
69+
70+
const handleMoveUp = useCallback(
71+
(index: number) => {
72+
if (index <= 0) return;
73+
updateColumns((cols) => {
74+
const [item] = cols.splice(index, 1);
75+
if (item) cols.splice(index - 1, 0, item);
76+
});
77+
},
78+
[updateColumns]
79+
);
80+
81+
const handleMoveDown = useCallback(
82+
(index: number) => {
83+
if (index >= columns.length - 1) return;
84+
updateColumns((cols) => {
85+
const [item] = cols.splice(index, 1);
86+
if (item) cols.splice(index + 1, 0, item);
87+
});
88+
},
89+
[updateColumns, columns.length]
90+
);
91+
92+
return (
93+
<ColumnsEditor<LogsColumnDefinition>
94+
columns={columns}
95+
description="Timestamp and Log line are shown by default. Add columns below to customize which columns are visible and their order."
96+
sortModeLabels={SORT_MODE_LABELS}
97+
defaultSortMode="alphabetical"
98+
getDisplayName={(col) => col.header || col.name || 'New column'}
99+
getHeaderPlaceholder={(col) => col.name || 'Column header'}
100+
onAdd={handleAddColumn}
101+
onRemove={handleRemoveColumn}
102+
onUpdate={handleUpdateColumn}
103+
onMoveUp={handleMoveUp}
104+
onMoveDown={handleMoveDown}
105+
renderNameField={(col, index, onUpdate) => (
106+
<TextField
107+
label="Column name"
108+
value={col.name}
109+
onChange={(e) =>
110+
onUpdate(index, (draft) => {
111+
draft.name = e.target.value;
112+
})
113+
}
114+
size="small"
115+
fullWidth
116+
helperText="Use 'timestamp', 'line', or a label key"
117+
/>
118+
)}
119+
renderExtraFields={(col, index, onUpdate) => (
120+
<Stack direction="row" spacing={2} alignItems="center">
121+
<TextField
122+
label="Width (px)"
123+
type="number"
124+
value={col.width ?? ''}
125+
onChange={(e) =>
126+
onUpdate(index, (draft) => {
127+
const val = e.target.value ? parseInt(e.target.value, 10) : undefined;
128+
draft.width = val && val > 0 ? val : undefined;
129+
})
130+
}
131+
size="small"
132+
sx={{ width: 120 }}
133+
placeholder="auto"
134+
/>
135+
<FormControlLabel
136+
control={
137+
<Checkbox
138+
checked={col.allowWrap ?? false}
139+
onChange={(e) =>
140+
onUpdate(index, (draft) => {
141+
draft.allowWrap = e.target.checked || undefined;
142+
})
143+
}
144+
size="small"
145+
/>
146+
}
147+
label="Wrap content"
148+
/>
149+
</Stack>
150+
)}
151+
/>
152+
);
153+
}

logstable/src/LogsTableComponent.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,15 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14-
import { ReactElement } from 'react';
14+
import { ReactElement, useMemo } from 'react';
1515
import { Box, Typography } from '@mui/material';
1616
import { LogsTableProps } from './model';
1717
import { LogsList } from './components/LogsList';
1818

1919
export function LogsTableComponent(props: LogsTableProps): ReactElement | null {
2020
const { queryResults, spec } = props;
2121

22-
// all queries results must be included
23-
const logs = queryResults
24-
.flatMap((result) => result?.data.logs?.entries ?? [])
25-
.sort((a, b) => b.timestamp - a.timestamp);
22+
const logs = useMemo(() => queryResults.flatMap((result) => result?.data.logs?.entries ?? []), [queryResults]);
2623

2724
if (!logs.length) {
2825
return (

logstable/src/LogsTablePanel.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ describe('LogsTablePanel', () => {
125125
fireEvent.mouseDown(firstRow, { metaKey: true });
126126
fireEvent.mouseDown(secondRow, { metaKey: true });
127127

128-
// Copy with onCopy event
129-
const virtuosoScroller = screen.getByTestId('virtuoso-scroller');
128+
// Copy with onCopy event — fire on the outer container which has the onCopy handler
129+
const container = items.closest('[class*="MuiBox-root"]')!;
130130
const mockClipboardData = {
131131
setData: jest.fn(),
132132
};
133-
fireEvent.copy(virtuosoScroller, { clipboardData: mockClipboardData });
133+
fireEvent.copy(container, { clipboardData: mockClipboardData });
134134

135135
// Should have copied both logs
136136
expect(mockClipboardData.setData).toHaveBeenCalledWith('text/plain', expect.stringMatching(/foo.*bar/s));

0 commit comments

Comments
 (0)