Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion logstable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"types": "lib/index.d.ts",
"dependencies": {
"ansi_up": "^6.0.0",
"dompurify": "^3.4.11"
"dompurify": "^3.4.11",
"immer": "^10.1.1"
},
"peerDependencies": {
"@emotion/react": "^11.7.1",
Expand Down
16 changes: 13 additions & 3 deletions logstable/schemas/logstable.cue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ kind: "LogsTable"
spec: close({
allowWrap?: bool
enableDetails?: bool
showTime?: bool
selection?: common.#selection
actions?: common.#actions
// Deprecated: use columns instead. Only effective when columns is not set.
showTime?: bool
columns?: [...close({
name: string
header?: string
enableSorting?: bool
sort?: "asc" | "desc"
sortMode?: "alphabetical" | "numeric" | "timestamp"
allowWrap?: bool
width?: number
})]
selection?: common.#selection
actions?: common.#actions
})
3 changes: 2 additions & 1 deletion logstable/src/LogsTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// limitations under the License.

import { PanelPlugin } from '@perses-dev/plugin-system';
import { LogsTableColumnsEditor } from './LogsTableColumnsEditor';
import { LogsTableComponent } from './LogsTableComponent';
import { LogsTableItemSelectionActionsEditor } from './LogsTableItemSelectionActionsEditor';
import { LogsTableSettingsEditor } from './LogsTableSettingsEditor';
Expand All @@ -22,11 +23,11 @@ export const LogsTable: PanelPlugin<LogsTableOptions, LogsTableProps> = {
PanelComponent: LogsTableComponent,
panelOptionsEditorComponents: [
{ label: 'Settings', content: LogsTableSettingsEditor },
{ label: 'Columns', content: LogsTableColumnsEditor },
{ label: 'Item Actions', content: LogsTableItemSelectionActionsEditor },
],
supportedQueryTypes: ['LogQuery'],
createInitialOptions: () => ({
showTime: true,
allowWrap: true,
enableDetails: true,
}),
Expand Down
151 changes: 151 additions & 0 deletions logstable/src/LogsTableColumnsEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { render, screen, fireEvent } from '@testing-library/react';
import { LogsTableColumnsEditor } from './LogsTableColumnsEditor';
import { LogsTableOptions, LogsColumnDefinition } from './model';

const createProps = (columns?: LogsColumnDefinition[]): { value: LogsTableOptions; onChange: jest.Mock } => {
const value: LogsTableOptions = {
allowWrap: true,
enableDetails: true,
columns,
};
const onChange = jest.fn();
return { value, onChange };
};

describe('LogsTableColumnsEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should render the description text about default columns', () => {
const props = createProps();
render(<LogsTableColumnsEditor {...props} />);
expect(screen.getByText(/Timestamp and Log line are shown by default/i)).toBeInTheDocument();
});

it('should render columns when provided', () => {
const props = createProps([{ name: 'service', header: 'Service' }, { name: 'level' }]);
render(<LogsTableColumnsEditor {...props} />);
expect(screen.getByText('Service')).toBeInTheDocument();
expect(screen.getByText('level')).toBeInTheDocument();
});

it('should pre-populate with timestamp and line columns on first add', () => {
const props = createProps(undefined);
render(<LogsTableColumnsEditor {...props} />);
fireEvent.click(screen.getByRole('button', { name: /add column/i }));
expect(props.onChange).toHaveBeenCalledTimes(1);
const newValue = props.onChange.mock.calls[0][0];
expect(newValue.columns).toHaveLength(2);
expect(newValue.columns[0]).toEqual({
name: 'timestamp',
header: 'Timestamp',
sortMode: 'timestamp',
sort: 'desc',
});
expect(newValue.columns[1]).toEqual({ name: 'line', header: 'Log line', allowWrap: true, enableSorting: false });
});

it('should call onChange with new column appended when add is clicked', () => {
const props = createProps([{ name: 'existing' }]);
render(<LogsTableColumnsEditor {...props} />);
fireEvent.click(screen.getByRole('button', { name: /add column/i }));
expect(props.onChange).toHaveBeenCalledTimes(1);
const newValue = props.onChange.mock.calls[0][0];
expect(newValue.columns).toHaveLength(2);
expect(newValue.columns[1]).toEqual({ name: '' });
});

it('should call onChange with column removed when delete is clicked', () => {
const props = createProps([{ name: 'first' }, { name: 'second' }]);
render(<LogsTableColumnsEditor {...props} />);
const deleteButtons = screen.getAllByLabelText('Delete column');
fireEvent.click(deleteButtons[0]!);
expect(props.onChange).toHaveBeenCalledTimes(1);
const newValue = props.onChange.mock.calls[0][0];
expect(newValue.columns).toHaveLength(1);
expect(newValue.columns[0].name).toBe('second');
});

it('should call onChange with columns reordered when move up is clicked', () => {
const props = createProps([{ name: 'first' }, { name: 'second' }]);
render(<LogsTableColumnsEditor {...props} />);
const moveUpButtons = screen.getAllByLabelText('Move column up');
fireEvent.click(moveUpButtons[1]!); // move second column up
expect(props.onChange).toHaveBeenCalledTimes(1);
const newValue = props.onChange.mock.calls[0][0];
expect(newValue.columns[0].name).toBe('second');
expect(newValue.columns[1].name).toBe('first');
});

it('should call onChange with columns reordered when move down is clicked', () => {
const props = createProps([{ name: 'first' }, { name: 'second' }]);
render(<LogsTableColumnsEditor {...props} />);
const moveDownButtons = screen.getAllByLabelText('Move column down');
fireEvent.click(moveDownButtons[0]!); // move first column down
expect(props.onChange).toHaveBeenCalledTimes(1);
const newValue = props.onChange.mock.calls[0][0];
expect(newValue.columns[0].name).toBe('second');
expect(newValue.columns[1].name).toBe('first');
});

it('should render wrap content checkbox for each column', () => {
const props = createProps([{ name: 'col1' }, { name: 'col2' }]);
render(<LogsTableColumnsEditor {...props} />);
const wrapCheckboxes = screen.getAllByLabelText('Wrap content');
expect(wrapCheckboxes).toHaveLength(2);
});

it('should have wrap content unchecked by default', () => {
const props = createProps([{ name: 'col1' }]);
render(<LogsTableColumnsEditor {...props} />);
const wrapCheckbox = screen.getByLabelText('Wrap content');
expect(wrapCheckbox).not.toBeChecked();
});

it('should have wrap content checked when allowWrap is true', () => {
const props = createProps([{ name: 'col1', allowWrap: true }]);
render(<LogsTableColumnsEditor {...props} />);
const wrapCheckbox = screen.getByLabelText('Wrap content');
expect(wrapCheckbox).toBeChecked();
});

it('should render column name field with helper text', () => {
const props = createProps([{ name: 'service' }]);
render(<LogsTableColumnsEditor {...props} />);
expect(screen.getByText(/Use 'timestamp', 'line', or a label key/)).toBeInTheDocument();
});

it('should display "New column" as display name for empty column', () => {
const props = createProps([{ name: '' }]);
render(<LogsTableColumnsEditor {...props} />);
expect(screen.getByText('New column')).toBeInTheDocument();
});

it('should render with empty columns array when columns is undefined', () => {
const props = createProps(undefined);
render(<LogsTableColumnsEditor {...props} />);
expect(screen.getByRole('button', { name: /add column/i })).toBeInTheDocument();
expect(screen.queryByLabelText('Enable sorting')).not.toBeInTheDocument();
});

it('should render sort mode options including Alphabetical, Numeric, and Timestamp', () => {
const props = createProps([{ name: 'col1' }]);
render(<LogsTableColumnsEditor {...props} />);
// The sort mode select should be present
expect(screen.getByLabelText('Sort mode')).toBeInTheDocument();
});
});
153 changes: 153 additions & 0 deletions logstable/src/LogsTableColumnsEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Checkbox, FormControlLabel, Stack, TextField } from '@mui/material';
import { OptionsEditorProps } from '@perses-dev/plugin-system';
import { produce } from 'immer';
import { ReactElement, useCallback } from 'react';
import { ColumnsEditor } from './components/ColumnsEditor';
import { LogsTableOptions, LogsColumnDefinition, LogsColumnSortMode } from './model';

const SORT_MODE_LABELS: Record<LogsColumnSortMode, string> = {
alphabetical: 'Alphabetical',
numeric: 'Numeric',
timestamp: 'Timestamp',
};

export function LogsTableColumnsEditor(props: OptionsEditorProps<LogsTableOptions>): ReactElement {
const { value, onChange } = props;
const columns = value.columns ?? [];

const updateColumns = useCallback(
(recipe: (draft: LogsColumnDefinition[]) => void) => {
onChange(
produce(value, (draft) => {
if (!draft.columns) draft.columns = [];
recipe(draft.columns);
})
);
},
[value, onChange]
);

const handleAddColumn = useCallback(() => {
updateColumns((cols) => {
if (cols.length === 0) {
cols.push(
{ name: 'timestamp', header: 'Timestamp', sortMode: 'timestamp', sort: 'desc' },
{ name: 'line', header: 'Log line', allowWrap: true, enableSorting: false }
);
} else {
cols.push({ name: '' });
}
});
}, [updateColumns]);

const handleRemoveColumn = useCallback(
(index: number) => updateColumns((cols) => cols.splice(index, 1)),
[updateColumns]
);

const handleUpdateColumn = useCallback(
(index: number, updater: (draft: LogsColumnDefinition) => void) => {
updateColumns((cols) => {
if (cols[index]) updater(cols[index]);
});
},
[updateColumns]
);

const handleMoveUp = useCallback(
(index: number) => {
if (index <= 0) return;
updateColumns((cols) => {
const [item] = cols.splice(index, 1);
if (item) cols.splice(index - 1, 0, item);
});
},
[updateColumns]
);

const handleMoveDown = useCallback(
(index: number) => {
if (index >= columns.length - 1) return;
updateColumns((cols) => {
const [item] = cols.splice(index, 1);
if (item) cols.splice(index + 1, 0, item);
});
},
[updateColumns, columns.length]
);

return (
<ColumnsEditor<LogsColumnDefinition>
columns={columns}
description="Timestamp and Log line are shown by default. Add columns below to customize which columns are visible and their order."
sortModeLabels={SORT_MODE_LABELS}
defaultSortMode="alphabetical"
getDisplayName={(col) => col.header || col.name || 'New column'}
getHeaderPlaceholder={(col) => col.name || 'Column header'}
onAdd={handleAddColumn}
onRemove={handleRemoveColumn}
onUpdate={handleUpdateColumn}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
renderNameField={(col, index, onUpdate) => (
<TextField
label="Column name"
value={col.name}
onChange={(e) =>
onUpdate(index, (draft) => {
draft.name = e.target.value;
})
}
size="small"
fullWidth
helperText="Use 'timestamp', 'line', or a label key"
/>
)}
renderExtraFields={(col, index, onUpdate) => (
<Stack direction="row" spacing={2} alignItems="center">
<TextField
label="Width (px)"
type="number"
value={col.width ?? ''}
onChange={(e) =>
onUpdate(index, (draft) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined;
draft.width = val && val > 0 ? val : undefined;
})
}
size="small"
sx={{ width: 120 }}
placeholder="auto"
/>
<FormControlLabel
control={
<Checkbox
checked={col.allowWrap ?? false}
onChange={(e) =>
onUpdate(index, (draft) => {
draft.allowWrap = e.target.checked || undefined;
})
}
size="small"
/>
}
label="Wrap content"
/>
</Stack>
)}
/>
);
}
7 changes: 2 additions & 5 deletions logstable/src/LogsTableComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { ReactElement } from 'react';
import { ReactElement, useMemo } from 'react';
import { Box, Typography } from '@mui/material';
import { LogsTableProps } from './model';
import { LogsList } from './components/LogsList';

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

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

if (!logs.length) {
return (
Expand Down
6 changes: 3 additions & 3 deletions logstable/src/LogsTablePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,12 @@ describe('LogsTablePanel', () => {
fireEvent.mouseDown(firstRow, { metaKey: true });
fireEvent.mouseDown(secondRow, { metaKey: true });

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

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