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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export type SortDirection = 'asc' | 'desc';
export type SilenceColumnSortMode = 'alphabetical' | 'date' | 'status';

export type SilenceFieldName =
'status' | 'matchers' | 'createdBy' | 'startsAt' | 'endsAt' | 'duration' | 'comment' | 'updatedAt';
| 'status'
| 'matchers'
| 'createdBy'
| 'startsAt'
| 'endsAt'
| 'duration'
| 'comment'
| 'updatedAt';

export interface SilenceColumnDefinition {
name: SilenceFieldName;
Expand Down
16 changes: 9 additions & 7 deletions jaeger/src/plugins/get-trace-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,15 @@ const makeClient = (): JaegerClient => {
return client;
};

const getDatasource: jest.Mock = jest.fn((): DatasourceSpec<typeof datasource> => ({
default: false,
plugin: {
kind: 'JaegerDatasource',
spec: datasource,
},
}));
const getDatasource: jest.Mock = jest.fn(
(): DatasourceSpec<typeof datasource> => ({
default: false,
plugin: {
kind: 'JaegerDatasource',
spec: datasource,
},
})
);

function createContext(client: JaegerClient): TraceQueryContext {
return {
Expand Down
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 @@ -23,11 +24,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) => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why passing name field, width and wrap as props? 🤔

<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>
)}
/>
);
}
Loading
Loading