Merge branch 'dev' of https://github.com/umami-software/umami into analytics

This commit is contained in:
Francis Cao 2025-07-23 09:38:59 -07:00
commit e8ff32b063
19 changed files with 244 additions and 33 deletions

View file

@ -107,6 +107,7 @@
"next": "15.3.3", "next": "15.3.3",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"papaparse": "^5.5.3",
"prisma": "6.7.0", "prisma": "6.7.0",
"pure-rand": "^6.1.0", "pure-rand": "^6.1.0",
"react": "^19.0.0", "react": "^19.0.0",

8
pnpm-lock.yaml generated
View file

@ -131,6 +131,9 @@ importers:
npm-run-all: npm-run-all:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5 version: 4.1.5
papaparse:
specifier: ^5.5.3
version: 5.5.3
prisma: prisma:
specifier: 6.7.0 specifier: 6.7.0
version: 6.7.0(typescript@5.8.3) version: 6.7.0(typescript@5.8.3)
@ -5107,6 +5110,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -12275,6 +12281,8 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
papaparse@5.5.3: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0

View file

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { ReportContext } from './Report'; import { ReportContext } from './Report';
import styles from './ReportBody.module.css'; import styles from './ReportBody.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export function ReportBody({ children }) { export function ReportBody({ children }) {
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
@ -9,7 +10,14 @@ export function ReportBody({ children }) {
return null; return null;
} }
return <div className={styles.body}>{children}</div>; return (
<div className={styles.body}>
{report.type !== 'revenue' && report.type !== 'attribution' && (
<DownloadButton filename={report.name} data={report.data} />
)}
{children}
</div>
);
} }
export default ReportBody; export default ReportBody;

View file

@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
const ReportComponent = reports[report.type]; const ReportComponent = reports[report.type];
if (!ReportComponent) {
return null;
}
return <ReportComponent reportId={reportId} />; return <ReportComponent reportId={reportId} />;
} }

View file

@ -15,6 +15,31 @@ function toArray(data: { [key: string]: number } = {}) {
.sort(firstBy('value', -1)); .sort(firstBy('value', -1));
} }
function parseParameters(data: any[]) {
return data.reduce((obj, { url_query, num }) => {
try {
const searchParams = new URLSearchParams(url_query);
for (const [key, value] of searchParams) {
if (key.match(/^utm_(\w+)$/)) {
const name = value;
if (!obj[key]) {
obj[key] = { [name]: Number(num) };
} else if (!obj[key][name]) {
obj[key][name] = Number(num);
} else {
obj[key][name] += Number(num);
}
}
}
} catch {
// Ignore
}
return obj;
}, {});
}
export default function UTMView() { export default function UTMView() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
@ -27,7 +52,7 @@ export default function UTMView() {
return ( return (
<div> <div>
{UTM_PARAMS.map(param => { {UTM_PARAMS.map(param => {
const items = toArray(data[param]); const items = toArray(parseParameters(data)[param]);
const chartData = { const chartData = {
labels: items.map(({ name }) => name), labels: items.map(({ name }) => name),
datasets: [ datasets: [

View file

@ -1,6 +1,6 @@
import { Dropdown, Item } from 'react-basics'; import { Dropdown, Item } from 'react-basics';
import classNames from 'classnames'; 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 WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
import MetricCard from '@/components/metrics/MetricCard'; import MetricCard from '@/components/metrics/MetricCard';
import MetricsBar from '@/components/metrics/MetricsBar'; import MetricsBar from '@/components/metrics/MetricsBar';
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats'; import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
import useStore, { setWebsiteDateCompare } from '@/store/websites'; import useStore, { setWebsiteDateCompare } from '@/store/websites';
import WebsiteFilterButton from './WebsiteFilterButton'; import WebsiteFilterButton from './WebsiteFilterButton';
import { ExportButton } from '@/components/input/ExportButton';
import styles from './WebsiteMetricsBar.module.css'; import styles from './WebsiteMetricsBar.module.css';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode && dateCompare, compareMode && dateCompare,
); );
const {
query: { view },
} = useNavigation();
const isAllTime = dateRange.value === 'all'; const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, bounces, totaltime } = data || {}; const { pageviews, visitors, visits, bounces, totaltime } = data || {};
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
</MetricsBar> </MetricsBar>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
{showFilter && <WebsiteFilterButton websiteId={websiteId} />} <div>
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
{!view && <ExportButton websiteId={websiteId} />}
</div>
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} /> <WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
{compareMode && ( {compareMode && (
<div className={styles.vs}> <div className={styles.vs}>

View file

@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
const pathname = usePathname(); const pathname = usePathname();
const tableProps = { const tableProps = {
websiteId, websiteId,
allowDownload: false,
limit: 10, limit: 10,
}; };
const isSharePage = pathname.includes('/share/'); const isSharePage = pathname.includes('/share/');

View file

@ -0,0 +1,73 @@
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, getPageviewMetrics, getSessionMetrics } 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 filters = {
...(await getRequestFilters(query)),
startDate,
endDate,
};
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
getEventMetrics(websiteId, 'event', filters),
getPageviewMetrics(websiteId, 'url', filters),
getPageviewMetrics(websiteId, 'referrer', filters),
getSessionMetrics(websiteId, 'browser', filters),
getSessionMetrics(websiteId, 'os', filters),
getSessionMetrics(websiteId, 'device', filters),
getSessionMetrics(websiteId, 'country', filters),
]);
const zip = new JSZip();
const parse = (data: any) => {
return Papa.unparse(data, {
header: true,
skipEmptyLines: true,
});
};
zip.file('events.csv', parse(events));
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
View 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
View 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

View file

@ -29,7 +29,7 @@ export function useReport(
data.parameters = { data.parameters = {
...defaultParameters?.parameters, ...defaultParameters?.parameters,
...data.parameters, ...data.parameters,
dateRange: parseDateRange(dateRange.value), dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
}; };
setReport(data); setReport(data);

View file

@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
import Clock from '@/assets/clock.svg'; import Clock from '@/assets/clock.svg';
import Compare from '@/assets/compare.svg'; import Compare from '@/assets/compare.svg';
import Dashboard from '@/assets/dashboard.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 Eye from '@/assets/eye.svg';
import Gear from '@/assets/gear.svg'; import Gear from '@/assets/gear.svg';
import Globe from '@/assets/globe.svg'; import Globe from '@/assets/globe.svg';
@ -37,6 +39,8 @@ const icons = {
Clock, Clock,
Compare, Compare,
Dashboard, Dashboard,
Download,
Export,
Eye, Eye,
Gear, Gear,
Globe, Globe,

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

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

View file

@ -316,6 +316,7 @@ export const labels = defineMessages({
other: { id: 'label.other', defaultMessage: 'Other' }, other: { id: 'label.other', defaultMessage: 'Other' },
chart: { id: 'label.chart', defaultMessage: 'Chart' }, chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' }, table: { id: 'label.table', defaultMessage: 'Table' },
download: { id: 'label.download', defaultMessage: 'Download' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
metric={formatMessage(labels.actions)} metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad} onDataLoad={handleDataLoad}
renderLabel={renderLabel} renderLabel={renderLabel}
allowDownload={false}
/> />
); );
} }

View file

@ -14,6 +14,13 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.footer { .footer {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -15,6 +15,7 @@ import {
import Icons from '@/components/icons'; import Icons from '@/components/icons';
import ListTable, { ListTableProps } from './ListTable'; import ListTable, { ListTableProps } from './ListTable';
import styles from './MetricsTable.module.css'; import styles from './MetricsTable.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps { export interface MetricsTableProps extends ListTableProps {
websiteId: string; websiteId: string;
@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
searchFormattedValues?: boolean; searchFormattedValues?: boolean;
showMore?: boolean; showMore?: boolean;
params?: { [key: string]: any }; params?: { [key: string]: any };
allowDownload?: boolean;
children?: ReactNode; children?: ReactNode;
} }
@ -44,6 +46,7 @@ export function MetricsTable({
searchFormattedValues = false, searchFormattedValues = false,
showMore = true, showMore = true,
params, params,
allowDownload = true,
children, children,
...props ...props
}: MetricsTableProps) { }: MetricsTableProps) {
@ -104,7 +107,10 @@ export function MetricsTable({
autoFocus={true} autoFocus={true}
/> />
)} )}
{children} <div className={styles.buttons}>
{children}
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
</div>
</div> </div>
{data && !error && ( {data && !error && (
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} /> <ListTable {...(props as ListTableProps)} data={filteredData} className={className} />

View file

@ -44,7 +44,7 @@ async function relationalQuery(
startDate, startDate,
endDate, endDate,
}, },
).then(result => parseParameters(result as any[])); );
} }
async function clickhouseQuery( async function clickhouseQuery(
@ -73,30 +73,5 @@ async function clickhouseQuery(
startDate, startDate,
endDate, endDate,
}, },
).then(result => parseParameters(result as any[])); );
}
function parseParameters(data: any[]) {
return data.reduce((obj, { url_query, num }) => {
try {
const searchParams = new URLSearchParams(url_query);
for (const [key, value] of searchParams) {
if (key.match(/^utm_(\w+)$/)) {
const name = value;
if (!obj[key]) {
obj[key] = { [name]: Number(num) };
} else if (!obj[key][name]) {
obj[key][name] = Number(num);
} else {
obj[key][name] += Number(num);
}
}
}
} catch {
// Ignore
}
return obj;
}, {});
} }