mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Merge branch 'feat/um-285-report-schema' into dev
This commit is contained in:
commit
40f53e8856
@ -20,6 +20,7 @@ export function NavBar() {
|
|||||||
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
|
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
|
||||||
{ label: formatMessage(labels.reports), url: '/reports' },
|
{ label: formatMessage(labels.reports), url: '/reports' },
|
||||||
{ label: formatMessage(labels.realtime), url: '/realtime' },
|
{ label: formatMessage(labels.realtime), url: '/realtime' },
|
||||||
|
{ label: formatMessage(labels.reports), url: '/reports/funnel' },
|
||||||
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
|
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
|
||||||
].filter(n => n);
|
].filter(n => n);
|
||||||
|
|
||||||
|
23
components/layout/ReportsLayout.js
Normal file
23
components/layout/ReportsLayout.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Column, Row } from 'react-basics';
|
||||||
|
import styles from './ReportsLayout.module.css';
|
||||||
|
|
||||||
|
export function SettingsLayout({ children, filter, header }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row>{header}</Row>
|
||||||
|
<Row>
|
||||||
|
{filter && (
|
||||||
|
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
|
||||||
|
<h2>Filters</h2>
|
||||||
|
{filter}
|
||||||
|
</Column>
|
||||||
|
)}
|
||||||
|
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsLayout;
|
23
components/layout/ReportsLayout.module.css
Normal file
23
components/layout/ReportsLayout.module.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.filter {
|
||||||
|
margin-top: 30px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 100vw;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--base50);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter h2 {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@ export const labels = defineMessages({
|
|||||||
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
|
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
|
||||||
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
||||||
details: { id: 'label.details', defaultMessage: 'Details' },
|
details: { id: 'label.details', defaultMessage: 'Details' },
|
||||||
|
website: { id: 'label.website', defaultMessage: 'Website' },
|
||||||
websites: { id: 'label.websites', defaultMessage: 'Websites' },
|
websites: { id: 'label.websites', defaultMessage: 'Websites' },
|
||||||
created: { id: 'label.created', defaultMessage: 'Created' },
|
created: { id: 'label.created', defaultMessage: 'Created' },
|
||||||
edit: { id: 'label.edit', defaultMessage: 'Edit' },
|
edit: { id: 'label.edit', defaultMessage: 'Edit' },
|
||||||
@ -186,6 +187,10 @@ export const messages = defineMessages({
|
|||||||
id: 'message.delete-website-warning',
|
id: 'message.delete-website-warning',
|
||||||
defaultMessage: 'All website data will be deleted.',
|
defaultMessage: 'All website data will be deleted.',
|
||||||
},
|
},
|
||||||
|
noResultsFound: {
|
||||||
|
id: 'messages.no-results-found',
|
||||||
|
defaultMessage: 'No results were found.',
|
||||||
|
},
|
||||||
noWebsitesConfigured: {
|
noWebsitesConfigured: {
|
||||||
id: 'messages.no-websites-configured',
|
id: 'messages.no-websites-configured',
|
||||||
defaultMessage: 'You do not have any websites configured.',
|
defaultMessage: 'You do not have any websites configured.',
|
||||||
|
28
components/pages/reports/ReportForm.js
Normal file
28
components/pages/reports/ReportForm.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics';
|
||||||
|
|
||||||
|
export function FunnelForm() {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const handleSubmit = () => {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<FormRow label={formatMessage(labels.website)}>
|
||||||
|
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={false}>
|
||||||
|
Save
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelForm;
|
19
components/pages/reports/ReportForm.module.css
Normal file
19
components/pages/reports/ReportForm.module.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.filter {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
visibility: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
max-height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
max-height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urlFormRow label {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
185
components/pages/reports/funnel/FunnelChart.js
Normal file
185
components/pages/reports/funnel/FunnelChart.js
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { colord } from 'colord';
|
||||||
|
import HoverTooltip from 'components/common/HoverTooltip';
|
||||||
|
import Legend from 'components/metrics/Legend';
|
||||||
|
import useLocale from 'hooks/useLocale';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import useTheme from 'hooks/useTheme';
|
||||||
|
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
|
||||||
|
import { formatLongNumber } from 'lib/format';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Loading, StatusLight } from 'react-basics';
|
||||||
|
import styles from './FunnelChart.module.css';
|
||||||
|
|
||||||
|
export function FunnelChart({
|
||||||
|
data,
|
||||||
|
animationDuration = DEFAULT_ANIMATION_DURATION,
|
||||||
|
stacked = false,
|
||||||
|
loading = false,
|
||||||
|
onCreate = () => {},
|
||||||
|
onUpdate = () => {},
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const canvas = useRef();
|
||||||
|
const chart = useRef(null);
|
||||||
|
const [tooltip, setTooltip] = useState(null);
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
const datasets = useMemo(() => {
|
||||||
|
const primaryColor = colord(THEME_COLORS[theme].primary);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.uniqueVisitors),
|
||||||
|
data: data,
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
|
||||||
|
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
|
||||||
|
borderColor: primaryColor.alpha(0.9).toRgbString(),
|
||||||
|
hoverBorderColor: primaryColor.toRgbString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const colors = useMemo(
|
||||||
|
() => ({
|
||||||
|
text: THEME_COLORS[theme].gray700,
|
||||||
|
line: THEME_COLORS[theme].gray200,
|
||||||
|
}),
|
||||||
|
[theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderYLabel = label => {
|
||||||
|
return +label > 1000 ? formatLongNumber(label) : label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTooltip = useCallback(model => {
|
||||||
|
const { opacity, labelColors, dataPoints } = model.tooltip;
|
||||||
|
|
||||||
|
if (!dataPoints?.length || !opacity) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTooltip(
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
<div>
|
||||||
|
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
||||||
|
<div className={styles.value}>
|
||||||
|
<div>{dataPoints[0].raw.x}</div>
|
||||||
|
<div>{formatLongNumber(dataPoints[0].raw.y)}</div>
|
||||||
|
</div>
|
||||||
|
</StatusLight>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getOptions = useCallback(() => {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: animationDuration,
|
||||||
|
resize: {
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
external: renderTooltip,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
color: colors.line,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text,
|
||||||
|
autoSkip: false,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
min: 0,
|
||||||
|
beginAtZero: true,
|
||||||
|
stacked,
|
||||||
|
grid: {
|
||||||
|
color: colors.line,
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
color: colors.line,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.text,
|
||||||
|
callback: renderYLabel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [animationDuration, renderTooltip, stacked, colors, locale]);
|
||||||
|
|
||||||
|
const createChart = () => {
|
||||||
|
Chart.defaults.font.family = 'Inter';
|
||||||
|
|
||||||
|
const options = getOptions();
|
||||||
|
|
||||||
|
chart.current = new Chart(canvas.current, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { datasets },
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
onCreate(chart.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChart = () => {
|
||||||
|
setTooltip(null);
|
||||||
|
|
||||||
|
chart.current.data.datasets[0].data = datasets[0].data;
|
||||||
|
chart.current.data.datasets[0].label = datasets[0].label;
|
||||||
|
|
||||||
|
chart.current.options = getOptions();
|
||||||
|
|
||||||
|
onUpdate(chart.current);
|
||||||
|
|
||||||
|
chart.current.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (datasets) {
|
||||||
|
if (!chart.current) {
|
||||||
|
createChart();
|
||||||
|
} else {
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [datasets, theme, animationDuration, locale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classNames(styles.chart, className)}>
|
||||||
|
{loading && <Loading position="page" icon="dots" />}
|
||||||
|
<canvas ref={canvas} />
|
||||||
|
</div>
|
||||||
|
<Legend chart={chart.current} />
|
||||||
|
{tooltip && <HoverTooltip tooltip={tooltip} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelChart;
|
23
components/pages/reports/funnel/FunnelChart.module.css
Normal file
23
components/pages/reports/funnel/FunnelChart.module.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.chart {
|
||||||
|
position: relative;
|
||||||
|
height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip .value {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 992px) {
|
||||||
|
.chart {
|
||||||
|
/*height: 200px;*/
|
||||||
|
}
|
||||||
|
}
|
118
components/pages/reports/funnel/FunnelForm.js
Normal file
118
components/pages/reports/funnel/FunnelForm.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import DateFilter from 'components/input/DateFilter';
|
||||||
|
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import { parseDateRange } from 'lib/date';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormButtons,
|
||||||
|
FormInput,
|
||||||
|
FormRow,
|
||||||
|
SubmitButton,
|
||||||
|
TextField,
|
||||||
|
} from 'react-basics';
|
||||||
|
import styles from './FunnelForm.module.css';
|
||||||
|
|
||||||
|
export function FunnelForm({ onSearch }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const [dateRange, setDateRange] = useState('');
|
||||||
|
const [startAt, setStartAt] = useState();
|
||||||
|
const [endAt, setEndAt] = useState();
|
||||||
|
const [urls, setUrls] = useState(['/', '/docs/getting-started', '/docs/intall']);
|
||||||
|
const [websiteId, setWebsiteId] = useState('');
|
||||||
|
const [window, setWindow] = useState(60);
|
||||||
|
|
||||||
|
const handleSubmit = async data => {
|
||||||
|
onSearch(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = value => {
|
||||||
|
const { startDate, endDate } = parseDateRange(value);
|
||||||
|
|
||||||
|
setDateRange(value);
|
||||||
|
setStartAt(startDate.getTime());
|
||||||
|
setEndAt(endDate.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUrl = () => setUrls([...urls, '']);
|
||||||
|
|
||||||
|
const handleRemoveUrl = i => {
|
||||||
|
const nextUrls = [...urls];
|
||||||
|
nextUrls.splice(i, 1);
|
||||||
|
setUrls(nextUrls);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowChange = value => setWindow(value.target.value);
|
||||||
|
|
||||||
|
const handleUrlChange = (value, i) => {
|
||||||
|
const nextUrls = [...urls];
|
||||||
|
|
||||||
|
nextUrls[i] = value.target.value;
|
||||||
|
setUrls(nextUrls);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
values={{
|
||||||
|
websiteId,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
urls,
|
||||||
|
window,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<FormRow label={formatMessage(labels.website)}>
|
||||||
|
<WebsiteSelect websiteId={websiteId} onSelect={value => setWebsiteId(value)} />
|
||||||
|
<FormInput name="websiteId" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField value={websiteId} className={styles.hiddenInput} />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Date">
|
||||||
|
<DateFilter
|
||||||
|
className={styles.filter}
|
||||||
|
value={dateRange}
|
||||||
|
alignment="start"
|
||||||
|
onChange={handleDateChange}
|
||||||
|
isF
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="startAt"
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField value={startAt} />
|
||||||
|
</FormInput>
|
||||||
|
<FormInput name="endAt" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField value={endAt} className={styles.hiddenInput} />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Window (minutes)">
|
||||||
|
<FormInput
|
||||||
|
name="window"
|
||||||
|
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
|
||||||
|
>
|
||||||
|
<TextField value={window} onChange={handleWindowChange} />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<Button onClick={handleAddUrl}>Add URL</Button>
|
||||||
|
{urls.map((a, i) => (
|
||||||
|
<FormRow className={styles.urlFormRow} key={`url${i}`} label={`URL ${i + 1}`}>
|
||||||
|
<TextField value={urls[i]} onChange={value => handleUrlChange(value, i)} />
|
||||||
|
<Button onClick={() => handleRemoveUrl(i)}>Remove URL</Button>
|
||||||
|
</FormRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={false}>
|
||||||
|
Query
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelForm;
|
13
components/pages/reports/funnel/FunnelForm.module.css
Normal file
13
components/pages/reports/funnel/FunnelForm.module.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.filter {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
visibility: hidden;
|
||||||
|
min-height: 0px;
|
||||||
|
max-height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urlFormRow label {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
36
components/pages/reports/funnel/FunnelPage.js
Normal file
36
components/pages/reports/funnel/FunnelPage.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import Page from 'components/layout/Page';
|
||||||
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
|
import ReportsLayout from 'components/layout/ReportsLayout';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import FunnelChart from './FunnelChart';
|
||||||
|
import FunnelTable from './FunnelTable';
|
||||||
|
import FunnelForm from './FunnelForm';
|
||||||
|
|
||||||
|
export default function FunnelPage() {
|
||||||
|
const { post } = useApi();
|
||||||
|
const { mutate } = useMutation(data => post('/reports/funnel', data));
|
||||||
|
const [data, setData] = useState([{}]);
|
||||||
|
const [setFormData] = useState();
|
||||||
|
|
||||||
|
function handleOnSearch(data) {
|
||||||
|
setFormData(data);
|
||||||
|
|
||||||
|
mutate(data, {
|
||||||
|
onSuccess: async data => {
|
||||||
|
setData(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportsLayout filter={<FunnelForm onSearch={handleOnSearch} />} header={'test'}>
|
||||||
|
<Page>
|
||||||
|
<PageHeader title="Funnel Report"></PageHeader>
|
||||||
|
<FunnelChart data={data} />
|
||||||
|
<FunnelTable data={data} />
|
||||||
|
</Page>
|
||||||
|
</ReportsLayout>
|
||||||
|
);
|
||||||
|
}
|
10
components/pages/reports/funnel/FunnelPage.module.css
Normal file
10
components/pages/reports/funnel/FunnelPage.module.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid var(--base400);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
line-height: 32px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
12
components/pages/reports/funnel/FunnelTable.js
Normal file
12
components/pages/reports/funnel/FunnelTable.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import DataTable from 'components/metrics/DataTable';
|
||||||
|
|
||||||
|
export function FunnelTable({ ...props }) {
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
|
const tableData =
|
||||||
|
data?.map(a => ({ x: a.x, y: a.y, z: Math.floor(a.y / data[0].y) * 100 })) || [];
|
||||||
|
|
||||||
|
return <DataTable data={tableData} title="Url" type="device" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelTable;
|
@ -7,14 +7,19 @@ import useMessages from 'hooks/useMessages';
|
|||||||
export function DateRangeSetting() {
|
export function DateRangeSetting() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [dateRange, setDateRange] = useDateRange();
|
const [dateRange, setDateRange] = useDateRange();
|
||||||
const { startDate, endDate, value } = dateRange;
|
const { value } = dateRange;
|
||||||
|
|
||||||
const handleChange = value => setDateRange(value);
|
const handleChange = value => setDateRange(value);
|
||||||
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
|
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flexbox gap={10}>
|
<Flexbox gap={10}>
|
||||||
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
|
<DateFilter
|
||||||
|
value={value}
|
||||||
|
startDate={dateRange.startDate}
|
||||||
|
endDate={dateRange.endDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
|
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
);
|
);
|
||||||
|
19
db/postgresql/migrations/02_report_schema/migration.sql
Normal file
19
db/postgresql/migrations/02_report_schema/migration.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_report" (
|
||||||
|
"report_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"website_id" UUID NOT NULL,
|
||||||
|
"report_name" VARCHAR(200) NOT NULL,
|
||||||
|
"template_name" VARCHAR(200) NOT NULL,
|
||||||
|
"parameters" VARCHAR(6000) NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "user_report_pkey" PRIMARY KEY ("report_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_report_report_id_key" ON "user_report"("report_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_report_user_id_idx" ON "user_report"("user_id");
|
@ -14,11 +14,12 @@ model User {
|
|||||||
password String @db.VarChar(60)
|
password String @db.VarChar(60)
|
||||||
role String @map("role") @db.VarChar(50)
|
role String @map("role") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
website Website[]
|
website Website[]
|
||||||
teamUser TeamUser[]
|
teamUser TeamUser[]
|
||||||
|
Report Report[]
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@ -53,12 +54,13 @@ model Website {
|
|||||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||||
userId String? @map("user_id") @db.Uuid
|
userId String? @map("user_id") @db.Uuid
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
teamWebsite TeamWebsite[]
|
teamWebsite TeamWebsite[]
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
|
Report Report[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@ -116,7 +118,7 @@ model Team {
|
|||||||
name String @db.VarChar(50)
|
name String @db.VarChar(50)
|
||||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
teamUser TeamUser[]
|
teamUser TeamUser[]
|
||||||
teamWebsite TeamWebsite[]
|
teamWebsite TeamWebsite[]
|
||||||
@ -131,7 +133,7 @@ model TeamUser {
|
|||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
role String @map("role") @db.VarChar(50)
|
role String @map("role") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
@ -154,3 +156,22 @@ model TeamWebsite {
|
|||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@map("team_website")
|
@@map("team_website")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id() @unique() @map("report_id") @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
websiteId String @map("website_id") @db.Uuid
|
||||||
|
type String @map("type") @db.VarChar(200)
|
||||||
|
name String @map("name") @db.VarChar(200)
|
||||||
|
description String @map("description") @db.VarChar(500)
|
||||||
|
parameters String @map("parameters") @db.VarChar(6000)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@map("report")
|
||||||
|
}
|
||||||
|
38
lib/auth.ts
38
lib/auth.ts
@ -1,6 +1,6 @@
|
|||||||
import debug from 'debug';
|
import { UserReport } from '@prisma/client';
|
||||||
import redis from '@umami/redis-client';
|
import redis from '@umami/redis-client';
|
||||||
import cache from 'lib/cache';
|
import debug from 'debug';
|
||||||
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||||
import { secret } from 'lib/crypto';
|
import { secret } from 'lib/crypto';
|
||||||
import {
|
import {
|
||||||
@ -10,11 +10,11 @@ import {
|
|||||||
parseSecureToken,
|
parseSecureToken,
|
||||||
parseToken,
|
parseToken,
|
||||||
} from 'next-basics';
|
} from 'next-basics';
|
||||||
import { getTeamUser, getTeamUserById } from 'queries';
|
import { getTeamUser } from 'queries';
|
||||||
import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite';
|
import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite';
|
||||||
import { validate } from 'uuid';
|
import { validate } from 'uuid';
|
||||||
import { Auth } from './types';
|
|
||||||
import { loadWebsite } from './query';
|
import { loadWebsite } from './query';
|
||||||
|
import { Auth } from './types';
|
||||||
|
|
||||||
const log = debug('umami:auth');
|
const log = debug('umami:auth');
|
||||||
|
|
||||||
@ -135,7 +135,34 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// To-do: Implement when payments are setup.
|
export async function canViewUserReport(auth: Auth, userReport: UserReport) {
|
||||||
|
if (auth.user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((auth.user.id = userReport.userId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await canViewWebsite(auth, userReport.websiteId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canUpdateUserReport(auth: Auth, userReport: UserReport) {
|
||||||
|
if (auth.user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((auth.user.id = userReport.userId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function canCreateTeam({ user }: Auth) {
|
export async function canCreateTeam({ user }: Auth) {
|
||||||
if (user.isAdmin) {
|
if (user.isAdmin) {
|
||||||
return true;
|
return true;
|
||||||
@ -144,7 +171,6 @@ export async function canCreateTeam({ user }: Auth) {
|
|||||||
return !!user;
|
return !!user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// To-do: Implement when payments are setup.
|
|
||||||
export async function canViewTeam({ user }: Auth, teamId: string) {
|
export async function canViewTeam({ user }: Auth, teamId: string) {
|
||||||
if (user.isAdmin) {
|
if (user.isAdmin) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -121,13 +121,36 @@ function getFilterQuery(filters = {}, params = {}) {
|
|||||||
return query.join('\n');
|
return query.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFunnelQuery(urls: string[]): {
|
||||||
|
columnsQuery: string;
|
||||||
|
conditionQuery: string;
|
||||||
|
urlParams: { [key: string]: string };
|
||||||
|
} {
|
||||||
|
return urls.reduce(
|
||||||
|
(pv, cv, i) => {
|
||||||
|
pv.columnsQuery += `\n,url_path = {url${i}:String}${
|
||||||
|
i > 0 && urls[i - 1] ? ` AND referrer_path = {url${i - 1}:String}` : ''
|
||||||
|
}`;
|
||||||
|
pv.conditionQuery += `${i > 0 ? ',' : ''} {url${i}:String}`;
|
||||||
|
pv.urlParams[`url${i}`] = cv;
|
||||||
|
|
||||||
|
return pv;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnsQuery: '',
|
||||||
|
conditionQuery: '',
|
||||||
|
urlParams: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) {
|
function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) {
|
||||||
return {
|
return {
|
||||||
filterQuery: getFilterQuery(filters, params),
|
filterQuery: getFilterQuery(filters, params),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rawQuery(query, params = {}) {
|
async function rawQuery<T>(query, params = {}): Promise<T> {
|
||||||
if (process.env.LOG_QUERY) {
|
if (process.env.LOG_QUERY) {
|
||||||
log('QUERY:\n', query);
|
log('QUERY:\n', query);
|
||||||
log('PARAMETERS:\n', params);
|
log('PARAMETERS:\n', params);
|
||||||
@ -135,7 +158,7 @@ async function rawQuery(query, params = {}) {
|
|||||||
|
|
||||||
await connect();
|
await connect();
|
||||||
|
|
||||||
return clickhouse.query(query, { params }).toPromise();
|
return clickhouse.query(query, { params }).toPromise() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findUnique(data) {
|
async function findUnique(data) {
|
||||||
@ -168,6 +191,7 @@ export default {
|
|||||||
getDateFormat,
|
getDateFormat,
|
||||||
getBetweenDates,
|
getBetweenDates,
|
||||||
getFilterQuery,
|
getFilterQuery,
|
||||||
|
getFunnelQuery,
|
||||||
getEventDataFilterQuery,
|
getEventDataFilterQuery,
|
||||||
parseFilters,
|
parseFilters,
|
||||||
findUnique,
|
findUnique,
|
||||||
|
@ -32,6 +32,18 @@ function toUuid(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAddMinutesQuery(field: string, minutes: number) {
|
||||||
|
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
if (db === POSTGRESQL) {
|
||||||
|
return `${field} + interval '${minutes} minute'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db === MYSQL) {
|
||||||
|
return `DATE_ADD(${field}, interval ${minutes} minute)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDateQuery(field: string, unit: string, timezone?: string): string {
|
function getDateQuery(field: string, unit: string, timezone?: string): string {
|
||||||
const db = getDatabaseType(process.env.DATABASE_URL);
|
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||||
|
|
||||||
@ -122,6 +134,50 @@ function getFilterQuery(filters = {}, params = []): string {
|
|||||||
return query.join('\n');
|
return query.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFunnelQuery(
|
||||||
|
urls: string[],
|
||||||
|
windowMinutes: number,
|
||||||
|
initParamLength = 3,
|
||||||
|
): {
|
||||||
|
levelQuery: string;
|
||||||
|
sumQuery: string;
|
||||||
|
urlFilterQuery: string;
|
||||||
|
} {
|
||||||
|
return urls.reduce(
|
||||||
|
(pv, cv, i) => {
|
||||||
|
const levelNumber = i + 1;
|
||||||
|
const start = i > 0 ? ',' : '';
|
||||||
|
|
||||||
|
if (levelNumber >= 2) {
|
||||||
|
pv.levelQuery += `\n
|
||||||
|
, level${levelNumber} AS (
|
||||||
|
select cl.*,
|
||||||
|
l0.created_at level_${levelNumber}_created_at,
|
||||||
|
l0.url_path as level_${levelNumber}_url
|
||||||
|
from level${i} cl
|
||||||
|
left join level0 l0
|
||||||
|
on cl.session_id = l0.session_id
|
||||||
|
and l0.created_at between cl.level_${i}_created_at
|
||||||
|
and ${getAddMinutesQuery(`cl.level_${i}_created_at`, windowMinutes)}
|
||||||
|
and l0.referrer_path = $${i + initParamLength}
|
||||||
|
and l0.url_path = $${levelNumber + initParamLength}
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pv.sumQuery += `\n${start}SUM(CASE WHEN level_${levelNumber}_url is not null THEN 1 ELSE 0 END) AS level${levelNumber}`;
|
||||||
|
|
||||||
|
pv.urlFilterQuery += `\n${start}$${levelNumber + initParamLength} `;
|
||||||
|
|
||||||
|
return pv;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
levelQuery: '',
|
||||||
|
sumQuery: '',
|
||||||
|
urlFilterQuery: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function parseFilters(
|
function parseFilters(
|
||||||
filters: { [key: string]: any } = {},
|
filters: { [key: string]: any } = {},
|
||||||
params = [],
|
params = [],
|
||||||
@ -152,9 +208,11 @@ async function rawQuery(query: string, params: never[] = []): Promise<any> {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
...prisma,
|
...prisma,
|
||||||
|
getAddMinutesQuery,
|
||||||
getDateQuery,
|
getDateQuery,
|
||||||
getTimestampInterval,
|
getTimestampInterval,
|
||||||
getFilterQuery,
|
getFilterQuery,
|
||||||
|
getFunnelQuery,
|
||||||
getEventDataFilterQuery,
|
getEventDataFilterQuery,
|
||||||
toUuid,
|
toUuid,
|
||||||
parseFilters,
|
parseFilters,
|
||||||
|
60
pages/api/reports/[id].ts
Normal file
60
pages/api/reports/[id].ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { canUpdateUserReport, canViewUserReport } from 'lib/auth';
|
||||||
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
|
import { getUserReportById, updateUserReport } from 'queries';
|
||||||
|
|
||||||
|
export interface UserReportRequestQuery {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserReportRequestBody {
|
||||||
|
websiteId: string;
|
||||||
|
reportName: string;
|
||||||
|
templateName: string;
|
||||||
|
parameters: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<UserReportRequestQuery, UserReportRequestBody>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
|
await useCors(req, res);
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const { id: userReportId } = req.query;
|
||||||
|
|
||||||
|
const data = await getUserReportById(userReportId);
|
||||||
|
|
||||||
|
if (!(await canViewUserReport(req.auth, data))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { id: userReportId } = req.query;
|
||||||
|
|
||||||
|
const data = await getUserReportById(userReportId);
|
||||||
|
|
||||||
|
if (!(await canUpdateUserReport(req.auth, data))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateUserReport(
|
||||||
|
{
|
||||||
|
...req.body,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: userReportId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return ok(res, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
50
pages/api/reports/funnel.ts
Normal file
50
pages/api/reports/funnel.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { useCors, useAuth } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
|
||||||
|
import { getPageviewFunnel } from 'queries';
|
||||||
|
|
||||||
|
export interface FunnelRequestBody {
|
||||||
|
websiteId: string;
|
||||||
|
urls: string[];
|
||||||
|
window: number;
|
||||||
|
startAt: number;
|
||||||
|
endAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelResponse {
|
||||||
|
urls: string[];
|
||||||
|
window: number;
|
||||||
|
startAt: number;
|
||||||
|
endAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<any, FunnelRequestBody>,
|
||||||
|
res: NextApiResponse<FunnelResponse>,
|
||||||
|
) => {
|
||||||
|
await useCors(req, res);
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { websiteId, urls, window, startAt, endAt } = req.body;
|
||||||
|
if (!(await canViewWebsite(req.auth, websiteId))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getPageviewFunnel(websiteId, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
urls,
|
||||||
|
windowMinutes: +window,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
43
pages/api/reports/index.ts
Normal file
43
pages/api/reports/index.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok } from 'next-basics';
|
||||||
|
import { createUserReport, getUserReports } from 'queries';
|
||||||
|
|
||||||
|
export interface UserReportRequestBody {
|
||||||
|
websiteId: string;
|
||||||
|
reportName: string;
|
||||||
|
templateName: string;
|
||||||
|
parameters: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<any, UserReportRequestBody>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
|
await useCors(req, res);
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
const {
|
||||||
|
user: { id: userId },
|
||||||
|
} = req.auth;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const data = await getUserReports(userId);
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const data = await createUserReport({
|
||||||
|
id: uuid(),
|
||||||
|
userId,
|
||||||
|
...req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
13
pages/reports/funnel.js
Normal file
13
pages/reports/funnel.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
|
import FunnelPage from 'components/pages/reports/funnel/FunnelPage';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
|
||||||
|
export default function Funnel() {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout title={`${formatMessage(labels.settings)} - ${formatMessage(labels.reports)}`}>
|
||||||
|
<FunnelPage />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -210,6 +210,20 @@ export async function deleteUser(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
client.userReport.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
websiteId: {
|
||||||
|
in: websiteIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
cloudMode
|
cloudMode
|
||||||
? client.website.updateMany({
|
? client.website.updateMany({
|
||||||
data: {
|
data: {
|
||||||
|
37
queries/admin/userReport.ts
Normal file
37
queries/admin/userReport.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Prisma, UserReport } from '@prisma/client';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
|
export async function createUserReport(
|
||||||
|
data: Prisma.UserReportUncheckedCreateInput,
|
||||||
|
): Promise<UserReport> {
|
||||||
|
return prisma.client.userReport.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserReportById(userReportId: string): Promise<UserReport> {
|
||||||
|
return prisma.client.userReport.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userReportId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserReports(userId: string): Promise<UserReport[]> {
|
||||||
|
return prisma.client.userReport.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserReport(
|
||||||
|
data: Prisma.UserReportUpdateInput,
|
||||||
|
where: Prisma.UserReportWhereUniqueInput,
|
||||||
|
): Promise<UserReport> {
|
||||||
|
return prisma.client.userReport.update({ data, where });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserReport(
|
||||||
|
where: Prisma.UserReportWhereUniqueInput,
|
||||||
|
): Promise<UserReport> {
|
||||||
|
return prisma.client.userReport.delete({ where });
|
||||||
|
}
|
@ -92,6 +92,11 @@ export async function deleteWebsite(
|
|||||||
websiteId,
|
websiteId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
client.userReport.deleteMany({
|
||||||
|
where: {
|
||||||
|
websiteId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
cloudMode
|
cloudMode
|
||||||
? prisma.client.website.update({
|
? prisma.client.website.update({
|
||||||
data: {
|
data: {
|
||||||
|
115
queries/analytics/pageview/getPageviewFunnel.ts
Normal file
115
queries/analytics/pageview/getPageviewFunnel.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import clickhouse from 'lib/clickhouse';
|
||||||
|
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
|
export async function getPageviewFunnel(
|
||||||
|
...args: [
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
windowMinutes: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
urls: string[];
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
windowMinutes: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
urls: string[];
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const { windowMinutes, startDate, endDate, urls } = criteria;
|
||||||
|
const { rawQuery, getFunnelQuery, toUuid } = prisma;
|
||||||
|
const { levelQuery, sumQuery, urlFilterQuery } = getFunnelQuery(urls, windowMinutes);
|
||||||
|
|
||||||
|
const params: any = [websiteId, startDate, endDate, ...urls];
|
||||||
|
|
||||||
|
return rawQuery(
|
||||||
|
`WITH level0 AS (
|
||||||
|
select session_id, url_path, referrer_path, created_at
|
||||||
|
from website_event
|
||||||
|
where url_path in (${urlFilterQuery})
|
||||||
|
and website_id = $1${toUuid()}
|
||||||
|
and created_at between $2 and $3
|
||||||
|
),level1 AS (
|
||||||
|
select session_id, url_path as level_1_url, created_at as level_1_created_at
|
||||||
|
from level0
|
||||||
|
where url_path = $4
|
||||||
|
)${levelQuery}
|
||||||
|
|
||||||
|
SELECT ${sumQuery}
|
||||||
|
from level3;
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
).then((a: { [key: string]: number }) => {
|
||||||
|
return urls.map((b, i) => ({ x: b, y: a[`level${i + 1}`] || 0 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
windowMinutes: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
urls: string[];
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const { windowMinutes, startDate, endDate, urls } = criteria;
|
||||||
|
const { rawQuery, getBetweenDates, getFunnelQuery } = clickhouse;
|
||||||
|
const { columnsQuery, conditionQuery, urlParams } = getFunnelQuery(urls);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
websiteId,
|
||||||
|
window: windowMinutes * 60,
|
||||||
|
...urlParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
return rawQuery<{ level: number; count: number }[]>(
|
||||||
|
`
|
||||||
|
SELECT level,
|
||||||
|
count(*) AS count
|
||||||
|
FROM (
|
||||||
|
SELECT session_id,
|
||||||
|
windowFunnel({window:UInt32}, 'strict_order')
|
||||||
|
(
|
||||||
|
created_at
|
||||||
|
${columnsQuery}
|
||||||
|
) AS level
|
||||||
|
FROM website_event
|
||||||
|
WHERE website_id = {websiteId:UUID}
|
||||||
|
and ${getBetweenDates('created_at', startDate, endDate)}
|
||||||
|
AND (url_path in [${conditionQuery}])
|
||||||
|
GROUP BY 1
|
||||||
|
)
|
||||||
|
GROUP BY level
|
||||||
|
ORDER BY level ASC;
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
).then(results => {
|
||||||
|
return urls.map((a, i) => ({
|
||||||
|
x: a,
|
||||||
|
y: results[i + 1]?.count || 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
export * from './admin/team';
|
export * from './admin/team';
|
||||||
export * from './admin/teamUser';
|
export * from './admin/teamUser';
|
||||||
export * from './admin/user';
|
export * from './admin/user';
|
||||||
|
export * from './admin/userReport';
|
||||||
export * from './admin/website';
|
export * from './admin/website';
|
||||||
export * from './analytics/event/getEventMetrics';
|
export * from './analytics/event/getEventMetrics';
|
||||||
export * from './analytics/event/getEventUsage';
|
export * from './analytics/event/getEventUsage';
|
||||||
@ -8,6 +9,7 @@ export * from './analytics/event/getEvents';
|
|||||||
export * from './analytics/eventData/getEventData';
|
export * from './analytics/eventData/getEventData';
|
||||||
export * from './analytics/eventData/getEventDataUsage';
|
export * from './analytics/eventData/getEventDataUsage';
|
||||||
export * from './analytics/event/saveEvent';
|
export * from './analytics/event/saveEvent';
|
||||||
|
export * from './analytics/pageview/getPageviewFunnel';
|
||||||
export * from './analytics/pageview/getPageviewMetrics';
|
export * from './analytics/pageview/getPageviewMetrics';
|
||||||
export * from './analytics/pageview/getPageviewStats';
|
export * from './analytics/pageview/getPageviewStats';
|
||||||
export * from './analytics/session/createSession';
|
export * from './analytics/session/createSession';
|
||||||
|
@ -5500,6 +5500,11 @@ functions-have-names@^1.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
|
funnel-graph-js@^1.3.7:
|
||||||
|
version "1.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/funnel-graph-js/-/funnel-graph-js-1.4.2.tgz#b82150189e8afa59104d881d5dcf55a28d715342"
|
||||||
|
integrity sha512-9bnmcBve7RDH9dTF9BLuUpuisKkDka3yrfhs+Z/106ZgJvqIse1RfKQWjW+QdAlTrZqC9oafen7t/KuJKv9ohA==
|
||||||
|
|
||||||
generic-names@^4.0.0:
|
generic-names@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"
|
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"
|
||||||
|
Loading…
Reference in New Issue
Block a user