diff --git a/logstable/package.json b/logstable/package.json index a45abaaaa..c80b00402 100644 --- a/logstable/package.json +++ b/logstable/package.json @@ -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": { diff --git a/logstable/src/LogsTable.ts b/logstable/src/LogsTable.ts index ed63e65d6..d5505afb9 100644 --- a/logstable/src/LogsTable.ts +++ b/logstable/src/LogsTable.ts @@ -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 = { @@ -30,5 +31,8 @@ export const LogsTable: PanelPlugin = { allowWrap: true, enableDetails: true, }), - actions: [{ component: LogsTableExportAction, location: 'header' }], + actions: [ + { component: LogsTableExportAction, location: 'header' }, + { component: LogsTableCsvExportAction, location: 'header' }, + ], }; diff --git a/logstable/src/LogsTableCsvExportAction.test.ts b/logstable/src/LogsTableCsvExportAction.test.ts new file mode 100644 index 000000000..6f985605a --- /dev/null +++ b/logstable/src/LogsTableCsvExportAction.test.ts @@ -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'); + }); +}); diff --git a/logstable/src/LogsTableCsvExportAction.tsx b/logstable/src/LogsTableCsvExportAction.tsx new file mode 100644 index 000000000..2e05dec8b --- /dev/null +++ b/logstable/src/LogsTableCsvExportAction.tsx @@ -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(); + 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 = ({ 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 ( + + + + + + ); +}; diff --git a/package-lock.json b/package-lock.json index 475901799..3d3623e9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18643,6 +18643,7 @@ "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", "@perses-dev/components": "^0.54.0-beta.8", + "@perses-dev/dashboards": "^0.54.0-beta.8", "@perses-dev/plugin-system": "^0.54.0-beta.8", "@perses-dev/spec": "^0.2.0-beta.6", "date-fns": "^4.1.0",