mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 12:47:13 +01:00
Added download functionality.
This commit is contained in:
parent
0debe89d05
commit
7670ec4136
17 changed files with 216 additions and 5 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useContext } from 'react';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './ReportBody.module.css';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
|
||||
export function ReportBody({ children }) {
|
||||
const { report } = useContext(ReportContext);
|
||||
|
|
@ -9,7 +10,12 @@ export function ReportBody({ children }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.body}>{children}</div>;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<DownloadButton filename={report.name} data={report.data} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportBody;
|
||||
|
|
|
|||
|
|
@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
|
|||
|
||||
const ReportComponent = reports[report.type];
|
||||
|
||||
if (!ReportComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ReportComponent reportId={reportId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Dropdown, Item } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import { useDateRange, useMessages, useSticky } from '@/components/hooks';
|
||||
import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks';
|
||||
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
|
||||
import MetricCard from '@/components/metrics/MetricCard';
|
||||
import MetricsBar from '@/components/metrics/MetricsBar';
|
||||
|
|
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
|
|||
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
|
||||
import useStore, { setWebsiteDateCompare } from '@/store/websites';
|
||||
import WebsiteFilterButton from './WebsiteFilterButton';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
import styles from './WebsiteMetricsBar.module.css';
|
||||
|
||||
export function WebsiteMetricsBar({
|
||||
|
|
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
|
|||
websiteId,
|
||||
compareMode && dateCompare,
|
||||
);
|
||||
const {
|
||||
query: { view },
|
||||
} = useNavigation();
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
|
||||
|
|
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
|
|||
</MetricsBar>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
<div>
|
||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
{!view && <ExportButton websiteId={websiteId} />}
|
||||
</div>
|
||||
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
|
||||
{compareMode && (
|
||||
<div className={styles.vs}>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
|||
const pathname = usePathname();
|
||||
const tableProps = {
|
||||
websiteId,
|
||||
allowDownload: false,
|
||||
limit: 10,
|
||||
};
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
|
|
|||
75
src/app/api/websites/[websiteId]/export/route.ts
Normal file
75
src/app/api/websites/[websiteId]/export/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { z } from 'zod';
|
||||
import JSZip from 'jszip';
|
||||
import Papa from 'papaparse';
|
||||
import { getRequestFilters, parseRequest } from '@/lib/request';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { getEventMetrics, getWebsiteEvents } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { startAt, endAt } = query;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const startDate = new Date(+startAt);
|
||||
const endDate = new Date(+endAt);
|
||||
const limit = 10;
|
||||
const offset = 0;
|
||||
|
||||
const filters = {
|
||||
...(await getRequestFilters(query)),
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||
getWebsiteEvents(websiteId, { startDate, endDate }, query),
|
||||
getEventMetrics(websiteId, 'url', filters, limit, offset),
|
||||
getEventMetrics(websiteId, 'referrer', filters, limit, offset),
|
||||
getEventMetrics(websiteId, 'browser', filters, limit, offset),
|
||||
getEventMetrics(websiteId, 'os', filters, limit, offset),
|
||||
getEventMetrics(websiteId, 'device', filters, limit, offset),
|
||||
getEventMetrics(websiteId, 'country', filters, limit, offset),
|
||||
]);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
const parse = (data: any) => {
|
||||
return Papa.unparse(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
};
|
||||
|
||||
zip.file('events.csv', parse(events?.data));
|
||||
zip.file('pages.csv', parse(pages));
|
||||
zip.file('referrers.csv', parse(referrers));
|
||||
zip.file('browsers.csv', parse(browsers));
|
||||
zip.file('os.csv', parse(os));
|
||||
zip.file('devices.csv', parse(devices));
|
||||
zip.file('countries.csv', parse(countries));
|
||||
|
||||
const content = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const base64 = content.toString('base64');
|
||||
|
||||
return json({ zip: base64 });
|
||||
}
|
||||
1
src/assets/download.svg
Normal file
1
src/assets/download.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/export.svg
Normal file
1
src/assets/export.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>
|
||||
|
After Width: | Height: | Size: 493 B |
|
|
@ -29,7 +29,7 @@ export function useReport(
|
|||
data.parameters = {
|
||||
...defaultParameters?.parameters,
|
||||
...data.parameters,
|
||||
dateRange: parseDateRange(dateRange.value),
|
||||
dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
|
||||
};
|
||||
|
||||
setReport(data);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
|
|||
import Clock from '@/assets/clock.svg';
|
||||
import Compare from '@/assets/compare.svg';
|
||||
import Dashboard from '@/assets/dashboard.svg';
|
||||
import Download from '@/assets/download.svg';
|
||||
import Export from '@/assets/export.svg';
|
||||
import Eye from '@/assets/eye.svg';
|
||||
import Gear from '@/assets/gear.svg';
|
||||
import Globe from '@/assets/globe.svg';
|
||||
|
|
@ -37,6 +39,8 @@ const icons = {
|
|||
Clock,
|
||||
Compare,
|
||||
Dashboard,
|
||||
Download,
|
||||
Export,
|
||||
Eye,
|
||||
Gear,
|
||||
Globe,
|
||||
|
|
|
|||
41
src/components/input/DownloadButton.tsx
Normal file
41
src/components/input/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Papa from 'papaparse';
|
||||
import { Button, Icon, TooltipPopup } from 'react-basics';
|
||||
import Icons from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function DownloadButton({
|
||||
filename = 'data',
|
||||
data,
|
||||
}: {
|
||||
filename?: string;
|
||||
data?: any;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleClick = async () => {
|
||||
downloadCsv(`${filename}.csv`, Papa.unparse(data));
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipPopup label={formatMessage(labels.download)} position="top">
|
||||
<Button variant="quiet" onClick={handleClick} disabled={!data}>
|
||||
<Icon>
|
||||
<Icons.Download />
|
||||
</Icon>
|
||||
</Button>
|
||||
</TooltipPopup>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, data: any) {
|
||||
const blob = new Blob([data], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
47
src/components/input/ExportButton.tsx
Normal file
47
src/components/input/ExportButton.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, TooltipPopup, LoadingButton } from 'react-basics';
|
||||
import Icons from '@/components/icons';
|
||||
import { useMessages, useApi } from '@/components/hooks';
|
||||
import { useFilterParams } from '@/components/hooks/useFilterParams';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export function ExportButton({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const params = useFilterParams(websiteId);
|
||||
const searchParams = useSearchParams();
|
||||
const { get } = useApi();
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { zip } = await get(`/websites/${websiteId}/export`, { ...params, ...searchParams });
|
||||
|
||||
const binary = atob(zip);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'application/zip' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'download.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipPopup label={formatMessage(labels.download)} position="top">
|
||||
<LoadingButton variant="quiet" isLoading={isLoading} onClick={handleClick}>
|
||||
<Icon>
|
||||
<Icons.Download />
|
||||
</Icon>
|
||||
</LoadingButton>
|
||||
</TooltipPopup>
|
||||
);
|
||||
}
|
||||
|
|
@ -316,6 +316,7 @@ export const labels = defineMessages({
|
|||
other: { id: 'label.other', defaultMessage: 'Other' },
|
||||
chart: { id: 'label.chart', defaultMessage: 'Chart' },
|
||||
table: { id: 'label.table', defaultMessage: 'Table' },
|
||||
download: { id: 'label.download', defaultMessage: 'Download' },
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
|||
metric={formatMessage(labels.actions)}
|
||||
onDataLoad={handleDataLoad}
|
||||
renderLabel={renderLabel}
|
||||
allowDownload={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import Icons from '@/components/icons';
|
||||
import ListTable, { ListTableProps } from './ListTable';
|
||||
import styles from './MetricsTable.module.css';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
|
||||
export interface MetricsTableProps extends ListTableProps {
|
||||
websiteId: string;
|
||||
|
|
@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
|
|||
searchFormattedValues?: boolean;
|
||||
showMore?: boolean;
|
||||
params?: { [key: string]: any };
|
||||
allowDownload?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +46,7 @@ export function MetricsTable({
|
|||
searchFormattedValues = false,
|
||||
showMore = true,
|
||||
params,
|
||||
allowDownload = true,
|
||||
children,
|
||||
...props
|
||||
}: MetricsTableProps) {
|
||||
|
|
@ -104,7 +107,10 @@ export function MetricsTable({
|
|||
autoFocus={true}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
<div className={styles.buttons}>
|
||||
{children}
|
||||
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
|
||||
</div>
|
||||
</div>
|
||||
{data && !error && (
|
||||
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue