mirror of https://github.com/buster-so/buster.git
Merge remote-tracking branch 'origin/big-nate-bus-1895-unify-download-csv-with-the-5000-records-logic' into dallin-bus-1898-metric-download-endpoint-should-accept-report-id
This commit is contained in:
commit
b5041606cd
|
@ -1,4 +1,4 @@
|
|||
import { MetricDownloadParamsSchema } from '@buster/server-shared';
|
||||
import { MetricDownloadParamsSchema, MetricDownloadQueryParamsSchema } from '@buster/server-shared';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { standardErrorHandler } from '../../../../../utils/response';
|
||||
|
@ -6,14 +6,20 @@ import { downloadMetricFileHandler } from './download-metric-file';
|
|||
|
||||
const app = new Hono()
|
||||
// GET /metric_files/:id/download - Download metric file data as CSV
|
||||
.get('/', zValidator('param', MetricDownloadParamsSchema), async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const user = c.get('busterUser');
|
||||
.get(
|
||||
'/',
|
||||
zValidator('param', MetricDownloadParamsSchema),
|
||||
zValidator('query', MetricDownloadQueryParamsSchema),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
// const { report_file_id } = c.req.valid('query');
|
||||
const user = c.get('busterUser');
|
||||
|
||||
const response = await downloadMetricFileHandler(id, user);
|
||||
const response = await downloadMetricFileHandler(id, user);
|
||||
|
||||
return c.json(response);
|
||||
})
|
||||
return c.json(response);
|
||||
}
|
||||
)
|
||||
.onError(standardErrorHandler);
|
||||
|
||||
export default app;
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
setProtectedAssetPasswordError,
|
||||
useProtectedAssetPassword,
|
||||
} from '@/context/BusterAssets/useProtectedAssetStore';
|
||||
import { useBusterNotifications } from '@/context/BusterNotifications';
|
||||
import { setOriginalMetric } from '@/context/Metrics/useOriginalMetricStore';
|
||||
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
||||
import { upgradeMetricToIMetric } from '@/lib/metrics';
|
||||
|
@ -226,8 +227,20 @@ export const usePrefetchGetMetricDataClient = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export const useDownloadMetricFile = () => {
|
||||
export const useDownloadMetricFile = (downloadImmediate = true) => {
|
||||
const { openInfoMessage } = useBusterNotifications();
|
||||
return useMutation({
|
||||
mutationFn: downloadMetricFile,
|
||||
onSuccess: (data) => {
|
||||
if (downloadImmediate) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.downloadUrl;
|
||||
link.download = ''; // This will use the filename from the response-content-disposition header
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
openInfoMessage(`Downloading ${data.rowCount} records...`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -12,6 +12,8 @@ import type {
|
|||
GetMetricResponse,
|
||||
ListMetricsResponse,
|
||||
MetricDataResponse,
|
||||
MetricDownloadParams,
|
||||
MetricDownloadQueryParams,
|
||||
MetricDownloadResponse,
|
||||
ShareDeleteResponse,
|
||||
ShareUpdateResponse,
|
||||
|
@ -100,8 +102,11 @@ export const updateMetricShare = async ({
|
|||
};
|
||||
|
||||
// Download metric file
|
||||
export const downloadMetricFile = async (id: string): Promise<MetricDownloadResponse> => {
|
||||
export const downloadMetricFile = async ({
|
||||
id,
|
||||
...params
|
||||
}: MetricDownloadParams & MetricDownloadQueryParams): Promise<MetricDownloadResponse> => {
|
||||
return mainApiV2
|
||||
.get<MetricDownloadResponse>(`/metric_files/${id}/download`)
|
||||
.get<MetricDownloadResponse>(`/metric_files/${id}/download`, { params })
|
||||
.then((res) => res.data);
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Route as AuthRoute } from '@/routes/auth.login';
|
|||
import { BASE_URL_V2 } from './config';
|
||||
import { rustErrorHandler } from './errors';
|
||||
|
||||
const AXIOS_TIMEOUT = 180000; // 3 minutes
|
||||
const AXIOS_TIMEOUT = 120000; // 2 minutes
|
||||
|
||||
export const createAxiosInstance = (baseURL = BASE_URL_V2) => {
|
||||
const apiInstance = axios.create({
|
||||
|
|
|
@ -1,53 +1,30 @@
|
|||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDownloadMetricFile } from '@/api/buster_rest/metrics/getMetricQueryRequests';
|
||||
import { Button } from '@/components/ui/buttons';
|
||||
import { CircleWarning, Download4 } from '@/components/ui/icons';
|
||||
import { Text } from '@/components/ui/typography';
|
||||
import { useGetReportParams } from '@/context/Reports/useGetReportParams';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
|
||||
interface MetricDataTruncatedWarningProps {
|
||||
className?: string;
|
||||
metricId: string;
|
||||
metricVersionNumber: number | undefined;
|
||||
}
|
||||
|
||||
export const MetricDataTruncatedWarning: React.FC<MetricDataTruncatedWarningProps> = ({
|
||||
className,
|
||||
metricId,
|
||||
metricVersionNumber,
|
||||
}) => {
|
||||
const {
|
||||
mutateAsync: downloadMetricFile,
|
||||
mutateAsync: handleDownload,
|
||||
isPending: isGettingFile,
|
||||
error: downloadError,
|
||||
} = useDownloadMetricFile();
|
||||
|
||||
const hasError = !!downloadError;
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
// Create a timeout promise that rejects after 2 minutes (matching backend timeout)
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Download timeout')), 2 * 60 * 1000); // 2 minutes
|
||||
});
|
||||
|
||||
// Race between the API call and the timeout
|
||||
const response = (await Promise.race([
|
||||
downloadMetricFile(metricId),
|
||||
timeoutPromise,
|
||||
])) as Awaited<ReturnType<typeof downloadMetricFile>>;
|
||||
|
||||
// Create a temporary anchor element to trigger download without navigation
|
||||
const link = document.createElement('a');
|
||||
link.href = response.downloadUrl;
|
||||
link.download = ''; // This will use the filename from the response-content-disposition header
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Failed to download metric file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
@ -67,7 +44,7 @@ export const MetricDataTruncatedWarning: React.FC<MetricDataTruncatedWarningProp
|
|||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
onClick={() => handleDownload({ id: metricId, metric_version_number: metricVersionNumber })}
|
||||
loading={isGettingFile}
|
||||
variant={hasError ? 'danger' : 'default'}
|
||||
className="ml-4"
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import type { BusterMetric, BusterMetricData } from '@/api/asset_interfaces/metric';
|
||||
import { useGetMetric, useGetMetricData } from '@/api/buster_rest/metrics';
|
||||
import type { BusterMetricData } from '@/api/asset_interfaces/metric';
|
||||
import { useGetMetricData } from '@/api/buster_rest/metrics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MetricChartCard } from '../MetricChartCard';
|
||||
import { MetricChartEvaluation } from './MetricChartEvaluation';
|
||||
import { MetricDataTruncatedWarning } from './MetricDataTruncatedWarning';
|
||||
|
||||
const stableMetricSelect = ({ evaluation_score, evaluation_summary }: BusterMetric) => ({
|
||||
evaluation_score,
|
||||
evaluation_summary,
|
||||
});
|
||||
const stableMetricDataSelect = (x: BusterMetricData) => x?.has_more_records;
|
||||
|
||||
export const MetricViewChart: React.FC<{
|
||||
|
@ -21,10 +15,6 @@ export const MetricViewChart: React.FC<{
|
|||
cardClassName?: string;
|
||||
}> = React.memo(
|
||||
({ metricId, versionNumber, readOnly = false, className = '', cardClassName = '' }) => {
|
||||
const { data: metric } = useGetMetric(
|
||||
{ id: metricId, versionNumber },
|
||||
{ select: stableMetricSelect, enabled: true }
|
||||
);
|
||||
const { data: hasMoreRecords } = useGetMetricData(
|
||||
{ id: metricId, versionNumber },
|
||||
{ select: stableMetricDataSelect }
|
||||
|
@ -39,13 +29,10 @@ export const MetricViewChart: React.FC<{
|
|||
readOnly={readOnly}
|
||||
className={cardClassName}
|
||||
/>
|
||||
{hasMoreRecords && <MetricDataTruncatedWarning metricId={metricId} />}
|
||||
{hasMoreRecords && (
|
||||
<MetricDataTruncatedWarning metricId={metricId} metricVersionNumber={versionNumber} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MetricChartEvaluationWrapper
|
||||
evaluationScore={metric?.evaluation_score}
|
||||
evaluationSummary={metric?.evaluation_summary}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -59,22 +46,3 @@ const animation = {
|
|||
exit: { opacity: 0 },
|
||||
transition: { duration: 0.4 },
|
||||
};
|
||||
|
||||
const MetricChartEvaluationWrapper: React.FC<{
|
||||
evaluationScore: BusterMetric['evaluation_score'] | undefined;
|
||||
evaluationSummary: string | undefined;
|
||||
}> = ({ evaluationScore, evaluationSummary }) => {
|
||||
const show = !!evaluationScore && !!evaluationSummary;
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{show && (
|
||||
<motion.div {...animation}>
|
||||
<MetricChartEvaluation
|
||||
evaluationScore={evaluationScore}
|
||||
evaluationSummary={evaluationSummary}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useNavigate } from '@tanstack/react-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { BusterMetric } from '@/api/asset_interfaces/metric';
|
||||
import { useGetMetric, useGetMetricData } from '@/api/buster_rest/metrics';
|
||||
import { useDownloadMetricFile, useGetMetric, useGetMetricData } from '@/api/buster_rest/metrics';
|
||||
import {
|
||||
createDropdownItem,
|
||||
createDropdownItems,
|
||||
|
@ -25,11 +25,10 @@ import { Star as StarFilled } from '@/components/ui/icons/NucleoIconFilled';
|
|||
import { useStartChatFromAsset } from '@/context/BusterAssets/useStartChatFromAsset';
|
||||
import { useBusterNotifications } from '@/context/BusterNotifications';
|
||||
import { ensureElementExists } from '@/lib/element';
|
||||
import { downloadElementToImage, exportJSONToCSV } from '@/lib/exportUtils';
|
||||
import { downloadElementToImage } from '@/lib/exportUtils';
|
||||
import { canEdit } from '../../../lib/share';
|
||||
import { FollowUpWithAssetContent } from '../assets/FollowUpWithAsset';
|
||||
import { useFavoriteStar } from '../favorites';
|
||||
import { ASSET_ICONS } from '../icons/assetIcons';
|
||||
import { getShareAssetConfig } from '../ShareMenu/helpers';
|
||||
import { useListMetricVersionDropdownItems } from '../versionHistory/useListMetricVersionDropdownItems';
|
||||
import { METRIC_CHART_CONTAINER_ID } from './MetricChartCard/config';
|
||||
|
@ -165,32 +164,25 @@ export const useDownloadMetricDataCSV = ({
|
|||
metricVersionNumber: number | undefined;
|
||||
cacheDataId?: string;
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const { data: metricData } = useGetMetricData(
|
||||
{ id: metricId, versionNumber: metricVersionNumber, cacheDataId },
|
||||
{ enabled: false }
|
||||
);
|
||||
const { data: name } = useGetMetric(
|
||||
{ id: metricId },
|
||||
{ select: useCallback((x: BusterMetric) => x.name, []) }
|
||||
);
|
||||
const { mutateAsync: handleDownload, isPending: isDownloading } = useDownloadMetricFile();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
label: 'Download as CSV',
|
||||
value: 'download-csv',
|
||||
icon: <Download4 />,
|
||||
loading: isDownloading,
|
||||
onClick: async () => {
|
||||
const data = metricData?.data;
|
||||
if (data && name) {
|
||||
setIsDownloading(true);
|
||||
await exportJSONToCSV(data, name);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[metricData, isDownloading, name]
|
||||
() =>
|
||||
createDropdownItem({
|
||||
label: 'Download as CSV',
|
||||
value: 'download-csv',
|
||||
icon: <Download4 />,
|
||||
loading: isDownloading,
|
||||
closeOnSelect: false,
|
||||
onClick: async () => {
|
||||
await handleDownload({
|
||||
id: metricId,
|
||||
report_file_id: cacheDataId,
|
||||
metric_version_number: metricVersionNumber,
|
||||
});
|
||||
},
|
||||
}),
|
||||
[isDownloading]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -383,7 +383,7 @@ const DropdownItem = <
|
|||
const renderContent = () => {
|
||||
const content = (
|
||||
<>
|
||||
{icon && !loading && <span className="text-icon-color text-lg">{icon}</span>}
|
||||
{icon && <span className="text-icon-color text-lg">{icon}</span>}
|
||||
<div className={cn('flex flex-col space-y-1', truncate && 'overflow-hidden')}>
|
||||
<span className={cn(truncate && 'truncate')}>{label}</span>
|
||||
{secondaryLabel && <span className="text-gray-light text-xs">{secondaryLabel}</span>}
|
||||
|
|
|
@ -6,9 +6,13 @@ import { z } from 'zod';
|
|||
export const MetricDownloadParamsSchema = z.object({
|
||||
id: z.string().uuid('Metric ID must be a valid UUID'),
|
||||
});
|
||||
export const MetricDownloadQueryParamsSchema = z.object({
|
||||
report_file_id: z.string().uuid('Report file ID must be a valid UUID').optional(),
|
||||
metric_version_number: z.number().optional(),
|
||||
});
|
||||
|
||||
export type MetricDownloadParams = z.infer<typeof MetricDownloadParamsSchema>;
|
||||
|
||||
export type MetricDownloadQueryParams = z.infer<typeof MetricDownloadQueryParamsSchema>;
|
||||
/**
|
||||
* Response for successful metric download
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue