mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-15 09:45:04 +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.reports), url: '/reports' },
|
||||
{ label: formatMessage(labels.realtime), url: '/realtime' },
|
||||
{ label: formatMessage(labels.reports), url: '/reports/funnel' },
|
||||
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
|
||||
].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' },
|
||||
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
||||
details: { id: 'label.details', defaultMessage: 'Details' },
|
||||
website: { id: 'label.website', defaultMessage: 'Website' },
|
||||
websites: { id: 'label.websites', defaultMessage: 'Websites' },
|
||||
created: { id: 'label.created', defaultMessage: 'Created' },
|
||||
edit: { id: 'label.edit', defaultMessage: 'Edit' },
|
||||
@ -186,6 +187,10 @@ export const messages = defineMessages({
|
||||
id: 'message.delete-website-warning',
|
||||
defaultMessage: 'All website data will be deleted.',
|
||||
},
|
||||
noResultsFound: {
|
||||
id: 'messages.no-results-found',
|
||||
defaultMessage: 'No results were found.',
|
||||
},
|
||||
noWebsitesConfigured: {
|
||||
id: 'messages.no-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() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [dateRange, setDateRange] = useDateRange();
|
||||
const { startDate, endDate, value } = dateRange;
|
||||
const { value } = dateRange;
|
||||
|
||||
const handleChange = value => setDateRange(value);
|
||||
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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)
|
||||
role String @map("role") @db.VarChar(50)
|
||||
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)
|
||||
|
||||
website Website[]
|
||||
teamUser TeamUser[]
|
||||
Report Report[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@ -53,12 +54,13 @@ model Website {
|
||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
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)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
teamWebsite TeamWebsite[]
|
||||
eventData EventData[]
|
||||
Report Report[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@ -116,7 +118,7 @@ model Team {
|
||||
name String @db.VarChar(50)
|
||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||
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[]
|
||||
teamWebsite TeamWebsite[]
|
||||
@ -131,7 +133,7 @@ model TeamUser {
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role String @map("role") @db.VarChar(50)
|
||||
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])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
@ -154,3 +156,22 @@ model TeamWebsite {
|
||||
@@index([websiteId])
|
||||
@@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 cache from 'lib/cache';
|
||||
import debug from 'debug';
|
||||
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||
import { secret } from 'lib/crypto';
|
||||
import {
|
||||
@ -10,11 +10,11 @@ import {
|
||||
parseSecureToken,
|
||||
parseToken,
|
||||
} from 'next-basics';
|
||||
import { getTeamUser, getTeamUserById } from 'queries';
|
||||
import { getTeamUser } from 'queries';
|
||||
import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite';
|
||||
import { validate } from 'uuid';
|
||||
import { Auth } from './types';
|
||||
import { loadWebsite } from './query';
|
||||
import { Auth } from './types';
|
||||
|
||||
const log = debug('umami:auth');
|
||||
|
||||
@ -135,7 +135,34 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
|
||||
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) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
@ -144,7 +171,6 @@ export async function canCreateTeam({ user }: Auth) {
|
||||
return !!user;
|
||||
}
|
||||
|
||||
// To-do: Implement when payments are setup.
|
||||
export async function canViewTeam({ user }: Auth, teamId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
|
@ -121,13 +121,36 @@ function getFilterQuery(filters = {}, params = {}) {
|
||||
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 = {}) {
|
||||
return {
|
||||
filterQuery: getFilterQuery(filters, params),
|
||||
};
|
||||
}
|
||||
|
||||
async function rawQuery(query, params = {}) {
|
||||
async function rawQuery<T>(query, params = {}): Promise<T> {
|
||||
if (process.env.LOG_QUERY) {
|
||||
log('QUERY:\n', query);
|
||||
log('PARAMETERS:\n', params);
|
||||
@ -135,7 +158,7 @@ async function rawQuery(query, params = {}) {
|
||||
|
||||
await connect();
|
||||
|
||||
return clickhouse.query(query, { params }).toPromise();
|
||||
return clickhouse.query(query, { params }).toPromise() as Promise<T>;
|
||||
}
|
||||
|
||||
async function findUnique(data) {
|
||||
@ -168,6 +191,7 @@ export default {
|
||||
getDateFormat,
|
||||
getBetweenDates,
|
||||
getFilterQuery,
|
||||
getFunnelQuery,
|
||||
getEventDataFilterQuery,
|
||||
parseFilters,
|
||||
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 {
|
||||
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||
|
||||
@ -122,6 +134,50 @@ function getFilterQuery(filters = {}, params = []): string {
|
||||
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(
|
||||
filters: { [key: string]: any } = {},
|
||||
params = [],
|
||||
@ -152,9 +208,11 @@ async function rawQuery(query: string, params: never[] = []): Promise<any> {
|
||||
|
||||
export default {
|
||||
...prisma,
|
||||
getAddMinutesQuery,
|
||||
getDateQuery,
|
||||
getTimestampInterval,
|
||||
getFilterQuery,
|
||||
getFunnelQuery,
|
||||
getEventDataFilterQuery,
|
||||
toUuid,
|
||||
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
|
||||
? client.website.updateMany({
|
||||
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,
|
||||
},
|
||||
}),
|
||||
client.userReport.deleteMany({
|
||||
where: {
|
||||
websiteId,
|
||||
},
|
||||
}),
|
||||
cloudMode
|
||||
? prisma.client.website.update({
|
||||
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/teamUser';
|
||||
export * from './admin/user';
|
||||
export * from './admin/userReport';
|
||||
export * from './admin/website';
|
||||
export * from './analytics/event/getEventMetrics';
|
||||
export * from './analytics/event/getEventUsage';
|
||||
@ -8,6 +9,7 @@ export * from './analytics/event/getEvents';
|
||||
export * from './analytics/eventData/getEventData';
|
||||
export * from './analytics/eventData/getEventDataUsage';
|
||||
export * from './analytics/event/saveEvent';
|
||||
export * from './analytics/pageview/getPageviewFunnel';
|
||||
export * from './analytics/pageview/getPageviewMetrics';
|
||||
export * from './analytics/pageview/getPageviewStats';
|
||||
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"
|
||||
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:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"
|
||||
|
Loading…
Reference in New Issue
Block a user