Merge pull request #1580 from umami-software/dev

v1.39
This commit is contained in:
Mike Cao 2022-10-24 18:03:43 -07:00 committed by GitHub
commit 80f22313f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 2624 additions and 1864 deletions

View File

@ -48,13 +48,7 @@ mysql://username:mypassword@localhost:3306/mydb
yarn build
```
### Create database tables
```bash
yarn update-db
```
This will also create a login account with username **admin** and password **umami**.
The build step will also create tables in your database if you ae installing for the first time. It will also create a login account with username **admin** and password **umami**.
### Start the application

View File

@ -0,0 +1,48 @@
import List from 'assets/list-ul.svg';
import Modal from 'components/common/Modal';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import Button from './Button';
import EventDataForm from 'components/forms/EventDataForm';
import styles from './EventDataButton.module.css';
function EventDataButton({ websiteId }) {
const [showEventData, setShowEventData] = useState(false);
function handleClick() {
if (!showEventData) {
setShowEventData(true);
}
}
function handleClose() {
setShowEventData(false);
}
return (
<>
<Button
icon={<List />}
tooltip={<FormattedMessage id="label.event-data" defaultMessage="Event" />}
tooltipId="button-event"
size="small"
onClick={handleClick}
className={styles.button}
>
Event Data
</Button>
{showEventData && (
<Modal title={<FormattedMessage id="label.event-data" defaultMessage="Query Event Data" />}>
<EventDataForm websiteId={websiteId} onClose={handleClose} />
</Modal>
)}
</>
);
}
EventDataButton.propTypes = {
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default EventDataButton;

View File

@ -0,0 +1,3 @@
.button {
width: fit-content;
}

View File

@ -11,7 +11,7 @@ import useDateRange from 'hooks/useDateRange';
function RefreshButton({ websiteId }) {
const [dateRange] = useDateRange(websiteId);
const [loading, setLoading] = useState(false);
const selector = useCallback(state => state[`/website/${websiteId}/stats`], [websiteId]);
const selector = useCallback(state => state[`/websites/${websiteId}/stats`], [websiteId]);
const completed = useStore(selector);
function handleClick() {

View File

@ -18,7 +18,7 @@ export default function UpdateNotice() {
function handleViewClick() {
updateCheck();
setDismissed(true);
location.href = releaseUrl || REPO_URL;
open(releaseUrl || REPO_URL, '_blank');
}
function handleDismissClick() {

View File

@ -15,13 +15,13 @@ const initialValues = {
password: '',
};
const validate = ({ user_id, username, password }) => {
const validate = ({ id, username, password }) => {
const errors = {};
if (!username) {
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!user_id && !password) {
if (!id && !password) {
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
@ -33,7 +33,8 @@ export default function AccountEditForm({ values, onSave, onClose }) {
const [message, setMessage] = useState();
const handleSubmit = async values => {
const { ok, data } = await post('/account', values);
const { id } = values;
const { ok, data } = await post(id ? `/accounts/${id}` : '/accounts', values);
if (ok) {
onSave();

View File

@ -9,6 +9,7 @@ import FormLayout, {
FormRow,
} from 'components/layout/FormLayout';
import useApi from 'hooks/useApi';
import useUser from '../../hooks/useUser';
const initialValues = {
current_password: '',
@ -39,9 +40,10 @@ const validate = ({ current_password, new_password, confirm_password }) => {
export default function ChangePasswordForm({ values, onSave, onClose }) {
const { post } = useApi();
const [message, setMessage] = useState();
const { user } = useUser();
const handleSubmit = async values => {
const { ok, data } = await post('/account/password', values);
const { ok, data } = await post(`/accounts/${user.userId}/password`, values);
if (ok) {
onSave();

View File

@ -0,0 +1,262 @@
import classNames from 'classnames';
import Button from 'components/common/Button';
import DateFilter from 'components/common/DateFilter';
import DropDown from 'components/common/DropDown';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import DataTable from 'components/metrics/DataTable';
import FilterTags from 'components/metrics/FilterTags';
import { Field, Form, Formik } from 'formik';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import { useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import styles from './EventDataForm.module.css';
import useTimezone from 'hooks/useTimezone';
export const filterOptions = [
{ label: 'Count', value: 'count' },
{ label: 'Average', value: 'avg' },
{ label: 'Minimum', value: 'min' },
{ label: 'Maxmimum', value: 'max' },
{ label: 'Sum', value: 'sum' },
];
export const dateOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
{
label: (
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
),
value: '24hour',
},
{
label: <FormattedMessage id="label.yesterday" defaultMessage="Yesterday" />,
value: '-1day',
},
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
),
value: '7day',
},
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
value: '1month',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
),
value: '30day',
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 90 }} />
),
value: '90day',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
value: 'custom',
divider: true,
},
];
export default function EventDataForm({ websiteId, onClose, className }) {
const { post } = useApi();
const [message, setMessage] = useState();
const [columns, setColumns] = useState({});
const [filters, setFilters] = useState({});
const [data, setData] = useState([]);
const [dateRange, setDateRange] = useDateRange('report');
const { startDate, endDate, value } = dateRange;
const [timezone] = useTimezone();
const [isValid, setIsValid] = useState(false);
useEffect(() => {
if (Object.keys(columns).length > 0) {
setIsValid(true);
} else {
setIsValid(false);
}
}, [columns]);
const handleAddTag = (value, list, setState, resetForm) => {
setState({ ...list, [`${value.field}`]: value.value });
resetForm();
};
const handleRemoveTag = (value, list, setState) => {
const newList = { ...list };
delete newList[`${value}`];
setState(newList);
};
const handleSubmit = async () => {
const params = {
website_id: websiteId,
start_at: +startDate,
end_at: +endDate,
timezone,
columns,
filters,
};
const { ok, data } = await post(`/websites/${websiteId}/eventdata`, params);
if (!ok) {
setMessage(<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />);
setData([]);
} else {
setData(data);
setMessage(null);
}
};
return (
<>
<FormMessage>{message}</FormMessage>
<div className={classNames(styles.container, className)}>
<div className={styles.form}>
<FormLayout>
<div className={styles.filters}>
<FormRow>
<label htmlFor="date-range">
<FormattedMessage id="label.date-range" defaultMessage="Date Range" />
</label>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={setDateRange}
options={dateOptions}
/>
</FormRow>
</div>
<div className={styles.filters}>
<Formik
initialValues={{ field: '', value: '' }}
onSubmit={(value, { resetForm }) =>
handleAddTag(value, columns, setColumns, resetForm)
}
>
{({ values, setFieldValue }) => (
<Form>
<FormRow>
<label htmlFor="field">
<FormattedMessage id="label.field-name" defaultMessage="Field Name" />
</label>
<div>
<Field name="field" type="text" />
<FormError name="field" />
</div>
</FormRow>
<FormRow>
<label htmlFor="value">
<FormattedMessage id="label.type" defaultMessage="Type" />
</label>
<div>
<DropDown
value={values.value}
onChange={value => setFieldValue('value', value)}
className={styles.dropdown}
name="value"
options={filterOptions}
/>
<FormError name="value" />
</div>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button
variant="action"
type="submit"
disabled={!values.field || !values.value}
>
<FormattedMessage id="label.add-column" defaultMessage="Add Column" />
</Button>
</FormButtons>
</Form>
)}
</Formik>
<FilterTags
className={styles.filterTag}
params={columns}
onClick={value => handleRemoveTag(value, columns, setColumns)}
/>
</div>
<div className={styles.filters}>
<Formik
initialValues={{ field: '', value: '' }}
onSubmit={(value, { resetForm }) =>
handleAddTag(value, filters, setFilters, resetForm)
}
>
{({ values }) => (
<Form>
<FormRow>
<label htmlFor="field">
<FormattedMessage id="label.field-name" defaultMessage="Field Name" />
</label>
<div>
<Field name="field" type="text" />
<FormError name="field" />
</div>
</FormRow>
<FormRow>
<label htmlFor="value">
<FormattedMessage id="label.value" defaultMessage="Value" />
</label>
<div>
<Field name="value" type="text" />
<FormError name="value" />
</div>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button
variant="action"
type="submit"
disabled={!values.field || !values.value}
>
<FormattedMessage id="label.add-filter" defaultMessage="Add Filter" />
</Button>
</FormButtons>
</Form>
)}
</Formik>
<FilterTags
className={styles.filterTag}
params={filters}
onClick={value => handleRemoveTag(value, filters, setFilters)}
/>
</div>
</FormLayout>
</div>
<div>
<DataTable className={styles.table} data={data} title="Results" showPercentage={false} />
</div>
</div>
<FormButtons>
<Button variant="action" onClick={handleSubmit} disabled={!isValid}>
<FormattedMessage id="label.search" defaultMessage="Search" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</>
);
}

View File

@ -0,0 +1,38 @@
.container {
display: flex;
}
.form {
border-right: 1px solid var(--gray300);
width: 420px;
}
.filters {
padding: 10px 5px;
}
.filters + .filters {
border-top: 1px solid var(--gray300);
min-height: 250px;
}
.table {
padding: 10px;
min-height: 430px;
min-width: 400px;
}
.formButtons {
justify-content: flex-start;
margin-left: 20px;
}
.dropdown {
min-height: 39px;
min-width: 240px;
}
.filterTag {
flex-wrap: wrap;
margin: 10px 5px 5px 5px;
}

View File

@ -8,7 +8,7 @@ import CopyButton from 'components/common/CopyButton';
export default function TrackingCodeForm({ values, onClose }) {
const ref = useRef();
const { basePath } = useRouter();
const { name, share_id } = values;
const { name, shareId } = values;
return (
<FormLayout>
@ -27,7 +27,7 @@ export default function TrackingCodeForm({ values, onClose }) {
spellCheck={false}
defaultValue={`${
document.location.origin
}${basePath}/share/${share_id}/${encodeURIComponent(name)}`}
}${basePath}/share/${shareId}/${encodeURIComponent(name)}`}
readOnly
/>
</FormRow>

View File

@ -26,7 +26,7 @@ export default function TrackingCodeForm({ values, onClose }) {
rows={3}
cols={60}
spellCheck={false}
defaultValue={`<script async defer data-website-id="${values.website_uuid}" src="${
defaultValue={`<script async defer data-website-id="${values.websiteUuid}" src="${
document.location.origin
}${basePath}/${trackerScriptName ? `${trackerScriptName}.js` : 'umami.js'}"></script>`}
readOnly

View File

@ -38,18 +38,17 @@ const validate = ({ name, domain }) => {
};
const OwnerDropDown = ({ user, accounts }) => {
console.info(styles);
const { setFieldValue, values } = useFormikContext();
useEffect(() => {
if (values.user_id != null && values.owner === '') {
setFieldValue('owner', values.user_id.toString());
} else if (user?.user_id && values.owner === '') {
setFieldValue('owner', user.user_id.toString());
if (values.userId != null && values.owner === '') {
setFieldValue('owner', values.userId.toString());
} else if (user?.id && values.owner === '') {
setFieldValue('owner', user.id.toString());
}
}, [accounts, setFieldValue, user, values]);
if (user?.is_admin) {
if (user?.isAdmin) {
return (
<FormRow>
<label htmlFor="owner">
@ -58,7 +57,7 @@ const OwnerDropDown = ({ user, accounts }) => {
<div>
<Field as="select" name="owner" className={styles.dropdown}>
{accounts?.map(acc => (
<option key={acc.user_id} value={acc.user_id}>
<option key={acc.id} value={acc.id}>
{acc.username}
</option>
))}
@ -79,7 +78,9 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
const [message, setMessage] = useState();
const handleSubmit = async values => {
const { ok, data } = await post('/website', values);
const { id: websiteId } = values;
const { ok, data } = await post(websiteId ? `/websites/${websiteId}` : '/websites', values);
if (ok) {
onSave();
@ -93,7 +94,7 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values, enable_share_url: !!values?.share_id }}
initialValues={{ ...initialValues, ...values, enableShareUrl: !!values?.shareId }}
validate={validate}
onSubmit={handleSubmit}
>
@ -117,9 +118,9 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
name="domain"
type="text"
placeholder="example.com"
spellcheck="false"
autocapitalize="off"
autocorrect="off"
spellCheck="false"
autoCapitalize="off"
autoCorrect="off"
/>
<FormError name="domain" />
</div>
@ -127,7 +128,7 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
<OwnerDropDown accounts={accounts} user={user} />
<FormRow>
<label />
<Field name="enable_share_url">
<Field name="enableShareUrl">
{({ field }) => (
<Checkbox
{...field}

View File

@ -19,7 +19,7 @@ export default function Header() {
const { pathname } = useRouter();
const { updatesDisabled } = useConfig();
const isSharePage = pathname.includes('/share/');
const allowUpdate = user?.is_admin && !updatesDisabled && !isSharePage;
const allowUpdate = user?.isAdmin && !updatesDisabled && !isSharePage;
return (
<>
@ -38,9 +38,11 @@ export default function Header() {
<Link href="/realtime">
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</Link>
<Link href="/settings">
<FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link>
{!process.env.isCloudMode && (
<Link href="/settings">
<FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link>
)}
</div>
)}
<div className={styles.buttons}>

View File

@ -6,7 +6,7 @@ import Dot from 'components/common/Dot';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className, value, interval = 60000 }) {
const url = websiteId ? `/website/${websiteId}/active` : null;
const url = websiteId ? `/websites/${websiteId}/active` : null;
const { data } = useFetch(url, {
interval,
});

View File

@ -16,6 +16,7 @@ export default function DataTable({
height,
animate = true,
virtualize = false,
showPercentage = true,
}) {
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
@ -38,6 +39,7 @@ export default function DataTable({
animate={animate && !virtualize}
format={formatFunc}
onClick={handleSetFormat}
showPercentage={showPercentage}
/>
);
};
@ -68,7 +70,15 @@ export default function DataTable({
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const AnimatedRow = ({
label,
value = 0,
percent,
animate,
format,
onClick,
showPercentage = true,
}) => {
const props = useSpring({
width: percent,
y: value,
@ -82,15 +92,17 @@ const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) =>
<div className={styles.value} onClick={onClick}>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
</div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
{showPercentage && (
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
)}
</div>
);
};

View File

@ -16,7 +16,7 @@ export default function EventsChart({ websiteId, className, token }) {
} = usePageQuery();
const { data, loading } = useFetch(
`/website/${websiteId}/events`,
`/websites/${websiteId}/events`,
{
params: {
start_at: +startDate,

View File

@ -5,12 +5,12 @@ import Button from 'components/common/Button';
import Times from 'assets/times.svg';
import styles from './FilterTags.module.css';
export default function FilterTags({ params, onClick }) {
export default function FilterTags({ className, params, onClick }) {
if (Object.keys(params).filter(key => params[key]).length === 0) {
return null;
}
return (
<div className={classNames(styles.filters, 'col-12')}>
<div className={classNames(styles.filters, 'col-12', className)}>
{Object.keys(params).map(key => {
if (!params[key]) {
return null;

View File

@ -7,8 +7,5 @@
.tag {
text-align: center;
margin-bottom: 10px;
}
.tag + .tag {
margin-left: 20px;
margin-right: 20px;
}

View File

@ -19,7 +19,7 @@ export default function MetricsBar({ websiteId, className }) {
} = usePageQuery();
const { data, error, loading } = useFetch(
`/website/${websiteId}/stats`,
`/websites/${websiteId}/stats`,
{
params: {
start_at: +startDate,

View File

@ -38,7 +38,7 @@ export default function MetricsTable({
const { formatMessage } = useIntl();
const { data, loading, error } = useFetch(
`/website/${websiteId}/metrics`,
`/websites/${websiteId}/metrics`,
{
params: {
type,

View File

@ -9,8 +9,8 @@ function mapData(data) {
const arr = [];
data.reduce((obj, val) => {
const { created_at } = val;
const t = startOfMinute(parseISO(created_at));
const { createdAt } = val;
const t = startOfMinute(parseISO(createdAt));
if (t.getTime() > last) {
obj = { t: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
arr.push(obj);

View File

@ -11,9 +11,9 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
const options = [
{ label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
].concat(
websites.map(({ name, website_id }, index) => ({
websites.map(({ name, id }, index) => ({
label: name,
value: website_id,
value: id,
divider: index === 0,
})),
);
@ -22,7 +22,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
const count = useMemo(() => {
return sessions.filter(
({ created_at }) => differenceInMinutes(new Date(), new Date(created_at)) <= 5,
({ createdAt }) => differenceInMinutes(new Date(), new Date(createdAt)) <= 5,
).length;
}, [sessions, websiteId]);

View File

@ -15,6 +15,7 @@ import Visitor from 'assets/visitor.svg';
import Eye from 'assets/eye.svg';
import { stringToColor } from 'lib/format';
import { dateFormat } from 'lib/date';
import { safeDecodeURI } from 'next-basics';
import styles from './RealtimeLog.module.css';
const TYPE_ALL = 0;
@ -36,7 +37,7 @@ export default function RealtimeLog({ data, websites, websiteId }) {
const logs = useMemo(() => {
const { pageviews, sessions, events } = data;
const logs = [...pageviews, ...sessions, ...events].sort(firstBy('created_at', -1));
const logs = [...pageviews, ...sessions, ...events].sort(firstBy('createdAt', -1));
if (filter) {
return logs.filter(row => getType(row) === filter);
}
@ -44,8 +45,8 @@ export default function RealtimeLog({ data, websites, websiteId }) {
}, [data, filter]);
const uuids = useMemo(() => {
return data.sessions.reduce((obj, { session_id, session_uuid }) => {
obj[session_id] = session_uuid;
return data.sessions.reduce((obj, { sessionId, sessionUuid }) => {
obj[sessionId] = sessionUuid;
return obj;
}, {});
}, [data]);
@ -69,14 +70,14 @@ export default function RealtimeLog({ data, websites, websiteId }) {
},
];
function getType({ view_id, session_id, event_id }) {
if (event_id) {
function getType({ pageviewId, sessionId, eventId }) {
if (eventId) {
return TYPE_EVENT;
}
if (view_id) {
if (pageviewId) {
return TYPE_PAGEVIEW;
}
if (session_id) {
if (sessionId) {
return TYPE_SESSION;
}
return null;
@ -86,26 +87,26 @@ export default function RealtimeLog({ data, websites, websiteId }) {
return TYPE_ICONS[getType(row)];
}
function getWebsite({ website_id }) {
return websites.find(n => n.website_id === website_id);
function getWebsite({ websiteId }) {
return websites.find(n => n.id === websiteId);
}
function getDetail({
event_name,
view_id,
session_id,
eventName,
pageviewId,
sessionId,
url,
browser,
os,
country,
device,
website_id,
websiteId,
}) {
if (event_name) {
return <div>{event_name}</div>;
if (eventName) {
return <div>{eventName}</div>;
}
if (view_id) {
const domain = getWebsite({ website_id })?.domain;
if (pageviewId) {
const domain = getWebsite({ websiteId })?.domain;
return (
<a
className={styles.link}
@ -113,11 +114,11 @@ export default function RealtimeLog({ data, websites, websiteId }) {
target="_blank"
rel="noreferrer noopener"
>
{url}
{safeDecodeURI(url)}
</a>
);
}
if (session_id) {
if (sessionId) {
return (
<FormattedMessage
id="message.log.visitor"
@ -133,14 +134,14 @@ export default function RealtimeLog({ data, websites, websiteId }) {
}
}
function getTime({ created_at }) {
return dateFormat(new Date(created_at), 'pp', locale);
function getTime({ createdAt }) {
return dateFormat(new Date(createdAt), 'pp', locale);
}
function getColor(row) {
const { session_id } = row;
const { sessionId } = row;
return stringToColor(uuids[session_id] || `${session_id}${getWebsite(row)}`);
return stringToColor(uuids[sessionId] || `${sessionId}${getWebsite(row)}`);
}
const Row = ({ index, style }) => {

View File

@ -16,7 +16,7 @@ export default function RealtimeViews({ websiteId, data, websites }) {
id =>
websites.length === 1
? websites[0]?.domain
: websites.find(({ website_id }) => website_id === id)?.domain,
: websites.find(({ websiteId }) => websiteId === id)?.domain,
[websites],
);
@ -65,10 +65,10 @@ export default function RealtimeViews({ websiteId, data, websites }) {
const pages = percentFilter(
pageviews
.reduce((arr, { url, website_id }) => {
.reduce((arr, { url, websiteId }) => {
if (url?.startsWith('/')) {
if (!websiteId && websites.length > 1) {
url = `${getDomain(website_id)}${url}`;
url = `${getDomain(websiteId)}${url}`;
}
const row = arr.find(({ x }) => x === url);

View File

@ -35,7 +35,7 @@ export default function WebsiteChart({
const { get } = useApi();
const { data, loading, error } = useFetch(
`/website/${websiteId}/pageviews`,
`/websites/${websiteId}/pageviews`,
{
params: {
start_at: +startDate,
@ -70,9 +70,9 @@ export default function WebsiteChart({
async function handleDateChange(value) {
if (value === 'all') {
const { data, ok } = await get(`/website/${websiteId}`);
const { data, ok } = await get(`/websites/${websiteId}`);
if (ok) {
setDateRange({ value, ...getDateRangeValues(new Date(data.created_at), Date.now()) });
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
}
} else {
setDateRange(value);

View File

@ -1,14 +1,13 @@
import React from 'react';
import Arrow from 'assets/arrow-right.svg';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import Favicon from 'components/common/Favicon';
import Link from 'components/common/Link';
import OverflowText from 'components/common/OverflowText';
import PageHeader from 'components/layout/PageHeader';
import RefreshButton from 'components/common/RefreshButton';
import ButtonLayout from 'components/layout/ButtonLayout';
import Favicon from 'components/common/Favicon';
import PageHeader from 'components/layout/PageHeader';
import { FormattedMessage } from 'react-intl';
import ActiveUsers from './ActiveUsers';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteHeader.module.css';
export default function WebsiteHeader({ websiteId, title, domain, showLink = false }) {
@ -17,8 +16,8 @@ export default function WebsiteHeader({ websiteId, title, domain, showLink = fal
<Favicon domain={domain} />
<Link
className={styles.titleLink}
href="/website/[...id]"
as={`/website/${websiteId}/${title}`}
href="/websites/[...id]"
as={`/websites/${websiteId}/${title}`}
>
<OverflowText tooltipId={`${websiteId}-title`}>{title}</OverflowText>
</Link>
@ -41,8 +40,8 @@ export default function WebsiteHeader({ websiteId, title, domain, showLink = fal
<RefreshButton websiteId={websiteId} />
{showLink && (
<Link
href="/website/[...id]"
as={`/website/${websiteId}/${title}`}
href="/websites/[...id]"
as={`/websites/${websiteId}/${title}`}
className={styles.link}
icon={<Arrow />}
size="small"

View File

@ -24,7 +24,7 @@ export default function DashboardEdit({ websites }) {
const ordered = useMemo(
() =>
websites
.map(website => ({ ...website, order: order.indexOf(website.website_id) }))
.map(website => ({ ...website, order: order.indexOf(website.websiteId) }))
.sort(firstBy('order')),
[websites, order],
);
@ -36,7 +36,7 @@ export default function DashboardEdit({ websites }) {
const [removed] = orderedWebsites.splice(source.index, 1);
orderedWebsites.splice(destination.index, 0, removed);
setOrder(orderedWebsites.map((website) => website?.website_id || 0));
setOrder(orderedWebsites.map(website => website?.websiteId || 0));
}
function handleSave() {
@ -76,8 +76,8 @@ export default function DashboardEdit({ websites }) {
ref={provided.innerRef}
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
>
{ordered.map(({ website_id, name, domain }, index) => (
<Draggable key={website_id} draggableId={`${dragId}-${website_id}`} index={index}>
{ordered.map(({ websiteId, name, domain }, index) => (
<Draggable key={websiteId} draggableId={`${dragId}-${websiteId}`} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}

View File

@ -21,11 +21,11 @@ function mergeData(state, data, time) {
const ids = state.map(({ __id }) => __id);
return state
.concat(data.filter(({ __id }) => !ids.includes(__id)))
.filter(({ created_at }) => new Date(created_at).getTime() >= time);
.filter(({ createdAt }) => new Date(createdAt).getTime() >= time);
}
function filterWebsite(data, id) {
return data.filter(({ website_id }) => website_id === id);
return data.filter(({ websiteId }) => websiteId === id);
}
export default function RealtimeDashboard() {

View File

@ -30,7 +30,7 @@ export default function Settings() {
{
label: <FormattedMessage id="label.accounts" defaultMessage="Accounts" />,
value: ACCOUNTS,
hidden: !user?.is_admin,
hidden: !user?.isAdmin,
},
{
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,

View File

@ -24,9 +24,9 @@ export default function TestConsole() {
return null;
}
const options = data.map(({ name, website_id }) => ({ label: name, value: website_id }));
const website = data.find(({ website_id }) => website_id === +websiteId);
const selectedValue = options.find(({ value }) => value === website?.website_id)?.value;
const options = data.map(({ name, websiteUuid }) => ({ label: name, value: websiteUuid }));
const website = data.find(({ websiteUuid }) => websiteId === websiteUuid);
const selectedValue = options.find(({ value }) => value === website?.websiteUuid)?.value;
function handleSelect(value) {
router.push(`/console/${value}`);
@ -46,7 +46,7 @@ export default function TestConsole() {
<script
async
defer
data-website-id={website.website_uuid}
data-website-id={website.websiteUuid}
src={`${basePath}/umami.js`}
data-cache="true"
/>
@ -104,13 +104,13 @@ export default function TestConsole() {
<div className="row">
<div className="col-12">
<WebsiteChart
websiteId={website.website_id}
websiteId={website.websiteUuid}
title={website.name}
domain={website.domain}
showLink
/>
<PageHeader>Events</PageHeader>
<EventsChart websiteId={website.website_id} />
<EventsChart websiteId={website.websiteUuid} />
</div>
</div>
</>

View File

@ -24,6 +24,7 @@ import useFetch from 'hooks/useFetch';
import usePageQuery from 'hooks/usePageQuery';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './WebsiteDetails.module.css';
import EventDataButton from 'components/common/EventDataButton';
const messages = defineMessages({
pages: { id: 'metrics.pages', defaultMessage: 'Pages' },
@ -52,7 +53,7 @@ const views = {
};
export default function WebsiteDetails({ websiteId }) {
const { data } = useFetch(`/website/${websiteId}`);
const { data } = useFetch(`/websites/${websiteId}`);
const [chartLoaded, setChartLoaded] = useState(false);
const [countryData, setCountryData] = useState();
const [eventsData, setEventsData] = useState();
@ -183,6 +184,7 @@ export default function WebsiteDetails({ websiteId }) {
<EventsTable {...tableProps} onDataLoad={setEventsData} />
</GridColumn>
<GridColumn xs={12} md={12} lg={8}>
<EventDataButton websiteId={websiteId} />
<EventsChart className={styles.eventschart} websiteId={websiteId} />
</GridColumn>
</GridRow>

View File

@ -27,7 +27,7 @@ export default function WebsiteList({ websites, showCharts, limit }) {
const ordered = useMemo(
() =>
websites
.map(website => ({ ...website, order: websiteOrder.indexOf(website.website_id) || 0 }))
.map(website => ({ ...website, order: websiteOrder.indexOf(website.websiteUuid) || 0 }))
.sort(firstBy('order')),
[websites, websiteOrder],
);
@ -46,11 +46,11 @@ export default function WebsiteList({ websites, showCharts, limit }) {
return (
<div>
{ordered.map(({ website_id, name, domain }, index) =>
{ordered.map(({ websiteUuid, name, domain }, index) =>
index < limit ? (
<div key={website_id} className={styles.website}>
<div key={websiteUuid} className={styles.website}>
<WebsiteChart
websiteId={website_id}
websiteId={websiteUuid}
title={name}
domain={domain}
showChart={showCharts}

View File

@ -27,10 +27,10 @@ export default function AccountSettings() {
const [message, setMessage] = useState();
const { data } = useFetch(`/accounts`, {}, [saved]);
const Checkmark = ({ is_admin }) => (is_admin ? <Icon icon={<Check />} size="medium" /> : null);
const Checkmark = ({ isAdmin }) => (isAdmin ? <Icon icon={<Check />} size="medium" /> : null);
const DashboardLink = row => (
<Link href={`/dashboard/${row.user_id}/${row.username}`}>
<Link href={`/dashboard/${row.userId}/${row.username}`}>
<a>
<Icon icon={<LinkIcon />} />
</a>
@ -42,7 +42,7 @@ export default function AccountSettings() {
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
<FormattedMessage id="label.edit" defaultMessage="Edit" />
</Button>
{!row.is_admin && (
{!row.isAdmin && (
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
<FormattedMessage id="label.delete" defaultMessage="Delete" />
</Button>
@ -57,7 +57,7 @@ export default function AccountSettings() {
className: 'col-12 col-lg-4',
},
{
key: 'is_admin',
key: 'isAdmin',
label: <FormattedMessage id="label.administrator" defaultMessage="Administrator" />,
className: 'col-12 col-lg-3',
render: Checkmark,
@ -121,7 +121,7 @@ export default function AccountSettings() {
title={<FormattedMessage id="label.delete-account" defaultMessage="Delete account" />}
>
<DeleteForm
values={{ type: 'account', id: deleteAccount.user_id, name: deleteAccount.username }}
values={{ type: 'accounts', id: deleteAccount.id, name: deleteAccount.username }}
onSave={handleSave}
onClose={handleClose}
/>

View File

@ -32,7 +32,7 @@ export default function ProfileSettings() {
return null;
}
const { user_id, username } = user;
const { userId, username } = user;
return (
<>
@ -79,7 +79,7 @@ export default function ProfileSettings() {
title={<FormattedMessage id="label.change-password" defaultMessage="Change password" />}
>
<ChangePasswordForm
values={{ user_id }}
values={{ userId }}
onSave={handleSave}
onClose={() => setChangePassword(false)}
/>

View File

@ -25,7 +25,11 @@ export default function UserButton() {
value: 'username',
className: styles.username,
},
{ label: <FormattedMessage id="label.profile" defaultMessage="Profile" />, value: 'profile' },
{
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
value: 'profile',
hidden: process.env.isCloudMode,
},
{ label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: 'logout' },
];

View File

@ -37,16 +37,16 @@ export default function WebsiteSettings() {
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch('/websites', { params: { include_all: !!user?.is_admin } }, [saved]);
const { data } = useFetch('/websites', { params: { include_all: !!user?.isAdmin } }, [saved]);
const Buttons = row => (
<ButtonLayout align="right">
{row.share_id && (
{row.shareId && (
<Button
icon={<LinkIcon />}
size="small"
tooltip={<FormattedMessage id="message.get-share-url" defaultMessage="Get share URL" />}
tooltipId={`button-share-${row.website_id}`}
tooltipId={`button-share-${row.websiteUuid}`}
onClick={() => setShowUrl(row)}
/>
)}
@ -56,46 +56,46 @@ export default function WebsiteSettings() {
tooltip={
<FormattedMessage id="message.get-tracking-code" defaultMessage="Get tracking code" />
}
tooltipId={`button-code-${row.website_id}`}
tooltipId={`button-code-${row.websiteUuid}`}
onClick={() => setShowCode(row)}
/>
<Button
icon={<Pen />}
size="small"
tooltip={<FormattedMessage id="label.edit" defaultMessage="Edit" />}
tooltipId={`button-edit-${row.website_id}`}
tooltipId={`button-edit-${row.websiteUuid}`}
onClick={() => setEditWebsite(row)}
/>
<Button
icon={<Reset />}
size="small"
tooltip={<FormattedMessage id="label.reset" defaultMessage="Reset" />}
tooltipId={`button-reset-${row.website_id}`}
tooltipId={`button-reset-${row.websiteUuid}`}
onClick={() => setResetWebsite(row)}
/>
<Button
icon={<Trash />}
size="small"
tooltip={<FormattedMessage id="label.delete" defaultMessage="Delete" />}
tooltipId={`button-delete-${row.website_id}`}
tooltipId={`button-delete-${row.websiteUuid}`}
onClick={() => setDeleteWebsite(row)}
/>
</ButtonLayout>
);
const DetailsLink = ({ website_id, name, domain }) => (
const DetailsLink = ({ websiteUuid, name, domain }) => (
<Link
className={styles.detailLink}
href="/website/[...id]"
as={`/website/${website_id}/${name}`}
href="/websites/[...id]"
as={`/websites/${websiteUuid}/${name}`}
>
<Favicon domain={domain} />
<OverflowText tooltipId={`${website_id}-name`}>{name}</OverflowText>
<OverflowText tooltipId={`${websiteUuid}-name`}>{name}</OverflowText>
</Link>
);
const Domain = ({ domain, website_id }) => (
<OverflowText tooltipId={`${website_id}-domain`}>{domain}</OverflowText>
const Domain = ({ domain, websiteUuid }) => (
<OverflowText tooltipId={`${websiteUuid}-domain`}>{domain}</OverflowText>
);
const adminColumns = [
@ -187,7 +187,7 @@ export default function WebsiteSettings() {
<FormattedMessage id="label.add-website" defaultMessage="Add website" />
</Button>
</PageHeader>
<Table columns={user.is_admin ? adminColumns : columns} rows={data} empty={empty} />
<Table columns={user.isAdmin ? adminColumns : columns} rows={data} empty={empty} />
{editWebsite && (
<Modal title={<FormattedMessage id="label.edit-website" defaultMessage="Edit website" />}>
<WebsiteEditForm values={editWebsite} onSave={handleSave} onClose={handleClose} />
@ -203,7 +203,7 @@ export default function WebsiteSettings() {
title={<FormattedMessage id="label.reset-website" defaultMessage="Reset statistics" />}
>
<ResetForm
values={{ type: 'website', id: resetWebsite.website_id, name: resetWebsite.name }}
values={{ type: 'websites', id: resetWebsite.websiteUuid, name: resetWebsite.name }}
onSave={handleSave}
onClose={handleClose}
/>
@ -214,7 +214,7 @@ export default function WebsiteSettings() {
title={<FormattedMessage id="label.delete-website" defaultMessage="Delete website" />}
>
<DeleteForm
values={{ type: 'website', id: deleteWebsite.website_id, name: deleteWebsite.name }}
values={{ type: 'websites', id: deleteWebsite.websiteUuid, name: deleteWebsite.name }}
onSave={handleSave}
onClose={handleClose}
/>

View File

@ -1,130 +0,0 @@
SET allow_experimental_object_type = 1;
-- Create Pageview
CREATE TABLE pageview
(
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE pageview_queue (
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'pageview',
kafka_group_name = 'pageview_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW pageview_queue_mv TO pageview AS
SELECT website_id,
session_uuid,
created_at,
url,
referrer
FROM pageview_queue;
-- Create Session
CREATE TABLE session
(
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE session_queue (
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'session',
kafka_group_name = 'session_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW session_queue_mv TO session AS
SELECT session_uuid,
website_id,
created_at,
hostname,
browser,
os,
device,
screen,
language,
country
FROM session_queue;
-- Create event
CREATE TABLE event
(
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data JSON
)
engine = MergeTree PRIMARY KEY (event_uuid, created_at)
ORDER BY (event_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE event_queue (
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'event',
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW event_queue_mv TO event AS
SELECT event_uuid,
website_id,
session_uuid,
created_at,
url,
event_name,
event_data
FROM event_queue;

70
db/clickhouse/schema.sql Normal file
View File

@ -0,0 +1,70 @@
SET allow_experimental_object_type = 1;
-- Create Event
CREATE TABLE event
(
website_id UUID,
session_id UUID,
event_id Nullable(UUID),
--session
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
--pageview
url String,
referrer String,
--event
event_name String,
event_data JSON,
created_at DateTime('UTC')
)
engine = MergeTree
ORDER BY (website_id, session_id, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE event_queue (
website_id UUID,
session_id UUID,
event_id Nullable(UUID),
url String,
referrer String,
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
event_name String,
event_data String,
created_at DateTime('UTC')
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
kafka_topic_list = 'event',
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW event_queue_mv TO event AS
SELECT website_id,
session_id,
event_id,
url,
referrer,
hostname,
browser,
os,
device,
screen,
language,
country,
event_name,
event_data,
created_at
FROM event_queue;

View File

@ -0,0 +1,35 @@
-- AlterTable
ALTER TABLE `account` ADD COLUMN `account_uuid` VARCHAR(36);
-- Backfill UUID
UPDATE `account` SET account_uuid=(SELECT uuid());
-- AlterTable
ALTER TABLE `account` MODIFY `account_uuid` VARCHAR(36) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX `account_account_uuid_key` ON `account`(`account_uuid`);
-- AlterTable
ALTER TABLE `event` ADD COLUMN `event_uuid` VARCHAR(36);
-- Backfill UUID
UPDATE `event` SET event_uuid=(SELECT uuid());
-- AlterTable
ALTER TABLE `event` MODIFY `event_uuid` VARCHAR(36) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX `event_event_uuid_key` ON `event`(`event_uuid`);
-- CreateIndex
CREATE INDEX `account_account_uuid_idx` ON `account`(`account_uuid`);
-- CreateIndex
CREATE INDEX `session_session_uuid_idx` ON `session`(`session_uuid`);
-- CreateIndex
CREATE INDEX `website_website_uuid_idx` ON `website`(`website_uuid`);
-- CreateIndex
CREATE INDEX `event_event_uuid_idx` ON `event`(`event_uuid`);

View File

@ -8,87 +8,96 @@ datasource db {
}
model account {
user_id Int @id @default(autoincrement()) @db.UnsignedInt
username String @unique() @db.VarChar(255)
password String @db.VarChar(60)
is_admin Boolean @default(false)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
website website[]
id Int @id @default(autoincrement()) @map("user_id") @db.UnsignedInt
username String @unique() @db.VarChar(255)
password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamp(0)
accountUuid String @unique() @map("account_uuid") @db.VarChar(36)
website website[]
@@index([accountUuid])
}
model event {
event_id Int @id @default(autoincrement()) @db.UnsignedInt
website_id Int @db.UnsignedInt
session_id Int @db.UnsignedInt
created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500)
event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
event_data event_data?
id Int @id @default(autoincrement()) @map("event_id") @db.UnsignedInt
websiteId Int @map("website_id") @db.UnsignedInt
sessionId Int @map("session_id") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
url String @db.VarChar(500)
eventName String @map("event_name") @db.VarChar(50)
eventUuid String @unique() @map("event_uuid") @db.VarChar(36)
session session @relation(fields: [sessionId], references: [id])
website website @relation(fields: [websiteId], references: [id])
eventData eventData?
@@index([created_at])
@@index([session_id])
@@index([website_id])
@@index([createdAt])
@@index([sessionId])
@@index([websiteId])
@@index([eventUuid])
}
model event_data {
event_data_id Int @id @default(autoincrement()) @db.UnsignedInt
event_id Int @unique @db.UnsignedInt
event_data Json
event event @relation(fields: [event_id], references: [event_id])
model eventData {
id Int @id @default(autoincrement()) @map("event_data_id") @db.UnsignedInt
eventId Int @unique @map("event_id") @db.UnsignedInt
eventData Json @map("event_data")
event event @relation(fields: [eventId], references: [id])
@@map("event_data")
}
model pageview {
view_id Int @id @default(autoincrement()) @db.UnsignedInt
website_id Int @db.UnsignedInt
session_id Int @db.UnsignedInt
created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
id Int @id @default(autoincrement()) @map("view_id") @db.UnsignedInt
websiteId Int @map("website_id") @db.UnsignedInt
sessionId Int @map("session_id") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [sessionId], references: [id])
website website @relation(fields: [websiteId], references: [id])
@@index([created_at])
@@index([session_id])
@@index([website_id, created_at])
@@index([website_id])
@@index([website_id, session_id, created_at])
@@index([createdAt])
@@index([sessionId])
@@index([websiteId, createdAt])
@@index([websiteId])
@@index([websiteId, sessionId, createdAt])
}
model session {
session_id Int @id @default(autoincrement()) @db.UnsignedInt
session_uuid String @unique() @db.VarChar(36)
website_id Int @db.UnsignedInt
created_at DateTime? @default(now()) @db.Timestamp(0)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id])
event event[]
pageview pageview[]
id Int @id @default(autoincrement()) @map("session_id") @db.UnsignedInt
sessionUuid String @unique() @map("session_uuid") @db.VarChar(36)
websiteId Int @map("website_id") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website @relation(fields: [websiteId], references: [id])
event event[]
pageview pageview[]
@@index([created_at])
@@index([website_id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionUuid])
}
model website {
website_id Int @id @default(autoincrement()) @db.UnsignedInt
website_uuid String @unique() @db.VarChar(36)
user_id Int @db.UnsignedInt
name String @db.VarChar(100)
domain String? @db.VarChar(500)
share_id String? @unique() @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamp(0)
account account @relation(fields: [user_id], references: [user_id])
event event[]
pageview pageview[]
session session[]
id Int @id @default(autoincrement()) @map("website_id") @db.UnsignedInt
websiteUuid String @unique() @map("website_uuid") @db.VarChar(36)
userId Int @map("user_id") @db.UnsignedInt
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique() @map("share_id") @db.VarChar(64)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
account account @relation(fields: [userId], references: [id])
event event[]
pageview pageview[]
session session[]
@@index([user_id])
@@index([userId])
@@index([websiteUuid])
}

View File

@ -0,0 +1,36 @@
-- AlterTable
ALTER TABLE "account" ADD COLUMN "account_uuid" UUID NULL;
-- Backfill UUID
UPDATE "account" SET account_uuid = gen_random_uuid();
-- AlterTable
ALTER TABLE "account" ALTER COLUMN "account_uuid" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "account_account_uuid_key" ON "account"("account_uuid");
-- AlterTable
ALTER TABLE "event" ADD COLUMN "event_uuid" UUID NULL;
-- Backfill UUID
UPDATE "event" SET event_uuid = gen_random_uuid();
-- AlterTable
ALTER TABLE "event" ALTER COLUMN "event_uuid" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "event_event_uuid_key" ON "event"("event_uuid");
-- CreateIndex
CREATE INDEX "account_account_uuid_idx" ON "account"("account_uuid");
-- CreateIndex
CREATE INDEX "session_session_uuid_idx" ON "session"("session_uuid");
-- CreateIndex
CREATE INDEX "website_website_uuid_idx" ON "website"("website_uuid");
-- CreateIndex
CREATE INDEX "event_event_uuid_idx" ON "event"("event_uuid");

View File

@ -8,87 +8,96 @@ datasource db {
}
model account {
user_id Int @id @default(autoincrement())
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
is_admin Boolean @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
website website[]
id Int @id @default(autoincrement()) @map("user_id")
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6)
accountUuid String @unique @map("account_uuid") @db.Uuid
website website[]
@@index([accountUuid])
}
model event {
event_id Int @id @default(autoincrement())
website_id Int
session_id Int
created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500)
event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
event_data event_data?
id Int @id() @default(autoincrement()) @map("event_id")
websiteId Int @map("website_id")
sessionId Int @map("session_id")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
url String @db.VarChar(500)
eventName String @map("event_name") @db.VarChar(50)
eventUuid String @unique @map("event_uuid") @db.Uuid
session session @relation(fields: [sessionId], references: [id])
website website @relation(fields: [websiteId], references: [id])
eventData eventData?
@@index([created_at])
@@index([session_id])
@@index([website_id])
@@index([createdAt])
@@index([sessionId])
@@index([websiteId])
@@index([eventUuid])
}
model event_data {
event_data_id Int @id @default(autoincrement())
event_id Int @unique
event_data Json
event event @relation(fields: [event_id], references: [event_id])
model eventData {
id Int @id @default(autoincrement()) @map("event_data_id")
eventId Int @unique @map("event_id")
eventData Json @map("event_data")
event event @relation(fields: [eventId], references: [id])
@@map("event_data")
}
model pageview {
view_id Int @id @default(autoincrement())
website_id Int
session_id Int
created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
id Int @id @default(autoincrement()) @map("view_id")
websiteId Int @map("website_id")
sessionId Int @map("session_id")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [sessionId], references: [id])
website website @relation(fields: [websiteId], references: [id])
@@index([created_at])
@@index([session_id])
@@index([website_id, created_at])
@@index([website_id])
@@index([website_id, session_id, created_at])
@@index([createdAt])
@@index([sessionId])
@@index([websiteId, createdAt])
@@index([websiteId])
@@index([websiteId, sessionId, createdAt])
}
model session {
session_id Int @id @default(autoincrement())
session_uuid String @unique @db.Uuid
website_id Int
created_at DateTime? @default(now()) @db.Timestamptz(6)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id])
event event[]
pageview pageview[]
id Int @id @default(autoincrement()) @map("session_id")
sessionUuid String @unique @map("session_uuid") @db.Uuid
websiteId Int @map("website_id")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website? @relation(fields: [websiteId], references: [id])
events event[]
pageview pageview[]
@@index([created_at])
@@index([website_id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionUuid])
}
model website {
website_id Int @id @default(autoincrement())
website_uuid String @unique @db.Uuid
user_id Int
name String @db.VarChar(100)
domain String? @db.VarChar(500)
share_id String? @unique @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamptz(6)
account account @relation(fields: [user_id], references: [user_id])
event event[]
pageview pageview[]
session session[]
id Int @id @default(autoincrement()) @map("website_id")
websiteUuid String @unique @map("website_uuid") @db.Uuid
userId Int @map("user_id")
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique @map("share_id") @db.VarChar(64)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
account account @relation(fields: [userId], references: [id])
event event[]
pageview pageview[]
session session[]
@@index([user_id])
@@index([userId])
@@index([websiteUuid])
}

View File

@ -1,6 +1,8 @@
{
"label.accounts": "Accounts",
"label.add-account": "Add account",
"label.add-column": "Add column",
"label.add-filter": "Add filter",
"label.add-website": "Add website",
"label.administrator": "Administrator",
"label.all": "All",
@ -25,6 +27,8 @@
"label.edit-account": "Edit account",
"label.edit-website": "Edit website",
"label.enable-share-url": "Enable share URL",
"label.event-data": "Event Data",
"label.field-name": "Field Name",
"label.invalid": "Invalid",
"label.invalid-domain": "Invalid domain",
"label.language": "Language",
@ -48,6 +52,7 @@
"label.reset": "Reset",
"label.reset-website": "Reset statistics",
"label.save": "Save",
"label.search": "Search",
"label.settings": "Settings",
"label.share-url": "Share URL",
"label.single-day": "Single day",
@ -58,8 +63,10 @@
"label.timezone": "Timezone",
"label.today": "Today",
"label.tracking-code": "Tracking code",
"label.type": "Type",
"label.unknown": "Unknown",
"label.username": "Username",
"label.value": "Value",
"label.view-details": "View details",
"label.websites": "Websites",
"label.yesterday": "Yesterday",

View File

@ -62,6 +62,7 @@
"label.username": "用户名",
"label.view-details": "查看更多",
"label.websites": "网站",
"label.yesterday": "昨天",
"message.active-users": "当前在线 {x} 人",
"message.confirm-delete": "你确定要删除 {target} 吗?",
"message.confirm-reset": "您确定要重置 {target} 的数据吗?",

View File

@ -1,9 +1,9 @@
import { parseSecureToken, parseToken, getItem } from 'next-basics';
import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from './constants';
import { getWebsiteById } from 'queries';
import { secret } from './crypto';
import { parseSecureToken, parseToken } from 'next-basics';
import { getWebsite } from 'queries';
import { SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
export async function getAuthToken(req) {
export function getAuthToken(req) {
try {
const token = req.headers.authorization;
@ -13,13 +13,15 @@ export async function getAuthToken(req) {
}
}
export function getAuthHeader() {
const token = getItem(AUTH_TOKEN);
return token ? { authorization: `Bearer ${token}` } : {};
export function getShareToken(req) {
try {
return parseSecureToken(req.headers[SHARE_TOKEN_HEADER], secret());
} catch {
return null;
}
}
export async function isValidToken(token, validation) {
export function isValidToken(token, validation) {
try {
const result = parseToken(token, secret());
@ -35,25 +37,23 @@ export async function isValidToken(token, validation) {
return false;
}
export async function allowQuery(req, skipToken) {
const { id } = req.query;
const token = req.headers[SHARE_TOKEN_HEADER];
const websiteId = +id;
export async function allowQuery(req) {
const { id: websiteId } = req.query;
const website = await getWebsiteById(websiteId);
const { userId, isAdmin, shareToken } = req.auth ?? {};
if (website) {
if (token && token !== 'undefined' && !skipToken) {
return isValidToken(token, { website_id: websiteId });
}
if (isAdmin) {
return true;
}
const authToken = await getAuthToken(req);
if (shareToken) {
return isValidToken(shareToken, { websiteUuid: websiteId });
}
if (authToken) {
const { user_id, is_admin } = authToken;
if (userId) {
const website = await getWebsite({ websiteUuid: websiteId });
return is_admin || website.user_id === user_id;
}
return website && website.userId === userId;
}
return false;

View File

@ -14,6 +14,9 @@ export const CLICKHOUSE_DATE_FORMATS = {
const log = debug('umami:clickhouse');
let clickhouse;
const enabled = Boolean(process.env.CLICKHOUSE_URL);
function getClient() {
const {
hostname,
@ -57,12 +60,53 @@ function getDateFormat(date) {
return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`;
}
function getBetweenDates(field, start_at, end_at) {
return `${field} between ${getDateFormat(start_at)}
and ${getDateFormat(end_at)}`;
function getCommaSeparatedStringFormat(data) {
return data.map(a => `'${a}'`).join(',') || '';
}
function getFilterQuery(table, column, filters = {}, params = []) {
function getBetweenDates(field, start_at, end_at) {
return `${field} between ${getDateFormat(start_at)} and ${getDateFormat(end_at)}`;
}
function getJsonField(column, property) {
return `${column}.${property}`;
}
function getEventDataColumnsQuery(column, columns) {
const query = Object.keys(columns).reduce((arr, key) => {
const filter = columns[key];
if (filter === undefined) {
return arr;
}
arr.push(`${filter}(${getJsonField(column, key)}) as "${filter}(${key})"`);
return arr;
}, []);
return query.join(',\n');
}
function getEventDataFilterQuery(column, filters) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined) {
return arr;
}
arr.push(
`${getJsonField(column, key)} = ${typeof filter === 'string' ? `'${filter}'` : filter}`,
);
return arr;
}, []);
return query.join('\nand ');
}
function getFilterQuery(column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
@ -72,48 +116,28 @@ function getFilterQuery(table, column, filters = {}, params = []) {
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
arr.push(`and ${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
arr.push(`and referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
arr.push(`and referrer not like $${params.length + 1}`);
arr.push(`and referrer not like '/%'`);
params.push(`%://${filter}/%`);
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
arr.push(`and url like '%?%'`);
}
return arr;
@ -122,7 +146,7 @@ function getFilterQuery(table, column, filters = {}, params = []) {
return query.join('\n');
}
function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
function parseFilters(column, filters = {}, params = []) {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
@ -135,13 +159,9 @@ function parseFilters(table, column, filters = {}, params = [], sessionKey = 'se
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
pageviewQuery: getFilterQuery(column, pageviewFilters, params),
sessionQuery: getFilterQuery(column, sessionFilters, params),
eventQuery: getFilterQuery(column, eventFilters, params),
};
}
@ -168,6 +188,8 @@ async function rawQuery(query, params = []) {
log(formattedQuery);
}
await connect();
return clickhouse.query(formattedQuery).toPromise();
}
@ -183,16 +205,26 @@ async function findFirst(data) {
return data[0] ?? null;
}
// Initialization
const clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
async function connect() {
if (!clickhouse) {
clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
}
return clickhouse;
}
export default {
enabled,
client: clickhouse,
log,
connect,
getDateStringQuery,
getDateQuery,
getDateFormat,
getCommaSeparatedStringFormat,
getBetweenDates,
getEventDataColumnsQuery,
getEventDataFilterQuery,
getFilterQuery,
parseFilters,
findUnique,

View File

@ -12,7 +12,7 @@ BigInt.prototype.toJSON = function () {
};
export function getDatabaseType(url = process.env.DATABASE_URL) {
const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]);
const type = url && url.split(':')[0];
if (type === 'postgres') {
return POSTGRESQL;

View File

@ -74,7 +74,7 @@ export function stringToColor(str) {
let color = '#';
for (let i = 0; i < 3; i++) {
let value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substring(-2);
color += ('00' + value.toString(16)).slice(-2);
}
return color;
}

View File

@ -5,6 +5,10 @@ import { KAFKA, KAFKA_PRODUCER } from 'lib/db';
const log = debug('umami:kafka');
let kafka;
let producer;
const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER);
function getClient() {
const { username, password } = new URL(process.env.KAFKA_URL);
const brokers = process.env.KAFKA_BROKER.split(',');
@ -12,7 +16,12 @@ function getClient() {
const ssl =
username && password
? {
ssl: true,
ssl: {
checkServerIdentity: () => undefined,
ca: [process.env.CA_CERT],
key: process.env.CLIENT_KEY,
cert: process.env.CLIENT_CERT,
},
sasl: {
mechanism: 'plain',
username,
@ -33,6 +42,8 @@ function getClient() {
global[KAFKA] = client;
}
log('Kafka initialized');
return client;
}
@ -44,6 +55,8 @@ async function getProducer() {
global[KAFKA_PRODUCER] = producer;
}
log('Kafka producer initialized');
return producer;
}
@ -52,6 +65,8 @@ function getDateFormat(date) {
}
async function sendMessage(params, topic) {
await connect();
await producer.send({
topic,
messages: [
@ -59,26 +74,28 @@ async function sendMessage(params, topic) {
value: JSON.stringify(params),
},
],
acks: 0,
acks: 1,
});
}
// Initialization
let kafka;
let producer;
async function connect() {
if (!kafka) {
kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (global[KAFKA] || getClient());
(async () => {
kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (global[KAFKA] || getClient());
if (kafka) {
producer = global[KAFKA_PRODUCER] || (await getProducer());
if (kafka) {
producer = global[KAFKA_PRODUCER] || (await getProducer());
}
}
})();
return kafka;
}
export default {
enabled,
client: kafka,
producer: producer,
producer,
log,
connect,
getDateFormat,
sendMessage,
};

View File

@ -1,7 +1,7 @@
import { createMiddleware, unauthorized, badRequest, serverError } from 'next-basics';
import cors from 'cors';
import { getSession } from './session';
import { getAuthToken } from './auth';
import { getAuthToken, getShareToken } from './auth';
export const useCors = createMiddleware(cors());
@ -27,11 +27,12 @@ export const useSession = createMiddleware(async (req, res, next) => {
export const useAuth = createMiddleware(async (req, res, next) => {
const token = await getAuthToken(req);
const shareToken = await getShareToken(req);
if (!token) {
return unauthorized(res);
}
req.auth = token;
req.auth = { ...token, shareToken };
next();
});

View File

@ -85,6 +85,64 @@ function getTimestampInterval(field) {
}
}
function getJsonField(column, property, isNumber) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
let accessor = `${column} ->> '${property}'`;
if (isNumber) {
accessor = `CAST(${accessor} AS DECIMAL)`;
}
return accessor;
}
if (db === MYSQL) {
return `${column} ->> "$.${property}"`;
}
}
function getEventDataColumnsQuery(column, columns) {
const query = Object.keys(columns).reduce((arr, key) => {
const filter = columns[key];
if (filter === undefined) {
return arr;
}
const isNumber = ['sum', 'avg', 'min', 'max'].some(a => a === filter);
arr.push(`${filter}(${getJsonField(column, key, isNumber)}) as "${filter}(${key})"`);
return arr;
}, []);
return query.join(',\n');
}
function getEventDataFilterQuery(column, filters) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined) {
return arr;
}
const isNumber = filter && typeof filter === 'number';
arr.push(
`${getJsonField(column, key, isNumber)} = ${
typeof filter === 'string' ? `'${filter}'` : filter
}`,
);
return arr;
}, []);
return query.join('\nand ');
}
function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
@ -193,6 +251,8 @@ export default {
getDateQuery,
getTimestampInterval,
getFilterQuery,
getEventDataColumnsQuery,
getEventDataFilterQuery,
parseFilters,
rawQuery,
transaction,

View File

@ -8,12 +8,20 @@ const log = debug('umami:redis');
const INITIALIZED = 'redis:initialized';
export const DELETED = 'deleted';
let redis;
const enabled = Boolean(process.env.REDIS_URL);
function getClient() {
if (!process.env.REDIS_URL) {
return null;
}
const redis = new Redis(process.env.REDIS_URL);
const redis = new Redis(process.env.REDIS_URL, {
retryStrategy(times) {
log(`Redis reconnecting attempt: ${times}`);
return 5000;
},
});
if (process.env.NODE_ENV !== 'production') {
global[REDIS] = redis;
@ -29,32 +37,43 @@ async function stageData() {
const websites = await getAllWebsites();
const sessionUuids = sessions.map(a => {
return { key: `session:${a.session_uuid}`, value: 1 };
return { key: `session:${a.sessionUuid}`, value: 1 };
});
const websiteIds = websites.map(a => {
return { key: `website:${a.website_uuid}`, value: Number(a.website_id) };
return { key: `website:${a.websiteUuid}`, value: Number(a.websiteId) };
});
await addRedis(sessionUuids);
await addRedis(websiteIds);
await addSet(sessionUuids);
await addSet(websiteIds);
await redis.set(INITIALIZED, 1);
}
async function addRedis(ids) {
async function addSet(ids) {
for (let i = 0; i < ids.length; i++) {
const { key, value } = ids[i];
await redis.set(key, value);
}
}
// Initialization
const redis = process.env.REDIS_URL && (global[REDIS] || getClient());
async function get(key) {
await connect();
(async () => {
if (redis && !(await redis.get(INITIALIZED))) {
await stageData();
return redis.get(key);
}
async function set(key, value) {
await connect();
return redis.set(key, value);
}
async function connect() {
if (!redis) {
redis = process.env.REDIS_URL && (global[REDIS] || getClient());
}
})();
export default { client: redis, stageData, log };
return redis;
}
export default { enabled, client: redis, log, connect, get, set, stageData };

View File

@ -1,7 +1,8 @@
import { parseToken } from 'next-basics';
import { validate } from 'uuid';
import { uuid } from 'lib/crypto';
import { secret, uuid } from 'lib/crypto';
import redis, { DELETED } from 'lib/redis';
import clickhouse from 'lib/clickhouse';
import { getClientInfo, getJsonBody } from 'lib/request';
import { createSession, getSessionByUuid, getWebsiteByUuid } from 'queries';
@ -15,80 +16,91 @@ export async function getSession(req) {
const cache = req.headers['x-umami-cache'];
if (cache) {
const result = await parseToken(cache);
const result = await parseToken(cache, secret());
if (result) {
return result;
}
}
const { website: website_uuid, hostname, screen, language } = payload;
const { website: websiteUuid, hostname, screen, language } = payload;
if (!validate(website_uuid)) {
if (!validate(websiteUuid)) {
return null;
}
let websiteId = null;
// Check if website exists
if (redis.client) {
websiteId = await redis.client.get(`website:${website_uuid}`);
if (redis.enabled) {
websiteId = Number(await redis.get(`website:${websiteUuid}`));
}
// Check database if redis does not have
// Check database if does not exists in Redis
if (!websiteId) {
const website = await getWebsiteByUuid(website_uuid);
websiteId = website ? website.website_id : null;
const website = await getWebsiteByUuid(websiteUuid);
websiteId = website ? website.id : null;
}
if (!websiteId || websiteId === DELETED) {
throw new Error(`Website not found: ${website_uuid}`);
throw new Error(`Website not found: ${websiteUuid}`);
}
const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload);
const sessionUuid = uuid(websiteUuid, hostname, ip, userAgent);
const session_uuid = uuid(websiteId, hostname, ip, userAgent);
let sessionCreated = false;
let sessionId = null;
let session = null;
// Check if session exists
if (redis.client) {
sessionCreated = !!(await redis.client.get(`session:${session_uuid}`));
}
if (!clickhouse.enabled) {
// Check if session exists
if (redis.enabled) {
sessionId = Number(await redis.get(`session:${sessionUuid}`));
}
// Check database if redis does not have
if (!sessionCreated) {
session = await getSessionByUuid(session_uuid);
sessionCreated = !!session;
sessionId = session ? session.session_id : null;
}
// Check database if does not exists in Redis
if (!sessionId) {
session = await getSessionByUuid(sessionUuid);
sessionId = session ? session.id : null;
}
if (!sessionCreated) {
try {
session = await createSession(websiteId, {
session_uuid,
hostname,
browser,
os,
screen,
language,
country,
device,
});
sessionId = session ? session.session_id : null;
} catch (e) {
if (!e.message.toLowerCase().includes('unique constraint')) {
throw e;
if (!sessionId) {
try {
session = await createSession(websiteId, {
sessionUuid,
hostname,
browser,
os,
screen,
language,
country,
device,
});
} catch (e) {
if (!e.message.toLowerCase().includes('unique constraint')) {
throw e;
}
}
}
} else {
session = {
sessionId,
sessionUuid,
hostname,
browser,
os,
screen,
language,
country,
device,
};
}
return {
website_id: websiteId,
session_id: sessionId,
session_uuid,
website: {
websiteId,
websiteUuid,
},
session,
};
}

View File

@ -36,6 +36,7 @@ module.exports = {
env: {
currentVersion: pkg.version,
isProduction: process.env.NODE_ENV === 'production',
isCloudMode: process.env.CLOUD_MODE,
},
basePath: process.env.BASE_PATH,
output: 'standalone',

View File

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "1.38.0",
"version": "1.39.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",
@ -56,7 +56,7 @@
},
"dependencies": {
"@fontsource/inter": "4.5.7",
"@prisma/client": "4.3.1",
"@prisma/client": "4.5.0",
"chalk": "^4.1.1",
"chart.js": "^2.9.4",
"classnames": "^2.3.1",
@ -83,8 +83,8 @@
"kafkajs": "^2.1.0",
"maxmind": "^4.3.6",
"moment-timezone": "^0.5.35",
"next": "^12.2.5",
"next-basics": "^0.7.0",
"next": "^12.3.1",
"next-basics": "^0.18.0",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"prop-types": "^15.7.2",
@ -125,7 +125,7 @@
"postcss-preset-env": "7.4.3",
"postcss-rtlcss": "^3.6.1",
"prettier": "^2.6.2",
"prisma": "4.3.1",
"prisma": "4.5.0",
"prompts": "2.4.2",
"rollup": "^2.70.1",
"rollup-plugin-terser": "^7.0.2",

View File

@ -1,29 +0,0 @@
import { getAccountById, deleteAccount } from 'queries';
import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
const { id } = req.query;
const user_id = +id;
if (!is_admin) {
return unauthorized(res);
}
if (req.method === 'GET') {
const account = await getAccountById(user_id);
return ok(res, account);
}
if (req.method === 'DELETE') {
await deleteAccount(user_id);
return ok(res);
}
return methodNotAllowed(res);
};

View File

@ -1,57 +0,0 @@
import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics';
import { getAccountById, getAccountByUsername, updateAccount, createAccount } from 'queries';
import { useAuth } from 'lib/middleware';
export default async (req, res) => {
await useAuth(req, res);
const { user_id: current_user_id, is_admin: current_user_is_admin } = req.auth;
if (req.method === 'POST') {
const { user_id, username, password, is_admin } = req.body;
if (user_id) {
const account = await getAccountById(user_id);
if (account.user_id === current_user_id || current_user_is_admin) {
const data = {};
if (password) {
data.password = hashPassword(password);
}
// Only admin can change these fields
if (current_user_is_admin) {
data.username = username;
data.is_admin = is_admin;
}
if (data.username && account.username !== data.username) {
const accountByUsername = await getAccountByUsername(username);
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
}
const updated = await updateAccount(user_id, data);
return ok(res, updated);
}
return unauthorized(res);
} else {
const accountByUsername = await getAccountByUsername(username);
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
const created = await createAccount({ username, password: hashPassword(password) });
return ok(res, created);
}
}
return methodNotAllowed(res);
};

View File

@ -0,0 +1,66 @@
import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getAccount, deleteAccount, updateAccount } from 'queries';
import { useAuth } from 'lib/middleware';
export default async (req, res) => {
await useAuth(req, res);
const { isAdmin, userId } = req.auth;
const { id } = req.query;
if (req.method === 'GET') {
if (id !== userId && !isAdmin) {
return unauthorized(res);
}
const account = await getAccount({ id: +id });
return ok(res, account);
}
if (req.method === 'POST') {
const { username, password } = req.body;
if (id !== userId && !isAdmin) {
return unauthorized(res);
}
const account = await getAccount({ id: +id });
const data = {};
if (password) {
data.password = hashPassword(password);
}
// Only admin can change these fields
if (isAdmin) {
data.username = username;
}
// Check when username changes
if (data.username && account.username !== data.username) {
const accountByUsername = await getAccount({ username });
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
}
const updated = await updateAccount(data, { id: +id });
return ok(res, updated);
}
if (req.method === 'DELETE') {
if (!isAdmin) {
return unauthorized(res);
}
await deleteAccount(userId);
return ok(res);
}
return methodNotAllowed(res);
};

View File

@ -12,24 +12,25 @@ import {
export default async (req, res) => {
await useAuth(req, res);
const { user_id: auth_user_id, is_admin } = req.auth;
const { user_id, current_password, new_password } = req.body;
const { userId: currentUserId, isAdmin: currentUserIsAdmin } = req.auth;
const { current_password, new_password } = req.body;
const { id } = req.query;
const userId = +id;
if (!is_admin && user_id !== auth_user_id) {
if (!currentUserIsAdmin && userId !== currentUserId) {
return unauthorized(res);
}
if (req.method === 'POST') {
const account = await getAccountById(user_id);
const valid = checkPassword(current_password, account.password);
const account = await getAccountById(userId);
if (!valid) {
if (!checkPassword(current_password, account.password)) {
return badRequest(res, 'Current password is incorrect');
}
const password = hashPassword(new_password);
const updated = await updateAccount(user_id, { password });
const updated = await updateAccount(userId, { password });
return ok(res, updated);
}

View File

@ -1,13 +1,14 @@
import { getAccounts } from 'queries';
import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics';
import { useAuth } from 'lib/middleware';
import { ok, unauthorized, methodNotAllowed } from 'next-basics';
import { uuid } from 'lib/crypto';
import { createAccount, getAccountByUsername, getAccounts } from 'queries';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
const { isAdmin } = req.auth;
if (!is_admin) {
if (!isAdmin) {
return unauthorized(res);
}
@ -17,5 +18,23 @@ export default async (req, res) => {
return ok(res, accounts);
}
if (req.method === 'POST') {
const { username, password, account_uuid } = req.body;
const accountByUsername = await getAccountByUsername(username);
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
const created = await createAccount({
username,
password: hashPassword(password),
accountUuid: account_uuid || uuid(),
});
return ok(res, created);
}
return methodNotAllowed(res);
};

View File

@ -12,8 +12,8 @@ export default async (req, res) => {
const account = await getAccountByUsername(username);
if (account && checkPassword(password, account.password)) {
const { user_id, username, is_admin } = account;
const user = { user_id, username, is_admin };
const { id, username, isAdmin, accountUuid } = account;
const user = { userId: id, username, isAdmin, accountUuid };
const token = createSecureToken(user, secret());
return ok(res, { token, user });

View File

@ -58,36 +58,39 @@ export default async (req, res) => {
await useSession(req, res);
const {
session: { website_id, session_id, session_uuid },
} = req;
const { website, session } = req.session;
const { type, payload } = getJsonBody(req);
let { url, referrer, event_name, event_data } = payload;
let { url, referrer, event_name: eventName, event_data: eventData } = payload;
if (process.env.REMOVE_TRAILING_SLASH) {
url = url.replace(/\/$/, '');
}
const event_uuid = uuid();
const eventUuid = uuid();
if (type === 'pageview') {
await savePageView(website_id, { session_id, session_uuid, url, referrer });
await savePageView(website, { session, url, referrer });
} else if (type === 'event') {
await saveEvent(website_id, {
event_uuid,
session_id,
session_uuid,
await saveEvent(website, {
session,
eventUuid,
url,
event_name,
event_data,
eventName,
eventData,
});
} else {
return badRequest(res);
}
const token = createToken({ website_id, session_id, session_uuid }, secret());
const token = createToken(
{
website,
session,
},
secret(),
);
return send(res, token);
};

View File

@ -8,10 +8,10 @@ export default async (req, res) => {
await useAuth(req, res);
if (req.method === 'GET') {
const { user_id } = req.auth;
const { userId } = req.auth;
const websites = await getUserWebsites(user_id);
const ids = websites.map(({ website_id }) => website_id);
const websites = await getUserWebsites(userId);
const ids = websites.map(({ websiteUuid }) => websiteUuid);
const token = createToken({ websites: ids }, secret());
const data = await getRealtimeData(ids, subMinutes(new Date(), 30));

View File

@ -9,10 +9,10 @@ export default async (req, res) => {
const website = await getWebsiteByShareId(id);
if (website) {
const websiteId = website.website_id;
const token = createToken({ website_id: websiteId }, secret());
const { websiteId, websiteUuid } = website;
const token = createToken({ websiteId, websiteUuid }, secret());
return ok(res, { websiteId, token });
return ok(res, { websiteId, websiteUuid, token });
}
return notFound(res);

View File

@ -1,34 +0,0 @@
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { deleteWebsite, getWebsiteById } from 'queries';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
export default async (req, res) => {
const { id } = req.query;
const websiteId = +id;
if (req.method === 'GET') {
await useCors(req, res);
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const website = await getWebsiteById(websiteId);
return ok(res, website);
}
if (req.method === 'DELETE') {
if (!(await allowQuery(req, true))) {
return unauthorized(res);
}
await deleteWebsite(websiteId);
return ok(res);
}
return methodNotAllowed(res);
};

View File

@ -1,50 +0,0 @@
import moment from 'moment-timezone';
import { getPageviewStats } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => {
if (req.method === 'GET') {
await useCors(req, res);
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, unit, tz, url, referrer, os, browser, device, country } =
req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, startDate, endDate, tz, unit, '*', {
url,
referrer,
os,
browser,
device,
country,
}),
getPageviewStats(websiteId, startDate, endDate, tz, unit, 'distinct pageview.', {
url,
os,
browser,
device,
country,
}),
]);
return ok(res, { pageviews, sessions });
}
return methodNotAllowed(res);
};

View File

@ -1,44 +0,0 @@
import { ok, unauthorized, methodNotAllowed, getRandomChars } from 'next-basics';
import { updateWebsite, createWebsite, getWebsiteById } from 'queries';
import { useAuth } from 'lib/middleware';
import { uuid } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
const { user_id, is_admin } = req.auth;
const { website_id, enable_share_url } = req.body;
if (req.method === 'POST') {
const { name, domain, owner } = req.body;
const website_owner = parseInt(owner);
if (website_id) {
const website = await getWebsiteById(website_id);
if (website.user_id !== user_id && !is_admin) {
return unauthorized(res);
}
let { share_id } = website;
if (enable_share_url) {
share_id = share_id ? share_id : getRandomChars(8);
} else {
share_id = null;
}
await updateWebsite(website_id, { name, domain, share_id, user_id: website_owner });
return ok(res);
} else {
const website_uuid = uuid();
const share_id = enable_share_url ? getRandomChars(8) : null;
const website = await createWebsite(website_owner, { website_uuid, name, domain, share_id });
return ok(res, website);
}
}
return methodNotAllowed(res);
};

View File

@ -1,19 +1,18 @@
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { useAuth, useCors } from 'lib/middleware';
import { getActiveVisitors } from 'queries';
export default async (req, res) => {
if (req.method === 'GET') {
await useCors(req, res);
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id } = req.query;
const websiteId = +id;
const { id: websiteId } = req.query;
const result = await getActiveVisitors(websiteId);

View File

@ -0,0 +1,40 @@
import moment from 'moment-timezone';
import { getEventData } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware';
export default async (req, res) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'POST') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id: websiteId } = req.query;
const { start_at, end_at, timezone, event_name: eventName, columns, filters } = req.body;
if (!moment.tz.zone(timezone)) {
return badRequest(res);
}
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEventData(websiteId, {
startDate,
endDate,
timezone,
eventName,
columns,
filters,
});
return ok(res, events);
}
return methodNotAllowed(res);
};

View File

@ -2,31 +2,30 @@ import moment from 'moment-timezone';
import { getEventMetrics } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { useAuth, useCors } from 'lib/middleware';
const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => {
if (req.method === 'GET') {
await useCors(req, res);
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, unit, tz, url, event_name } = req.query;
const { id: websiteId, start_at, end_at, unit, tz, url, event_name } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEventMetrics(websiteId, startDate, endDate, tz, unit, {
url,
event_name,
eventName: event_name,
});
return ok(res, events);

View File

@ -0,0 +1,69 @@
import { allowQuery } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware';
import { getRandomChars, methodNotAllowed, ok, serverError, unauthorized } from 'next-basics';
import { deleteWebsite, getAccount, getWebsite, updateWebsite } from 'queries';
export default async (req, res) => {
await useCors(req, res);
await useAuth(req, res);
const { id: websiteId } = req.query;
if (!(await allowQuery(req))) {
return unauthorized(res);
}
if (req.method === 'GET') {
const website = await getWebsite({ websiteUuid: websiteId });
return ok(res, website);
}
if (req.method === 'POST') {
const { name, domain, owner, enableShareUrl, shareId } = req.body;
const { accountUuid } = req.auth;
let account;
if (accountUuid) {
account = await getAccount({ accountUuid });
if (!account) {
return serverError(res, 'Account does not exist.');
}
}
const website = await getWebsite({ websiteUuid: websiteId });
const newShareId = enableShareUrl ? website.shareId || getRandomChars(8) : null;
try {
await updateWebsite(
{
name,
domain,
shareId: shareId ? shareId : newShareId,
userId: account ? account.id : +owner || undefined,
},
{ websiteUuid: websiteId },
);
} catch (e) {
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) {
return serverError(res, 'That share ID is already taken.');
}
}
return ok(res);
}
if (req.method === 'DELETE') {
if (!(await allowQuery(req, true))) {
return unauthorized(res);
}
await deleteWebsite(websiteId);
return ok(res);
}
return methodNotAllowed(res);
};

View File

@ -1,8 +1,8 @@
import { getPageviewMetrics, getSessionMetrics, getWebsiteById } from 'queries';
import { ok, methodNotAllowed, unauthorized, badRequest } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { FILTER_IGNORED } from 'lib/constants';
import { useAuth, useCors } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getPageviewMetrics, getSessionMetrics, getWebsiteByUuid } from 'queries';
const sessionColumns = ['browser', 'os', 'device', 'screen', 'country', 'language'];
const pageviewColumns = ['url', 'referrer', 'query'];
@ -34,25 +34,41 @@ function getColumn(type) {
}
export default async (req, res) => {
if (req.method === 'GET') {
await useCors(req, res);
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, type, start_at, end_at, url, referrer, os, browser, device, country } = req.query;
const {
id: websiteId,
type,
start_at,
end_at,
url,
referrer,
os,
browser,
device,
country,
} = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if (sessionColumns.includes(type)) {
let data = await getSessionMetrics(websiteId, startDate, endDate, type, {
os,
browser,
device,
country,
let data = await getSessionMetrics(websiteId, {
startDate,
endDate,
field: type,
filters: {
os,
browser,
device,
country,
},
});
if (type === 'language') {
@ -78,7 +94,7 @@ export default async (req, res) => {
let domain;
if (type === 'referrer') {
const website = await getWebsiteById(websiteId);
const website = await getWebsiteByUuid(websiteId);
if (!website) {
return badRequest(res);
@ -101,7 +117,13 @@ export default async (req, res) => {
query: type === 'query' && table !== 'event' ? true : undefined,
};
const data = await getPageviewMetrics(websiteId, startDate, endDate, column, table, filters);
const data = await getPageviewMetrics(websiteId, {
startDate,
endDate,
column,
table,
filters,
});
return ok(res, data);
}

View File

@ -0,0 +1,75 @@
import moment from 'moment-timezone';
import { getPageviewStats } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware';
const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const {
id: websiteId,
start_at,
end_at,
unit,
tz,
url,
referrer,
os,
browser,
device,
country,
} = req.query;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, {
start_at: startDate,
end_at: endDate,
timezone: tz,
unit,
count: '*',
filters: {
url,
referrer,
os,
browser,
device,
country,
},
}),
getPageviewStats(websiteId, {
start_at: startDate,
end_at: endDate,
timezone: tz,
unit,
count: 'distinct pageview.',
filters: {
url,
os,
browser,
device,
country,
},
}),
]);
return ok(res, { pageviews, sessions });
}
return methodNotAllowed(res);
};

View File

@ -1,10 +1,13 @@
import { resetWebsite } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware';
export default async (req, res) => {
const { id } = req.query;
const websiteId = +id;
await useCors(req, res);
await useAuth(req, res);
const { id: websiteId } = req.query;
if (req.method === 'POST') {
if (!(await allowQuery(req))) {

View File

@ -1,19 +1,29 @@
import { getWebsiteStats } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { useAuth, useCors } from 'lib/middleware';
export default async (req, res) => {
if (req.method === 'GET') {
await useCors(req, res);
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, url, referrer, os, browser, device, country } = req.query;
const {
id: websiteId,
start_at,
end_at,
url,
referrer,
os,
browser,
device,
country,
} = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
@ -21,21 +31,29 @@ export default async (req, res) => {
const prevStartDate = new Date(+start_at - distance);
const prevEndDate = new Date(+end_at - distance);
const metrics = await getWebsiteStats(websiteId, startDate, endDate, {
url,
referrer,
os,
browser,
device,
country,
const metrics = await getWebsiteStats(websiteId, {
start_at: startDate,
end_at: endDate,
filters: {
url,
referrer,
os,
browser,
device,
country,
},
});
const prevPeriod = await getWebsiteStats(websiteId, prevStartDate, prevEndDate, {
url,
referrer,
os,
browser,
device,
country,
const prevPeriod = await getWebsiteStats(websiteId, {
start_at: prevStartDate,
end_at: prevEndDate,
filters: {
url,
referrer,
os,
browser,
device,
country,
},
});
const stats = Object.keys(metrics[0]).reduce((obj, key) => {

View File

@ -1,26 +1,49 @@
import { getAllWebsites, getUserWebsites } from 'queries';
import { createWebsite, getAccount, getAllWebsites, getUserWebsites } from 'queries';
import { ok, methodNotAllowed, unauthorized, getRandomChars } from 'next-basics';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
import { uuid } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
const { user_id: current_user_id, is_admin } = req.auth;
const { userId: currentUserId, isAdmin, accountUuid } = req.auth;
const { user_id, include_all } = req.query;
const userId = +user_id;
let account;
if (accountUuid) {
account = await getAccount({ accountUuid: accountUuid });
}
const userId = account ? account.id : +user_id;
if (req.method === 'GET') {
if (userId && userId !== current_user_id && !is_admin) {
if (userId && userId !== currentUserId && !isAdmin) {
return unauthorized(res);
}
const websites =
is_admin && include_all
isAdmin && include_all
? await getAllWebsites()
: await getUserWebsites(userId || current_user_id);
: await getUserWebsites(userId || currentUserId);
return ok(res, websites);
}
if (req.method === 'POST') {
const { name, domain, owner, enableShareUrl } = req.body;
const website_owner = account ? account.id : +owner;
if (website_owner !== currentUserId && !isAdmin) {
return unauthorized(res);
}
const websiteUuid = uuid();
const shareId = enableShareUrl ? getRandomChars(8) : null;
const website = await createWebsite(website_owner, { websiteUuid, name, domain, shareId });
return ok(res, website);
}
return methodNotAllowed(res);
};

View File

@ -8,7 +8,7 @@ export default function ConsolePage({ enabled }) {
const { loading } = useRequireLogin();
const { user } = useUser();
if (loading || !enabled || !user?.is_admin) {
if (loading || !enabled || !user?.isAdmin) {
return null;
}

View File

@ -16,6 +16,6 @@ export default function LoginPage({ loginDisabled }) {
export async function getServerSideProps() {
return {
props: { loginDisabled: !!process.env.DISABLE_LOGIN },
props: { loginDisabled: !!process.env.DISABLE_LOGIN || !!process.env.isCloudMode },
};
}

View File

@ -6,7 +6,7 @@ import useRequireLogin from 'hooks/useRequireLogin';
export default function SettingsPage() {
const { loading } = useRequireLogin();
if (loading) {
if (process.env.isCloudMode || loading) {
return null;
}

View File

@ -14,11 +14,11 @@ export default function SharePage() {
return null;
}
const { websiteId } = shareToken;
const { websiteUuid } = shareToken;
return (
<Layout>
<WebsiteDetails websiteId={websiteId} />
<WebsiteDetails websiteId={websiteUuid} />
</Layout>
);
}

View File

@ -11,6 +11,18 @@
"value": "Add account"
}
],
"label.add-column": [
{
"type": 0,
"value": "Add column"
}
],
"label.add-filter": [
{
"type": 0,
"value": "Add filter"
}
],
"label.add-website": [
{
"type": 0,
@ -155,6 +167,18 @@
"value": "Enable share URL"
}
],
"label.event-data": [
{
"type": 0,
"value": "Event Data"
}
],
"label.field-name": [
{
"type": 0,
"value": "Field Name"
}
],
"label.invalid": [
{
"type": 0,
@ -313,6 +337,12 @@
"value": "Save"
}
],
"label.search": [
{
"type": 0,
"value": "Search"
}
],
"label.settings": [
{
"type": 0,
@ -373,6 +403,12 @@
"value": "Tracking code"
}
],
"label.type": [
{
"type": 0,
"value": "Type"
}
],
"label.unknown": [
{
"type": 0,
@ -385,6 +421,12 @@
"value": "Username"
}
],
"label.value": [
{
"type": 0,
"value": "Value"
}
],
"label.view-details": [
{
"type": 0,

View File

@ -244,7 +244,7 @@
"label.none": [
{
"type": 0,
"value": "None"
"value": "Байхгүй"
}
],
"label.owner": [
@ -397,6 +397,12 @@
"value": "Вебүүд"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Өчигдөр"
}
],
"message.active-users": [
{
"type": 0,
@ -456,7 +462,7 @@
"message.confirm-reset": [
{
"type": 0,
"value": "Are your sure you want to reset "
"value": "Та "
},
{
"type": 1,
@ -464,7 +470,7 @@
},
{
"type": 0,
"value": "'s statistics?"
"value": "-н тоон үзүүлэлтүүдийг устгахдаа итгэлтэй байна уу?"
}
],
"message.copied": [
@ -482,7 +488,7 @@
"message.edit-dashboard": [
{
"type": 0,
"value": "Edit dashboard"
"value": "Хянах самбар засах"
}
],
"message.failure": [
@ -770,7 +776,7 @@
"metrics.query-parameters": [
{
"type": 0,
"value": "Query parameters"
"value": "Query параметр"
}
],
"metrics.referrers": [
@ -782,7 +788,7 @@
"metrics.screens": [
{
"type": 0,
"value": "Screens"
"value": "Дэлгэц"
}
],
"metrics.unique-visitors": [

View File

@ -397,6 +397,12 @@
"value": "网站"
}
],
"label.yesterday": [
{
"type": 0,
"value": "昨天"
}
],
"message.active-users": [
{
"type": 0,

View File

@ -1,47 +1,47 @@
import prisma from 'lib/prisma';
import redis, { DELETED } from 'lib/redis';
export async function deleteAccount(user_id) {
export async function deleteAccount(userId) {
const { client } = prisma;
const websites = await client.website.findMany({
where: { user_id },
select: { website_uuid: true },
where: { userId },
select: { websiteUuid: true },
});
let websiteUuids = [];
if (websites.length > 0) {
websiteUuids = websites.map(a => a.website_uuid);
websiteUuids = websites.map(a => a.websiteUuid);
}
return client
.$transaction([
client.pageview.deleteMany({
where: { session: { website: { user_id } } },
where: { session: { website: { userId } } },
}),
client.event_data.deleteMany({
where: { event: { session: { website: { user_id } } } },
client.eventData.deleteMany({
where: { event: { session: { website: { userId } } } },
}),
client.event.deleteMany({
where: { session: { website: { user_id } } },
where: { session: { website: { userId } } },
}),
client.session.deleteMany({
where: { website: { user_id } },
where: { website: { userId } },
}),
client.website.deleteMany({
where: { user_id },
where: { userId },
}),
client.account.delete({
where: {
user_id,
id: userId,
},
}),
])
.then(async res => {
if (redis.client) {
for (let i = 0; i < websiteUuids.length; i++) {
await redis.client.set(`website:${websiteUuids[i]}`, DELETED);
await redis.set(`website:${websiteUuids[i]}`, DELETED);
}
}

View File

@ -0,0 +1,7 @@
import prisma from 'lib/prisma';
export async function getAccount(where) {
return prisma.client.account.findUnique({
where,
});
}

View File

@ -1,9 +1,9 @@
import prisma from 'lib/prisma';
export async function getAccountById(user_id) {
export async function getAccountById(userId) {
return prisma.client.account.findUnique({
where: {
user_id,
id: userId,
},
});
}

View File

@ -3,17 +3,17 @@ import prisma from 'lib/prisma';
export async function getAccounts() {
return prisma.client.account.findMany({
orderBy: [
{ is_admin: 'desc' },
{ isAdmin: 'desc' },
{
username: 'asc',
},
],
select: {
user_id: true,
id: true,
username: true,
is_admin: true,
created_at: true,
updated_at: true,
isAdmin: true,
createdAt: true,
updatedAt: true,
},
});
}

View File

@ -1,10 +1,8 @@
import prisma from 'lib/prisma';
export async function updateAccount(user_id, data) {
export async function updateAccount(data, where) {
return prisma.client.account.update({
where: {
user_id,
},
where,
data,
});
}

View File

@ -1,13 +1,13 @@
import prisma from 'lib/prisma';
import redis from 'lib/redis';
export async function createWebsite(user_id, data) {
export async function createWebsite(userId, data) {
return prisma.client.website
.create({
data: {
account: {
connect: {
user_id,
id: userId,
},
},
...data,
@ -15,7 +15,7 @@ export async function createWebsite(user_id, data) {
})
.then(async res => {
if (redis.client && res) {
await redis.client.set(`website:${res.website_uuid}`, res.website_id);
await redis.client.set(`website:${res.websiteUuid}`, res.id);
}
return res;

View File

@ -1,31 +1,31 @@
import prisma from 'lib/prisma';
import redis, { DELETED } from 'lib/redis';
import { getWebsiteById } from 'queries';
import { getWebsiteByUuid } from 'queries';
export async function deleteWebsite(website_id) {
export async function deleteWebsite(websiteId) {
const { client, transaction } = prisma;
const { website_uuid } = await getWebsiteById(website_id);
const { websiteUuid } = await getWebsiteByUuid(websiteId);
return transaction([
client.pageview.deleteMany({
where: { session: { website: { website_id } } },
where: { session: { website: { websiteUuid: websiteId } } },
}),
client.event_data.deleteMany({
where: { event: { session: { website: { website_id } } } },
client.eventData.deleteMany({
where: { event: { session: { website: { websiteUuid: websiteId } } } },
}),
client.event.deleteMany({
where: { session: { website: { website_id } } },
where: { session: { website: { websiteUuid: websiteId } } },
}),
client.session.deleteMany({
where: { website: { website_id } },
where: { website: { websiteUuid: websiteId } },
}),
client.website.delete({
where: { website_id },
where: { websiteUuid: websiteId },
}),
]).then(async res => {
if (redis.client) {
await redis.client.set(`website:${website_uuid}`, DELETED);
await redis.client.set(`website:${websiteUuid}`, DELETED);
}
return res;

View File

@ -4,7 +4,7 @@ export async function getAllWebsites() {
let data = await prisma.client.website.findMany({
orderBy: [
{
user_id: 'asc',
userId: 'asc',
},
{
name: 'asc',

View File

@ -1,9 +1,9 @@
import prisma from 'lib/prisma';
export async function getUserWebsites(user_id) {
export async function getUserWebsites(userId) {
return prisma.client.website.findMany({
where: {
user_id,
userId,
},
orderBy: {
name: 'asc',

View File

@ -0,0 +1,7 @@
import prisma from 'lib/prisma';
export async function getWebsite(where) {
return prisma.client.website.findUnique({
where,
});
}

View File

@ -1,9 +1,9 @@
import prisma from 'lib/prisma';
export async function getWebsiteById(website_id) {
export async function getWebsiteById(websiteId) {
return prisma.client.website.findUnique({
where: {
website_id,
id: websiteId,
},
});
}

View File

@ -1,9 +1,9 @@
import prisma from 'lib/prisma';
export async function getWebsiteByShareId(share_id) {
export async function getWebsiteByShareId(shareId) {
return prisma.client.website.findUnique({
where: {
share_id,
shareId,
},
});
}

View File

@ -1,16 +1,16 @@
import prisma from 'lib/prisma';
import redis from 'lib/redis';
export async function getWebsiteByUuid(website_uuid) {
export async function getWebsiteByUuid(websiteUuid) {
return prisma.client.website
.findUnique({
where: {
website_uuid,
websiteUuid,
},
})
.then(async res => {
if (redis.client && res) {
await redis.client.set(`website:${res.website_uuid}`, 1);
await redis.client.set(`website:${res.websiteUuid}`, res.id);
}
return res;

View File

@ -1,20 +1,20 @@
import prisma from 'lib/prisma';
export async function resetWebsite(website_id) {
export async function resetWebsite(websiteId) {
const { client, transaction } = prisma;
return transaction([
client.pageview.deleteMany({
where: { session: { website: { website_id } } },
where: { session: { website: { websiteUuid: websiteId } } },
}),
client.event_data.deleteMany({
where: { event: { session: { website: { website_id } } } },
client.eventData.deleteMany({
where: { event: { session: { website: { websiteUuid: websiteId } } } },
}),
client.event.deleteMany({
where: { session: { website: { website_id } } },
where: { session: { website: { websiteUuid: websiteId } } },
}),
client.session.deleteMany({
where: { website: { website_id } },
where: { website: { websiteUuid: websiteId } },
}),
]);
}

View File

@ -1,10 +1,8 @@
import prisma from 'lib/prisma';
export async function updateWebsite(website_id, data) {
export async function updateWebsite(data, where) {
return prisma.client.website.update({
where: {
website_id,
},
where,
data,
});
}

View File

@ -0,0 +1,63 @@
import clickhouse from 'lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
export async function getEventData(...args) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId, { startDate, endDate, event_name, columns, filters }) {
const { rawQuery, getEventDataColumnsQuery, getEventDataFilterQuery } = prisma;
const params = [startDate, endDate];
return rawQuery(
`select
${getEventDataColumnsQuery('event_data.event_data', columns)}
from event
join website
on event.website_id = website.website_id
join event_data
on event.event_id = event_data.event_id
where website_uuid='${websiteId}'
and event.created_at between $1 and $2
${event_name ? `and event_name = ${event_name}` : ''}
${
Object.keys(filters).length > 0
? `and ${getEventDataFilterQuery('event_data.event_data', filters)}`
: ''
}`,
params,
).then(results => {
return Object.keys(results[0]).map(a => {
return { x: a, y: results[0][`${a}`] };
});
});
}
async function clickhouseQuery(websiteId, { startDate, endDate, event_name, columns, filters }) {
const { rawQuery, getBetweenDates, getEventDataColumnsQuery, getEventDataFilterQuery } =
clickhouse;
const params = [websiteId];
return rawQuery(
`select
${getEventDataColumnsQuery('event_data', columns)}
from event
where website_id= $1
${event_name ? `and event_name = ${event_name}` : ''}
and ${getBetweenDates('created_at', startDate, endDate)}
${
Object.keys(filters).length > 0
? `and ${getEventDataFilterQuery('event_data', filters)}`
: ''
}`,
params,
).then(results => {
return Object.keys(results[0]).map(a => {
return { x: a, y: results[0][`${a}`] };
});
});
}

Some files were not shown because too many files have changed in this diff Show More