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
2 changes: 1 addition & 1 deletion logstable/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@perses-dev/logs-table-plugin",
"version": "0.3.0-beta.1",
"version": "0.3.0-beta.2",
"license": "Apache-2.0",
"homepage": "https://github.com/perses/plugins/blob/main/README.md",
"repository": {
Expand Down
6 changes: 5 additions & 1 deletion logstable/src/LogsTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { LogsTableComponent } from './LogsTableComponent';
import { LogsTableItemSelectionActionsEditor } from './LogsTableItemSelectionActionsEditor';
import { LogsTableSettingsEditor } from './LogsTableSettingsEditor';
import { LogsTableOptions, LogsTableProps } from './model';
import { LogsTableCsvExportAction } from './LogsTableCsvExportAction';
import { LogsTableExportAction } from './LogsTableExportAction';

export const LogsTable: PanelPlugin<LogsTableOptions, LogsTableProps> = {
Expand All @@ -30,5 +31,8 @@ export const LogsTable: PanelPlugin<LogsTableOptions, LogsTableProps> = {
allowWrap: true,
enableDetails: true,
}),
actions: [{ component: LogsTableExportAction, location: 'header' }],
actions: [
{ component: LogsTableExportAction, location: 'header' },
{ component: LogsTableCsvExportAction, location: 'header' },
],
};
124 changes: 124 additions & 0 deletions logstable/src/LogsTableCsvExportAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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.

jest.mock('echarts/core');
jest.mock('@perses-dev/components', () => ({ InfoTooltip: 'InfoTooltip' }));
jest.mock('@perses-dev/plugin-system', () => {
const csvExport = jest.requireActual('@perses-dev/plugin-system/dist/cjs/utils/csv-export');
return {
escapeCsvValue: csvExport.escapeCsvValue,
formatTimestampISO: csvExport.formatTimestampISO,
sanitizeFilename: csvExport.sanitizeFilename,
};
});

import { LogEntry } from '@perses-dev/spec';
import { collectLabelKeys, buildLogsCsvString } from './LogsTableCsvExportAction';

describe('collectLabelKeys', () => {
it('returns sorted unique label keys from multiple entries', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'log1', labels: { service: 'api', level: 'info' } },
{ timestamp: 1767225601, line: 'log2', labels: { region: 'us-east', level: 'warn' } },
];
expect(collectLabelKeys(entries)).toEqual(['level', 'region', 'service']);
});

it('returns empty array when entries have no labels', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'log1', labels: {} },
{ timestamp: 1767225601, line: 'log2', labels: {} },
];
expect(collectLabelKeys(entries)).toEqual([]);
});

it('handles entries with different label sets (union of all keys)', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'log1', labels: { app: 'web' } },
{ timestamp: 1767225601, line: 'log2', labels: { env: 'prod' } },
{ timestamp: 1767225602, line: 'log3', labels: { host: 'server-1' } },
];
expect(collectLabelKeys(entries)).toEqual(['app', 'env', 'host']);
});

it('deduplicates keys that appear in multiple entries', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'log1', labels: { level: 'info', service: 'api' } },
{ timestamp: 1767225601, line: 'log2', labels: { level: 'warn', service: 'web' } },
{ timestamp: 1767225602, line: 'log3', labels: { level: 'error', service: 'db' } },
];
expect(collectLabelKeys(entries)).toEqual(['level', 'service']);
});
});

describe('buildLogsCsvString', () => {
it('produces correct header row with sorted label columns', () => {
const entries: LogEntry[] = [{ timestamp: 1767225600, line: 'test', labels: { service: 'api', level: 'info' } }];
const csv = buildLogsCsvString(entries);
const headerRow = csv.split('\n')[0];
expect(headerRow).toBe('timestamp,body,level,service');
});

it('formats timestamps as ISO 8601', () => {
const entries: LogEntry[] = [{ timestamp: 1767225600, line: 'test', labels: {} }];
const csv = buildLogsCsvString(entries);
const dataRow = csv.split('\n')[1];
expect(dataRow).toContain('2026-01-01T00:00:00.000Z');
});

it('strips ANSI codes from log lines', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: '\x1b[31mERROR\x1b[0m connection refused', labels: {} },
];
const csv = buildLogsCsvString(entries);
const dataRow = csv.split('\n')[1];
expect(dataRow).not.toContain('\x1b[');
expect(dataRow).toContain('ERROR connection refused');
});

it('escapes values containing commas, quotes, and newlines', () => {
const entries: LogEntry[] = [{ timestamp: 1767225600, line: 'message with "quotes" and, commas', labels: {} }];
const csv = buildLogsCsvString(entries);
const dataRow = csv.split('\n')[1];
expect(dataRow).toContain('"message with ""quotes"" and, commas"');
});

it('handles entries with missing labels (empty string in column)', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'log1', labels: { level: 'info', service: 'api' } },
{ timestamp: 1767225601, line: 'log2', labels: { level: 'warn' } },
];
const csv = buildLogsCsvString(entries);
const lines = csv.split('\n');
expect(lines[0]).toBe('timestamp,body,level,service');
expect(lines[1]).toContain('api');
const secondRowCols = lines[2]!.split(',');
expect(secondRowCols[secondRowCols.length - 1]).toBe('');
});

it('returns header-only string when entries array is empty', () => {
const csv = buildLogsCsvString([]);
expect(csv).toBe('timestamp,body\n');
});

it('handles entries with no labels (only timestamp and body columns)', () => {
const entries: LogEntry[] = [
{ timestamp: 1767225600, line: 'simple log', labels: {} },
{ timestamp: 1767225601, line: 'another log', labels: {} },
];
const csv = buildLogsCsvString(entries);
const lines = csv.split('\n');
expect(lines[0]).toBe('timestamp,body');
expect(lines[1]).toBe('2026-01-01T00:00:00.000Z,simple log');
});
});
88 changes: 88 additions & 0 deletions logstable/src/LogsTableCsvExportAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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 { InfoTooltip } from '@perses-dev/components';
import { IconButton } from '@mui/material';
import FileDelimitedOutline from 'mdi-material-ui/FileDelimitedOutline';
import { escapeCsvValue, formatTimestampISO, sanitizeFilename } from '@perses-dev/plugin-system';
import { LogEntry } from '@perses-dev/spec';
import { useCallback, useMemo } from 'react';
import { LogsTableProps } from './model';
import { stripAnsi } from './utils/ansi';

export function collectLabelKeys(entries: LogEntry[]): string[] {
const keys = new Set<string>();
for (const entry of entries) {
for (const key of Object.keys(entry.labels)) {
keys.add(key);
}
}
return Array.from(keys).sort();
}

export function buildLogsCsvString(entries: LogEntry[]): string {
const labelKeys = collectLabelKeys(entries);

const headerColumns = ['timestamp', 'body', ...labelKeys];
const headerRow = headerColumns.map(escapeCsvValue).join(',');

const dataRows = entries.map((entry) => {
const timestamp = escapeCsvValue(formatTimestampISO(entry.timestamp));
const body = escapeCsvValue(stripAnsi(entry.line));
const labels = labelKeys.map((key) => escapeCsvValue(entry.labels[key] ?? ''));
return [timestamp, body, ...labels].join(',');
});

return [headerRow, ...dataRows].join('\n') + '\n';
}

export const LogsTableCsvExportAction: React.FC<LogsTableProps> = ({ queryResults, definition }) => {
const entries = useMemo(() => {
return queryResults.flatMap((q) => q.data?.logs?.entries ?? []);
}, [queryResults]);

const isDisabled = !entries.length;

const handleDownload = useCallback((): void => {
if (isDisabled) return;
try {
const csvString = buildLogsCsvString(entries);
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const title = definition?.spec?.display?.name || 'Logs Table Data';
const baseFilename = sanitizeFilename(title);
link.download = `${baseFilename}_data.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Logs table CSV export failed:', error);
}
}, [definition, entries, isDisabled]);

return (
<InfoTooltip description={isDisabled ? 'No data to export' : 'Export as CSV'}>
<IconButton
disabled={isDisabled}
size="small"
onClick={handleDownload}
aria-label="Export Logs Table Data as CSV"
>
<FileDelimitedOutline fontSize="inherit" />
</IconButton>
</InfoTooltip>
);
};
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading