mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(ui): stop usage pagination from under-reporting wide date ranges
Merge daily activity pages by date and block the CSV export until the whole range is loaded. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
5277dab4f2
commit
fe131b807a
8 changed files with 328 additions and 18 deletions
|
|
@ -137,6 +137,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
isFetchingMore,
|
||||
progress,
|
||||
cancelled,
|
||||
failed,
|
||||
incomplete,
|
||||
cancel,
|
||||
} = usePaginatedDailyActivity({
|
||||
fetchFn,
|
||||
|
|
@ -151,6 +153,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
isFetchingMore: agentIsFetchingMore,
|
||||
progress: agentProgress,
|
||||
cancelled: agentCancelled,
|
||||
failed: agentFailed,
|
||||
cancel: agentCancel,
|
||||
} = usePaginatedDailyActivity({
|
||||
fetchFn: agentDailyActivityCall,
|
||||
|
|
@ -651,10 +654,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{cancelled && (
|
||||
<Alert variant="info" className="mb-2">
|
||||
{(cancelled || failed) && (
|
||||
<Alert variant={failed ? "error" : "info"} className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
{failed ? "Fetching spend data failed, so totals cover only part of the range" : "Showing partial data"} (
|
||||
{progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -677,10 +681,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{agentCancelled && showAgentBreakdown && (
|
||||
<Alert variant="info" className="mb-2">
|
||||
{(agentCancelled || agentFailed) && showAgentBreakdown && (
|
||||
<Alert variant={agentFailed ? "error" : "info"} className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
|
||||
{agentFailed
|
||||
? "Fetching agent data failed, so totals cover only part of the range"
|
||||
: "Showing partial agent data"}{" "}
|
||||
({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -696,6 +703,12 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
onFiltersChange={setSelectedTags}
|
||||
filterOptions={getAllTags() || undefined}
|
||||
teams={teams || []}
|
||||
exportDisabled={incomplete}
|
||||
exportDisabledReason={
|
||||
failed
|
||||
? "Spend data failed to load for the whole range, so an export would under-report. Reload the page first."
|
||||
: "Spend data is still loading, so an export would under-report. Wait for it to finish."
|
||||
}
|
||||
/>
|
||||
<Tabs defaultValue={tabs[0].key}>
|
||||
<TabsList className="mt-1">
|
||||
|
|
|
|||
|
|
@ -482,11 +482,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{paginatedResult.cancelled && (
|
||||
<Alert variant="info" className="mb-2">
|
||||
{(paginatedResult.cancelled || paginatedResult.failed) && (
|
||||
<Alert variant={paginatedResult.failed ? "error" : "info"} className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages
|
||||
loaded)
|
||||
{paginatedResult.failed
|
||||
? "Fetching spend data failed, so totals cover only part of the range"
|
||||
: "Showing partial data"}{" "}
|
||||
({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import { mergeDailyResults } from "./mergeDailyActivity";
|
||||
|
||||
const metrics = (overrides: Partial<SpendMetrics> = {}): SpendMetrics => ({
|
||||
spend: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: 0,
|
||||
successful_requests: 0,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const day = (date: string, spend: number, teamSpend: Record<string, number>, keySpend: number): DailyData => ({
|
||||
date,
|
||||
metrics: metrics({ spend, total_tokens: spend * 10, api_requests: 1 }),
|
||||
breakdown: {
|
||||
models: {},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: {
|
||||
"sk-a": { metrics: metrics({ spend: keySpend }), metadata: { key_alias: "a", team_id: "team-1" } },
|
||||
},
|
||||
entities: Object.fromEntries(
|
||||
Object.entries(teamSpend).map(([team, value]) => [
|
||||
team,
|
||||
{
|
||||
metrics: metrics({ spend: value, total_tokens: value * 10 }),
|
||||
metadata: { team_alias: team },
|
||||
api_key_breakdown: {
|
||||
"sk-a": { metrics: metrics({ spend: value }), metadata: { key_alias: "a", team_id: team } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
describe("mergeDailyResults", () => {
|
||||
it("keeps one entry per date when a date straddles a page boundary", () => {
|
||||
const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5), day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)];
|
||||
const pageTwo = [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52), day("2026-06-24", 3, { "team-1": 3 }, 3)];
|
||||
|
||||
const merged = mergeDailyResults(pageOne, pageTwo);
|
||||
|
||||
expect(merged.map((d) => d.date)).toEqual(["2026-06-26", "2026-06-25", "2026-06-24"]);
|
||||
const splitDay = merged.find((d) => d.date === "2026-06-25")!;
|
||||
expect(splitDay.metrics.spend).toBeCloseTo(36.9, 10);
|
||||
expect(splitDay.metrics.total_tokens).toBeCloseTo(369, 10);
|
||||
expect(splitDay.metrics.api_requests).toBe(2);
|
||||
});
|
||||
|
||||
it("merges every breakdown bucket of a split date instead of dropping one page's share", () => {
|
||||
const merged = mergeDailyResults(
|
||||
[day("2026-06-25", 10, { "team-1": 6, "team-2": 4 }, 10)],
|
||||
[day("2026-06-25", 5, { "team-2": 5 }, 5)],
|
||||
);
|
||||
|
||||
const { entities, api_keys } = merged[0].breakdown;
|
||||
expect(entities["team-1"].metrics.spend).toBeCloseTo(6, 10);
|
||||
expect(entities["team-2"].metrics.spend).toBeCloseTo(9, 10);
|
||||
expect(entities["team-2"].api_key_breakdown["sk-a"].metrics.spend).toBeCloseTo(9, 10);
|
||||
expect(api_keys["sk-a"].metrics.spend).toBeCloseTo(15, 10);
|
||||
});
|
||||
|
||||
it("preserves the per-day total across pages so day sums match the response metadata", () => {
|
||||
const pages = [
|
||||
[day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)],
|
||||
[day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52)],
|
||||
[day("2026-06-24", 3, { "team-1": 3 }, 3)],
|
||||
];
|
||||
|
||||
const merged = pages.reduce<DailyData[]>((acc, page) => mergeDailyResults(acc, page), []);
|
||||
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.reduce((total, d) => total + d.metrics.spend, 0)).toBeCloseTo(39.9, 10);
|
||||
});
|
||||
|
||||
it("leaves distinct dates untouched", () => {
|
||||
const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5)];
|
||||
const pageTwo = [day("2026-06-25", 7, { "team-1": 7 }, 7)];
|
||||
|
||||
expect(mergeDailyResults(pageOne, pageTwo)).toEqual([...pageOne, ...pageTwo]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type {
|
||||
BreakdownMetrics,
|
||||
DailyData,
|
||||
KeyMetricWithMetadata,
|
||||
MetricWithMetadata,
|
||||
SpendMetrics,
|
||||
} from "@/components/UsagePage/types";
|
||||
|
||||
const METRIC_KEYS: readonly (keyof SpendMetrics)[] = [
|
||||
"spend",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"api_requests",
|
||||
"successful_requests",
|
||||
"failed_requests",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"compression_saved_tokens",
|
||||
"compression_savings_spend",
|
||||
"prompt_caching_savings_spend",
|
||||
"autorouter_savings_spend",
|
||||
];
|
||||
|
||||
const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics =>
|
||||
METRIC_KEYS.reduce(
|
||||
(acc, key) =>
|
||||
a[key] === undefined && b[key] === undefined ? acc : { ...acc, [key]: (a[key] ?? 0) + (b[key] ?? 0) },
|
||||
{} as SpendMetrics,
|
||||
);
|
||||
|
||||
const mergeBuckets = <T>(
|
||||
a: Record<string, T> | undefined,
|
||||
b: Record<string, T> | undefined,
|
||||
mergeEntry: (left: T, right: T) => T,
|
||||
): Record<string, T> => {
|
||||
const left = a ?? {};
|
||||
const right = b ?? {};
|
||||
return Object.fromEntries(
|
||||
Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => {
|
||||
const leftEntry = left[key];
|
||||
const rightEntry = right[key];
|
||||
if (leftEntry === undefined) return [key, rightEntry];
|
||||
if (rightEntry === undefined) return [key, leftEntry];
|
||||
return [key, mergeEntry(leftEntry, rightEntry)];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({
|
||||
...a,
|
||||
metrics: addMetrics(a.metrics, b.metrics),
|
||||
});
|
||||
|
||||
const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({
|
||||
...a,
|
||||
metrics: addMetrics(a.metrics, b.metrics),
|
||||
api_key_breakdown: mergeBuckets(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric),
|
||||
});
|
||||
|
||||
const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({
|
||||
models: mergeBuckets(a.models, b.models, mergeMetricWithMetadata),
|
||||
model_groups: mergeBuckets(a.model_groups, b.model_groups, mergeMetricWithMetadata),
|
||||
mcp_servers: mergeBuckets(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata),
|
||||
providers: mergeBuckets(a.providers, b.providers, mergeMetricWithMetadata),
|
||||
entities: mergeBuckets(a.entities, b.entities, mergeMetricWithMetadata),
|
||||
endpoints: mergeBuckets(a.endpoints, b.endpoints, mergeMetricWithMetadata),
|
||||
api_keys: mergeBuckets(a.api_keys, b.api_keys, mergeKeyMetric),
|
||||
});
|
||||
|
||||
const mergeDay = (a: DailyData, b: DailyData): DailyData => ({
|
||||
...a,
|
||||
metrics: addMetrics(a.metrics, b.metrics),
|
||||
breakdown: mergeBreakdown(a.breakdown, b.breakdown),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combine daily activity pages into one series with a single entry per date.
|
||||
*
|
||||
* The backend paginates over raw spend rows, so a date whose rows straddle a
|
||||
* page boundary comes back once per page, each entry holding only that page's
|
||||
* share of the day. Concatenating those entries leaves duplicate dates that
|
||||
* under-report every per-day figure in the charts and the CSV export.
|
||||
*/
|
||||
export const mergeDailyResults = (existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] =>
|
||||
incoming.reduce<DailyData[]>(
|
||||
(acc, day) => {
|
||||
const index = acc.findIndex((existingDay) => existingDay.date === day.date);
|
||||
if (index === -1) return [...acc, day];
|
||||
return acc.map((existingDay, i) => (i === index ? mergeDay(existingDay, day) : existingDay));
|
||||
},
|
||||
[...existing],
|
||||
);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sumMetadata } from "./usePaginatedDailyActivity";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity";
|
||||
|
||||
describe("sumMetadata", () => {
|
||||
it("sums flat cost across pages instead of keeping the first page's value", () => {
|
||||
|
|
@ -49,3 +50,87 @@ describe("sumMetadata", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
const page = (date: string, spend: number, totalPages: number, pageNumber: number) => ({
|
||||
results: [
|
||||
{
|
||||
date,
|
||||
metrics: {
|
||||
spend,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: spend * 10,
|
||||
api_requests: 1,
|
||||
successful_requests: 1,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
breakdown: {
|
||||
models: {},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: {},
|
||||
entities: {
|
||||
"team-1": {
|
||||
metrics: {
|
||||
spend,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: spend * 10,
|
||||
api_requests: 1,
|
||||
successful_requests: 1,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
metadata: {},
|
||||
api_key_breakdown: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { total_spend: spend, total_tokens: spend * 10, total_pages: totalPages, page: pageNumber },
|
||||
});
|
||||
|
||||
const args = ["token", new Date("2026-02-05"), new Date("2026-08-05"), null];
|
||||
|
||||
describe("usePaginatedDailyActivity", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("collapses a date split across pages into a single day entry", async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page("2026-06-25", 22.38, 2, 1))
|
||||
.mockResolvedValueOnce(page("2026-06-25", 14.52, 2, 2));
|
||||
|
||||
const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true }));
|
||||
|
||||
await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||
await waitFor(() => expect(result.current.data.metadata.total_spend).toBeCloseTo(36.9, 10), { timeout: 3000 });
|
||||
|
||||
expect(result.current.data.results).toHaveLength(1);
|
||||
expect(result.current.data.results[0].metrics.spend).toBeCloseTo(36.9, 10);
|
||||
expect(result.current.data.results[0].breakdown.entities["team-1"].metrics.spend).toBeCloseTo(36.9, 10);
|
||||
expect(result.current.incomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("flags the range as incomplete when a page fetch fails instead of looking complete", async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page("2026-06-25", 22.38, 3, 1))
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true }));
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 3000 });
|
||||
|
||||
expect(result.current.incomplete).toBe(true);
|
||||
expect(result.current.isFetchingMore).toBe(false);
|
||||
expect(result.current.data.metadata.total_spend).toBeCloseTo(22.38, 10);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DailyData } from "@/components/UsagePage/types";
|
||||
import { mergeDailyResults } from "./mergeDailyActivity";
|
||||
|
||||
export interface PaginationProgress {
|
||||
currentPage: number;
|
||||
|
|
@ -48,6 +49,10 @@ interface UsePaginatedDailyActivityReturn {
|
|||
isFetchingMore: boolean;
|
||||
progress: PaginationProgress;
|
||||
cancelled: boolean;
|
||||
/** True when a page fetch failed, so the data on screen covers only part of the range. */
|
||||
failed: boolean;
|
||||
/** True whenever the data on screen is known not to cover the whole requested range. */
|
||||
incomplete: boolean;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +110,7 @@ export function usePaginatedDailyActivity({
|
|||
totalPages: 0,
|
||||
});
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const fetchIdRef = useRef(0);
|
||||
const cancelledRef = useRef(false);
|
||||
|
|
@ -135,12 +141,14 @@ export function usePaginatedDailyActivity({
|
|||
setIsFetchingMore(false);
|
||||
setProgress({ currentPage: 0, totalPages: 0 });
|
||||
setCancelled(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
cancelledRef.current = false;
|
||||
setCancelled(false);
|
||||
setFailed(false);
|
||||
|
||||
const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current;
|
||||
|
||||
|
|
@ -197,7 +205,7 @@ export function usePaginatedDailyActivity({
|
|||
|
||||
if (isStale()) return;
|
||||
|
||||
accumulatedResults = [...accumulatedResults, ...pageData.results];
|
||||
accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results);
|
||||
accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata);
|
||||
accumulatedMetadata.total_pages = totalPages;
|
||||
accumulatedMetadata.has_more = page < totalPages;
|
||||
|
|
@ -224,6 +232,7 @@ export function usePaginatedDailyActivity({
|
|||
console.error("Error fetching daily activity:", error);
|
||||
setLoading(false);
|
||||
setIsFetchingMore(false);
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -241,5 +250,7 @@ export function usePaginatedDailyActivity({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, fetchFn, argsKey]);
|
||||
|
||||
return { data, loading, isFetchingMore, progress, cancelled, cancel };
|
||||
const incomplete = isFetchingMore || cancelled || failed;
|
||||
|
||||
return { data, loading, isFetchingMore, progress, cancelled, failed, incomplete, cancel };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ describe("UsageExportHeader", () => {
|
|||
expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should block the export while the data on screen is incomplete", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsageExportHeader {...defaultProps} exportDisabled exportDisabledReason="Still loading" />);
|
||||
const exportButton = screen.getByRole("button", { name: /export data/i });
|
||||
expect(exportButton).toBeDisabled();
|
||||
await user.click(exportButton);
|
||||
expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show filter dropdown when showFilters is false", () => {
|
||||
renderWithProviders(<UsageExportHeader {...defaultProps} showFilters={false} />);
|
||||
expect(screen.queryByText(/filter/i)).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ interface UsageExportHeaderProps {
|
|||
customTitle?: string;
|
||||
compactLayout?: boolean;
|
||||
teams?: Team[];
|
||||
/** Blocks the export while the data on screen does not cover the whole requested range. */
|
||||
exportDisabled?: boolean;
|
||||
exportDisabledReason?: string;
|
||||
}
|
||||
|
||||
const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
||||
|
|
@ -50,6 +53,8 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
customTitle,
|
||||
compactLayout = false,
|
||||
teams = [],
|
||||
exportDisabled = false,
|
||||
exportDisabledReason,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
|
@ -112,10 +117,12 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
)}
|
||||
|
||||
<div className="justify-self-end">
|
||||
<Button onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
<span title={exportDisabled ? exportDisabledReason : undefined}>
|
||||
<Button disabled={exportDisabled} onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue