diff --git a/package-lock.json b/package-lock.json index 8dc9ca21..b14b6ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17513,15 +17513,15 @@ "name": "@perses-dev/timeseries-chart-plugin", "version": "0.9.1", "dependencies": { + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "@perses-dev/plugin-system": "0.52.0-beta.1", "color-hash": "^2.0.2" }, "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", - "@perses-dev/components": "^0.51.0-rc.1", - "@perses-dev/core": "^0.51.0-rc.1", - "@perses-dev/plugin-system": "^0.51.0-rc.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "echarts": "5.5.0", @@ -17532,6 +17532,80 @@ "use-resize-observer": "^9.0.0" } }, + "timeserieschart/node_modules/@perses-dev/components": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/components/-/components-0.52.0-beta.1.tgz", + "integrity": "sha512-Bm8oM9rC2GR3SZhieeSau/mkopOjfxZB3mmubLDkTBBRb/M39X+YEkS4XidkHt+HnSMl5bRhGNqxM8RWxKcm5A==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@codemirror/lang-json": "^6.0.1", + "@fontsource/lato": "^4.5.10", + "@mui/x-date-pickers": "^7.23.1", + "@perses-dev/core": "0.52.0-beta.1", + "@tanstack/react-table": "^8.20.5", + "@uiw/react-codemirror": "^4.19.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "echarts": "5.5.0", + "immer": "^10.1.1", + "lodash": "^4.17.21", + "mathjs": "^10.6.4", + "mdi-material-ui": "^7.9.2", + "notistack": "^3.0.2", + "react-colorful": "^5.6.1", + "react-error-boundary": "^3.1.4", + "react-hook-form": "^7.51.3", + "react-virtuoso": "^4.12.2" + }, + "peerDependencies": { + "@mui/material": "^6.1.10", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, + "timeserieschart/node_modules/@perses-dev/core": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/core/-/core-0.52.0-beta.1.tgz", + "integrity": "sha512-4RhldTO0PtY72n0c2/pDkpVuN91VtDSGZXherojiF9RMPW+MaYgnWXWPOrMv7iujqgSm97D564RVdNWb/xc55w==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "lodash": "^4.17.21", + "mathjs": "^10.6.4", + "numbro": "^2.3.6", + "zod": "^3.21.4" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, + "timeserieschart/node_modules/@perses-dev/plugin-system": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/plugin-system/-/plugin-system-0.52.0-beta.1.tgz", + "integrity": "sha512-m5AX5bwd2HJcL9NKU/Y+mo+7GzRTTuthrDsIQcawqXMp2TXlBkrgP59vupTxQxb59f9fgf76/X4knhWFmGodOA==", + "license": "Apache-2.0", + "dependencies": { + "@module-federation/enhanced": "^0.14.3", + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "immer": "^10.1.1", + "react-hook-form": "^7.46.1", + "use-immer": "^0.11.0", + "use-query-params": "^2.1.2", + "zod": "^3.22.2" + }, + "peerDependencies": { + "@mui/material": "^6.1.10", + "@tanstack/react-query": "^4.39.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, "timeseriestable": { "name": "@perses-dev/timeseries-table-plugin", "version": "0.8.0", diff --git a/timeserieschart/package.json b/timeserieschart/package.json index 12e37c60..05bb6251 100644 --- a/timeserieschart/package.json +++ b/timeserieschart/package.json @@ -24,15 +24,15 @@ "module": "lib/index.js", "types": "lib/index.d.ts", "dependencies": { - "color-hash": "^2.0.2" + "color-hash": "^2.0.2", + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "@perses-dev/plugin-system": "0.52.0-beta.1" }, "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", - "@perses-dev/components": "^0.51.0-rc.1", - "@perses-dev/core": "^0.51.0-rc.1", - "@perses-dev/plugin-system": "^0.51.0-rc.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "echarts": "5.5.0", diff --git a/timeserieschart/src/CSVExportUtils.ts b/timeserieschart/src/CSVExportUtils.ts new file mode 100644 index 00000000..f2c7d100 --- /dev/null +++ b/timeserieschart/src/CSVExportUtils.ts @@ -0,0 +1,305 @@ +// Copyright 2023 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. + +export interface SeriesDataPoint { + timestamp: number | string; + value: unknown; +} + +export interface DataSeries { + name?: string; + formattedName?: string; + legendName?: string; + displayName?: string; + legend?: string; + labels?: Record; + values: Array<[number | string, unknown]> | SeriesDataPoint[]; +} + +export interface ExportableData { + series: DataSeries[]; + timeRange?: { + start: string | number; + end: string | number; + }; + stepMs?: number; + metadata?: Record; +} + +export const isExportableData = (data: unknown): data is ExportableData => { + return !!( + data && + typeof data === 'object' && + 'series' in data && + Array.isArray((data as ExportableData).series) && + (data as ExportableData).series.length > 0 + ); +}; + +export interface QueryDataInput { + data?: unknown; + error?: unknown; + isFetching?: boolean; +} + +export const extractExportableData = (queryResults: QueryDataInput[]): ExportableData | undefined => { + if (!queryResults || queryResults.length === 0) return undefined; + + const allSeries: DataSeries[] = []; + let timeRange: ExportableData['timeRange'] = undefined; + let stepMs: number | undefined = undefined; + let metadata: ExportableData['metadata'] = undefined; + + queryResults.forEach((query) => { + if (query?.data && typeof query.data === 'object' && 'series' in query.data) { + const data = query.data as ExportableData; + if (data.series && Array.isArray(data.series) && data.series.length > 0) { + allSeries.push(...data.series); + if (!timeRange && data.timeRange) { + timeRange = data.timeRange; + } + if (!stepMs && data.stepMs) { + stepMs = data.stepMs; + } + if (!metadata && data.metadata) { + metadata = data.metadata; + } + } + } + }); + + if (allSeries.length > 0) { + return { + series: allSeries, + timeRange, + stepMs, + metadata, + }; + } + + return undefined; +}; + +export const formatLegendName = (series: DataSeries, seriesIndex: number): string => { + const seriesAny = series as DataSeries & { + formattedName?: string; + legendName?: string; + displayName?: string; + legend?: string; + labels?: Record; + }; + + let legendName = series.formattedName || series.name; + + if (!legendName || legendName === `Series ${seriesIndex + 1}`) { + legendName = seriesAny.legendName || seriesAny.displayName || seriesAny.legend || series.name || ''; + } + + if ((!legendName || legendName === series.name) && series.labels) { + const labels = series.labels; + const displayLabels = { ...labels }; + const metricName = displayLabels.__name__; + delete displayLabels.__name__; + + const labelPairs = Object.entries(displayLabels) + .filter(([value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${key}="${value}"`) + .join(', '); + + if (metricName && labelPairs) { + legendName = `${metricName}{${labelPairs}}`; + } else if (metricName) { + legendName = metricName; + } else if (labelPairs) { + legendName = `{${labelPairs}}`; + } else { + legendName = labels.job || labels.instance || labels.metric || `Series ${seriesIndex + 1}`; + } + } + + if (!legendName || legendName.trim() === '') { + legendName = `Series ${seriesIndex + 1}`; + } + + return legendName; +}; + +export const sanitizeColumnName = (name: string): string => { + return name + .replace(/[,"\n\r]/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .substring(0, 255); +}; + +export const sanitizeFilename = (filename: string): string => { + return filename + .replace(/[<>:"/\\|?*]/g, ' ') + .trim() + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); +}; + +export const formatTimestampISO = (timestamp: number | string): string => { + let timestampMs: number; + + if (typeof timestamp === 'string') { + const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return timestamp; + } + timestampMs = date.getTime(); + } else { + timestampMs = timestamp > 1e10 ? timestamp : timestamp * 1000; + } + + const date = new Date(timestampMs); + if (isNaN(date.getTime())) { + return new Date(timestampMs).toISOString(); + } + + return date.toISOString(); +}; + +export const escapeCsvValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + + const stringValue = String(value); + + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') || + stringValue.includes('\r') + ) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +}; + +export interface ExportDataOptions { + data: ExportableData; +} + +export const exportDataAsCSV = ({ data }: ExportDataOptions): Blob => { + if (!isExportableData(data)) { + console.warn('No valid data found to export to CSV.'); + return new Blob([''], { type: 'text/csv;charset=utf-8' }); + } + + let csvString = ''; + const result: Record> = {}; + const seriesInfo: Array<{ legendName: string; columnName: string; originalName: string }> = []; + let validSeriesCount = 0; + + for (let i = 0; i < data.series.length; i++) { + const series = data.series[i]; + + if (!series) { + continue; + } + + if (!Array.isArray(series.values) || series.values.length === 0) { + continue; + } + + const legendName = formatLegendName(series, i); + const columnName = sanitizeColumnName(legendName); + + const currentSeriesInfo = { + legendName, + columnName: columnName || `Series_${i + 1}`, + originalName: series.name || '', + }; + + seriesInfo.push(currentSeriesInfo); + validSeriesCount++; + + for (let j = 0; j < series.values.length; j++) { + const entry = series.values[j]; + + let timestamp: number | string; + let value: unknown; + + if (Array.isArray(entry) && entry.length >= 2) { + timestamp = entry[0]; + value = entry[1]; + } else if (typeof entry === 'object' && entry !== null && 'timestamp' in entry && 'value' in entry) { + const dataPoint = entry as SeriesDataPoint; + timestamp = dataPoint.timestamp; + value = dataPoint.value; + } else { + continue; + } + + if (value === null || value === undefined) { + continue; + } + + const dateTime = formatTimestampISO(timestamp); + + if (!result[dateTime]) { + result[dateTime] = {}; + } + + result[dateTime][currentSeriesInfo.columnName] = value; + } + } + + if (validSeriesCount === 0 || seriesInfo.length === 0) { + console.warn('No valid data found to export to CSV.'); + return new Blob([''], { type: 'text/csv;charset=utf-8' }); + } + + const timestampCount = Object.keys(result).length; + if (timestampCount === 0) { + console.warn('No valid timestamp data found to export to CSV.'); + return new Blob([''], { type: 'text/csv;charset=utf-8' }); + } + + const columnNames = seriesInfo.map((info) => info.columnName); + csvString += `DateTime,${columnNames.join(',')}\n`; + + const sortedDateTimes = Object.keys(result).sort((a, b) => { + const dateA = new Date(a).getTime(); + const dateB = new Date(b).getTime(); + return dateA - dateB; + }); + + for (const dateTime of sortedDateTimes) { + const rowData = result[dateTime]; + const values: string[] = []; + + if (rowData) { + for (const columnName of columnNames) { + const value = rowData[columnName]; + values.push(escapeCsvValue(value)); + } + + csvString += `${escapeCsvValue(dateTime)},${values.join(',')}\n`; + } + } + + return new Blob([csvString], { type: 'text/csv;charset=utf-8' }); +}; diff --git a/timeserieschart/src/TimeSeriesChart.ts b/timeserieschart/src/TimeSeriesChart.ts index 68f96784..102448c3 100644 --- a/timeserieschart/src/TimeSeriesChart.ts +++ b/timeserieschart/src/TimeSeriesChart.ts @@ -15,13 +15,17 @@ import { PanelPlugin } from '@perses-dev/plugin-system'; import { createInitialTimeSeriesChartOptions, TimeSeriesChartOptions } from './time-series-chart-model'; import { TimeSeriesChartOptionsEditorSettings } from './TimeSeriesChartOptionsEditorSettings'; import { TimeSeriesChartPanel, TimeSeriesChartProps } from './TimeSeriesChartPanel'; +import { TimeSeriesExportAction } from './TimeSeriesExportAction'; -/** - * The core TimeSeriesChart panel plugin for Perses. - */ export const TimeSeriesChart: PanelPlugin = { PanelComponent: TimeSeriesChartPanel, supportedQueryTypes: ['TimeSeriesQuery'], panelOptionsEditorComponents: [{ label: 'Settings', content: TimeSeriesChartOptionsEditorSettings }], createInitialOptions: createInitialTimeSeriesChartOptions, + actions: [ + { + component: TimeSeriesExportAction, + location: 'header', + }, + ], }; diff --git a/timeserieschart/src/TimeSeriesExportAction.tsx b/timeserieschart/src/TimeSeriesExportAction.tsx new file mode 100644 index 00000000..ab6cfcf1 --- /dev/null +++ b/timeserieschart/src/TimeSeriesExportAction.tsx @@ -0,0 +1,65 @@ +// Copyright 2023 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 React, { useCallback, useMemo } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import DownloadIcon from 'mdi-material-ui/Download'; +import { TimeSeriesChartProps } from './TimeSeriesChartPanel'; +import { extractExportableData, isExportableData, sanitizeFilename, exportDataAsCSV } from './CSVExportUtils'; + +export const TimeSeriesExportAction: React.FC = ({ queryResults, definition }) => { + const exportableData = useMemo(() => { + return extractExportableData(queryResults); + }, [queryResults]); + + const canExport = useMemo(() => { + return isExportableData(exportableData); + }, [exportableData]); + + const handleExport = useCallback(() => { + if (!exportableData || !canExport) return; + + try { + const title = definition?.spec?.display?.name || 'Time Series Data'; + + const csvBlob = exportDataAsCSV({ + data: exportableData, + }); + + const baseFilename = sanitizeFilename(title); + const filename = `${baseFilename}_data.csv`; + + const link = document.createElement('a'); + link.href = URL.createObjectURL(csvBlob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + } catch (error) { + console.error('Time series export failed:', error); + } + }, [exportableData, canExport, definition]); + + if (!canExport) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/timeserieschart/src/index.ts b/timeserieschart/src/index.ts index 0039b81a..46c4f6ec 100644 --- a/timeserieschart/src/index.ts +++ b/timeserieschart/src/index.ts @@ -20,3 +20,4 @@ export * from './YAxisOptionsEditor'; export * from './TimeSeriesChartPanel'; export * from './TimeSeriesChartBase'; export * from './time-series-chart-model'; +export * from './CSVExportUtils';