diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index 9b45801356..248819723c 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-loop-func */ import './BillingContainer.styles.scss'; -import { CheckCircleOutlined } from '@ant-design/icons'; +import { CheckCircleOutlined, CloudDownloadOutlined } from '@ant-design/icons'; import { Color } from '@signozhq/design-tokens'; import { Alert, @@ -40,6 +40,7 @@ import { isCloudUser } from 'utils/app'; import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph'; +import { prepareCsvData } from './BillingUsageGraph/utils'; interface DataType { key: string; @@ -371,6 +372,37 @@ export default function BillingContainer(): JSX.Element { ); + const handleCsvDownload = useCallback((): void => { + try { + const csv = prepareCsvData(apiResponse); + + if (!csv.csvData || !csv.fileName) { + throw new Error('Invalid CSV data or file name.'); + } + + const csvBlob = new Blob([csv.csvData], { type: 'text/csv;charset=utf-8;' }); + const csvUrl = URL.createObjectURL(csvBlob); + const downloadLink = document.createElement('a'); + + downloadLink.href = csvUrl; + downloadLink.download = csv.fileName; + document.body.appendChild(downloadLink); // Required for Firefox + downloadLink.click(); + + // Clean up + downloadLink.remove(); + URL.revokeObjectURL(csvUrl); // Release the memory associated with the object URL + notifications.success({ + message: 'Download successful', + }); + } catch (error) { + console.error('Error downloading the CSV file:', error); + notifications.error({ + message: SOMETHING_WENT_WRONG, + }); + } + }, [apiResponse, notifications]); + return (
@@ -399,17 +431,29 @@ export default function BillingContainer(): JSX.Element { ) : null} - + + + + {licensesData?.payload?.onTrial && diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/generateCsvData.ts b/frontend/src/container/BillingContainer/BillingUsageGraph/generateCsvData.ts new file mode 100644 index 0000000000..b70526aaa8 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/generateCsvData.ts @@ -0,0 +1,129 @@ +import dayjs from 'dayjs'; + +export interface QuantityData { + metric: string; + values: [number, number][]; + queryName: string; + legend: string; + quantity: number[]; + unit: string; +} + +interface DataPoint { + date: string; + metric: { + total: number; + cost: number; + }; + trace: { + total: number; + cost: number; + }; + log: { + total: number; + cost: number; + }; +} + +interface CsvData { + Date: string; + 'Metrics Vol (Mn samples)': number; + 'Metrics Cost ($)': number; + 'Traces Vol (GBs)': number; + 'Traces Cost ($)': number; + 'Logs Vol (GBs)': number; + 'Logs Cost ($)': number; +} + +const formatDate = (timestamp: number): string => + dayjs.unix(timestamp).format('MM/DD/YYYY'); + +const getQuantityData = ( + data: QuantityData[], + metricName: string, +): QuantityData => { + const defaultData: QuantityData = { + metric: metricName, + values: [], + queryName: metricName, + legend: metricName, + quantity: [], + unit: '', + }; + return data.find((d) => d.metric === metricName) || defaultData; +}; + +const generateCsvData = (quantityData: QuantityData[]): any[] => { + const convertData = (data: QuantityData[]): DataPoint[] => { + const metricsData = getQuantityData(data, 'Metrics'); + const tracesData = getQuantityData(data, 'Traces'); + const logsData = getQuantityData(data, 'Logs'); + + const timestamps = metricsData.values.map((value) => value[0]); + + return timestamps.map((timestamp, index) => { + const date = formatDate(timestamp); + + return { + date, + metric: { + total: metricsData.quantity[index] ?? 0, + cost: metricsData.values[index]?.[1] ?? 0, + }, + trace: { + total: tracesData.quantity[index] ?? 0, + cost: tracesData.values[index]?.[1] ?? 0, + }, + log: { + total: logsData.quantity[index] ?? 0, + cost: logsData.values[index]?.[1] ?? 0, + }, + }; + }); + }; + + const formattedData = convertData(quantityData); + + // Calculate totals + const totals = formattedData.reduce( + (acc, dataPoint) => { + acc.metric.total += dataPoint.metric.total; + acc.metric.cost += dataPoint.metric.cost; + acc.trace.total += dataPoint.trace.total; + acc.trace.cost += dataPoint.trace.cost; + acc.log.total += dataPoint.log.total; + acc.log.cost += dataPoint.log.cost; + return acc; + }, + { + metric: { total: 0, cost: 0 }, + trace: { total: 0, cost: 0 }, + log: { total: 0, cost: 0 }, + }, + ); + + const csvData: CsvData[] = formattedData.map((dataPoint) => ({ + Date: dataPoint.date, + 'Metrics Vol (Mn samples)': parseFloat(dataPoint.metric.total.toFixed(2)), + 'Metrics Cost ($)': parseFloat(dataPoint.metric.cost.toFixed(2)), + 'Traces Vol (GBs)': parseFloat(dataPoint.trace.total.toFixed(2)), + 'Traces Cost ($)': parseFloat(dataPoint.trace.cost.toFixed(2)), + 'Logs Vol (GBs)': parseFloat(dataPoint.log.total.toFixed(2)), + 'Logs Cost ($)': parseFloat(dataPoint.log.cost.toFixed(2)), + })); + + // Add totals row + csvData.push({ + Date: 'Total', + 'Metrics Vol (Mn samples)': parseFloat(totals.metric.total.toFixed(2)), + 'Metrics Cost ($)': parseFloat(totals.metric.cost.toFixed(2)), + 'Traces Vol (GBs)': parseFloat(totals.trace.total.toFixed(2)), + 'Traces Cost ($)': parseFloat(totals.trace.cost.toFixed(2)), + 'Logs Vol (GBs)': parseFloat(totals.log.total.toFixed(2)), + 'Logs Cost ($)': parseFloat(totals.log.cost.toFixed(2)), + }); + + return csvData; +}; + +export default generateCsvData; diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts index d40c8a6097..5123d59329 100644 --- a/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts @@ -1,6 +1,12 @@ +import { UsageResponsePayloadProps } from 'api/billing/getUsage'; +import dayjs from 'dayjs'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; import { isEmpty, isNull } from 'lodash-es'; +import { unparse } from 'papaparse'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import generateCsvData, { QuantityData } from './generateCsvData'; + export const convertDataToMetricRangePayload = ( data: any, ): MetricRangePayloadProps => { @@ -58,10 +64,7 @@ export const convertDataToMetricRangePayload = ( }; }; -export function fillMissingValuesForQuantities( - data: any, - timestampArray: number[], -): MetricRangePayloadProps { +export function quantityDataArr(data: any, timestampArray: number[]): any[] { const { result } = data.data; const transformedResultArr: any[] = []; @@ -76,6 +79,14 @@ export function fillMissingValuesForQuantities( ); transformedResultArr.push({ ...item, quantity: quantityArray }); }); + return transformedResultArr; +} + +export function fillMissingValuesForQuantities( + data: any, + timestampArray: number[], +): MetricRangePayloadProps { + const transformedResultArr = quantityDataArr(data, timestampArray); return { data: { @@ -85,3 +96,36 @@ export function fillMissingValuesForQuantities( }, }; } + +const formatDate = (timestamp: number): string => + dayjs.unix(timestamp).format('MM/DD/YYYY'); + +export function csvFileName(csvData: QuantityData[]): string { + if (!csvData.length) { + return `billing-usage.csv`; + } + + const { values } = csvData[0]; + + const timestamps = values.map((item) => item[0]); + const startDate = formatDate(Math.min(...timestamps)); + const endDate = formatDate(Math.max(...timestamps)); + + return `billing_usage_(${startDate}-${endDate}).csv`; +} + +export function prepareCsvData( + data: Partial, +): { + csvData: string; + fileName: string; +} { + const graphCompatibleData = convertDataToMetricRangePayload(data); + const chartData = getUPlotChartData(graphCompatibleData); + const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]); + + return { + csvData: unparse(generateCsvData(quantityMapArr)), + fileName: csvFileName(quantityMapArr), + }; +}