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:
dal 2025-09-23 14:04:56 -06:00
commit b5041606cd
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
9 changed files with 70 additions and 105 deletions

View File

@ -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;

View File

@ -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...`);
}
},
});
};

View File

@ -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);
};

View File

@ -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({

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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]
);
};

View File

@ -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>}

View File

@ -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
*/