mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 12:05:41 +01:00
Merge branch 'umami-software:master' into master
This commit is contained in:
commit
24fb8cfac4
133 changed files with 4732 additions and 2027 deletions
12
README.md
12
README.md
|
|
@ -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
|
||||
|
||||
|
|
@ -76,12 +70,12 @@ docker compose up
|
|||
|
||||
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
||||
```bash
|
||||
docker pull docker.umami.is/umami-software/umami:postgresql-latest
|
||||
docker pull docker.umami.dev/umami-software/umami:postgresql-latest
|
||||
```
|
||||
|
||||
Or with MySQL support:
|
||||
```bash
|
||||
docker pull docker.umami.is/umami-software/umami:mysql-latest
|
||||
docker pull docker.umami.dev/umami-software/umami:mysql-latest
|
||||
```
|
||||
|
||||
## Getting updates
|
||||
|
|
|
|||
48
components/common/EventDataButton.js
Normal file
48
components/common/EventDataButton.js
Normal 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;
|
||||
3
components/common/EventDataButton.module.css
Normal file
3
components/common/EventDataButton.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,15 +40,16 @@ 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, error } = await post(`/accounts/${user.accountUuid}/password`, values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
error || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
262
components/forms/EventDataForm.js
Normal file
262
components/forms/EventDataForm.js
Normal 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: 'Maximum', 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
components/forms/EventDataForm.module.css
Normal file
38
components/forms/EventDataForm.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 { websiteUuid: 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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,5 @@
|
|||
.tag {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tag + .tag {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export default function MetricsTable({
|
|||
const { formatMessage } = useIntl();
|
||||
|
||||
const { data, loading, error } = useFetch(
|
||||
`/website/${websiteId}/metrics`,
|
||||
`/websites/${websiteId}/metrics`,
|
||||
{
|
||||
params: {
|
||||
type,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import styles from './RealtimeHeader.module.css';
|
|||
|
||||
export default function RealtimeHeader({ websites, data, websiteId, onSelect }) {
|
||||
const options = [
|
||||
{ label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
|
||||
{
|
||||
label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />,
|
||||
value: null,
|
||||
},
|
||||
].concat(
|
||||
websites.map(({ name, website_id }, index) => ({
|
||||
websites.map(({ name, websiteUuid }, index) => ({
|
||||
label: name,
|
||||
value: website_id,
|
||||
value: websiteUuid,
|
||||
divider: index === 0,
|
||||
})),
|
||||
);
|
||||
|
|
@ -22,7 +25,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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouter } from 'next/router';
|
||||
import Page from 'components/layout/Page';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import WebsiteList from 'components/pages/WebsiteList';
|
||||
|
|
@ -16,10 +15,7 @@ const messages = defineMessages({
|
|||
more: { id: 'label.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const userId = id?.[0];
|
||||
export default function Dashboard({ userId }) {
|
||||
const dashboard = useDashboard();
|
||||
const { showCharts, limit, editing } = dashboard;
|
||||
const [max, setMax] = useState(limit);
|
||||
|
|
|
|||
|
|
@ -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.websiteUuid) }))
|
||||
.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?.websiteUuid || 0));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
|
|
@ -76,8 +76,12 @@ 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(({ websiteUuid, name, domain }, index) => (
|
||||
<Draggable
|
||||
key={websiteUuid}
|
||||
draggableId={`${dragId}-${websiteUuid}`}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
|
|
|
|||
|
|
@ -21,18 +21,18 @@ 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() {
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const [data, setData] = useState();
|
||||
const [websiteId, setWebsiteId] = useState(0);
|
||||
const [websiteUuid, setWebsiteUuid] = useState(null);
|
||||
const { data: init, loading } = useFetch('/realtime/init');
|
||||
const { data: updates } = useFetch('/realtime/update', {
|
||||
params: { start_at: data?.timestamp },
|
||||
|
|
@ -50,17 +50,18 @@ export default function RealtimeDashboard() {
|
|||
if (data) {
|
||||
const { pageviews, sessions, events } = data;
|
||||
|
||||
if (websiteId) {
|
||||
if (websiteUuid) {
|
||||
const { id } = init.websites.find(n => n.websiteUuid === websiteUuid);
|
||||
return {
|
||||
pageviews: filterWebsite(pageviews, websiteId),
|
||||
sessions: filterWebsite(sessions, websiteId),
|
||||
events: filterWebsite(events, websiteId),
|
||||
pageviews: filterWebsite(pageviews, id),
|
||||
sessions: filterWebsite(sessions, id),
|
||||
events: filterWebsite(events, id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [data, websiteId]);
|
||||
}, [data, websiteUuid]);
|
||||
|
||||
const countries = useMemo(() => {
|
||||
if (realtimeData?.sessions) {
|
||||
|
|
@ -117,25 +118,20 @@ export default function RealtimeDashboard() {
|
|||
<Page>
|
||||
<RealtimeHeader
|
||||
websites={websites}
|
||||
websiteId={websiteId}
|
||||
websiteId={websiteUuid}
|
||||
data={{ ...realtimeData, countries }}
|
||||
onSelect={setWebsiteId}
|
||||
onSelect={setWebsiteUuid}
|
||||
/>
|
||||
<div className={styles.chart}>
|
||||
<RealtimeChart
|
||||
websiteId={websiteId}
|
||||
data={realtimeData}
|
||||
unit="minute"
|
||||
records={REALTIME_RANGE}
|
||||
/>
|
||||
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
|
||||
</div>
|
||||
<GridLayout>
|
||||
<GridRow>
|
||||
<GridColumn xs={12} lg={4}>
|
||||
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
|
||||
<RealtimeViews websiteId={websiteUuid} data={realtimeData} websites={websites} />
|
||||
</GridColumn>
|
||||
<GridColumn xs={12} lg={8}>
|
||||
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
|
||||
<RealtimeLog websiteId={websiteUuid} data={realtimeData} websites={websites} />
|
||||
</GridColumn>
|
||||
</GridRow>
|
||||
<GridRow>
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -27,22 +27,24 @@ 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}`}>
|
||||
<a>
|
||||
<Icon icon={<LinkIcon />} />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
const DashboardLink = row => {
|
||||
return (
|
||||
<Link href={`/dashboard/${row.accountUuid}/${row.username}`}>
|
||||
<a>
|
||||
<Icon icon={<LinkIcon />} />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const Buttons = row => (
|
||||
<ButtonLayout align="right">
|
||||
<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 +59,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 +123,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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
70
db/clickhouse/schema.sql
Normal 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;
|
||||
35
db/mysql/migrations/04_add_uuid/migration.sql
Normal file
35
db/mysql/migrations/04_add_uuid/migration.sql
Normal 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`);
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
38
db/postgresql/migrations/04_add_uuid/migration.sql
Normal file
38
db/postgresql/migrations/04_add_uuid/migration.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- 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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
"label.more": "بیشتر",
|
||||
"label.name": "نام",
|
||||
"label.new-password": "رمز جدید",
|
||||
"label.none": "None",
|
||||
"label.none": "هیچ",
|
||||
"label.owner": "ایجاد شده توسط",
|
||||
"label.password": "رمز",
|
||||
"label.passwords-dont-match": "رمزها یکسان نیستند",
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"message.confirm-reset": "آیا از بازنشانی آمار {target} مطمئن هستید?",
|
||||
"message.copied": "کپی شد!",
|
||||
"message.delete-warning": "همهی دادههای مرتبط هم حذف خواهد شد.",
|
||||
"message.edit-dashboard": "Edit dashboard",
|
||||
"message.edit-dashboard": "ویرایش داشبورد",
|
||||
"message.failure": "مشکلی پیش آمده است.",
|
||||
"message.get-share-url": "دریافت URL برای اشتراک گذاری",
|
||||
"message.get-tracking-code": "گرفتن کد رهگیری",
|
||||
|
|
@ -103,9 +103,9 @@
|
|||
"metrics.operating-systems": "سیستمعاملها",
|
||||
"metrics.page-views": "بازدید صفحه",
|
||||
"metrics.pages": "صفحهها",
|
||||
"metrics.query-parameters": "Query parameters",
|
||||
"metrics.query-parameters": "پارامترهای کوئری",
|
||||
"metrics.referrers": "ارجاع دهندگان",
|
||||
"metrics.screens": "Screens",
|
||||
"metrics.screens": "نمایشگرها",
|
||||
"metrics.unique-visitors": "بازدیدکنندههای یکتا",
|
||||
"metrics.views": "بازدید",
|
||||
"metrics.visitors": "بازدیدکننده"
|
||||
|
|
|
|||
120
lang/hr-HR.json
Normal file
120
lang/hr-HR.json
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
"label.accounts": "Računi",
|
||||
"label.add-account": "Dodaj račun",
|
||||
"label.add-column": "Dodaj stupac",
|
||||
"label.add-filter": "Dodaj filter",
|
||||
"label.add-website": "Dodaj web stranicu",
|
||||
"label.administrator": "Administrator",
|
||||
"label.all": "Sve",
|
||||
"label.all-time": "Svo vrijeme" ,
|
||||
"label.all-websites": "Sve web stranice",
|
||||
"label.back": "Natrag ",
|
||||
"label.cancel": "Odustani",
|
||||
"label.change-password": "Promijeni lozinku",
|
||||
"label.confirm-password": "Potvrdi lozinku",
|
||||
"label.copy-to-clipboard": "Kopiraj u međuspremnik",
|
||||
"label.current-password": "Trenutna lozinka",
|
||||
"label.custom-range": "Prilagođeni raspon",
|
||||
"label.dashboard": "Nadzorna ploča",
|
||||
"label.date-range": "Raspon datuma",
|
||||
"label.default-date-range": "Zadani datumski raspon",
|
||||
"label.delete": "Obriši",
|
||||
"label.delete-account": "Obriši račun",
|
||||
"label.delete-website": "Obriši web stranicu",
|
||||
"label.dismiss": "Odbaci",
|
||||
"label.domain": "Domena",
|
||||
"label.edit": "Uredi",
|
||||
"label.edit-account": "Uredi račun",
|
||||
"label.edit-website": "Uredi web stranicu",
|
||||
"label.enable-share-url": "Omogući dijeljenje URL-a",
|
||||
"label.event-data": "Podaci događaja",
|
||||
"label.field-name": "Naziv polja",
|
||||
"label.invalid": "Nesipravno",
|
||||
"label.invalid-domain": "Neispravna domena",
|
||||
"label.language": "Jezik",
|
||||
"label.last-days": "Zadnjih {x} dana",
|
||||
"label.last-hours": "Zadnjih {x} sati",
|
||||
"label.logged-in-as": "Prijavljen kao {username}",
|
||||
"label.login": "Prijava",
|
||||
"label.logout": "Odjava",
|
||||
"label.more": "Više",
|
||||
"label.name": "Ime",
|
||||
"label.new-password": "Nova lozinka",
|
||||
"label.none": "Ništa",
|
||||
"label.owner": "Vlasnik",
|
||||
"label.password": "Lozinka",
|
||||
"label.passwords-dont-match": "Lozinka nije točna",
|
||||
"label.profile": "Profil",
|
||||
"label.realtime": "Stvarno vrijeme",
|
||||
"label.realtime-logs":"Trenutni zapisi",
|
||||
"label.refresh": "Osvježi",
|
||||
"label.required": "Potrebna",
|
||||
"label.reset": "Resetirati",
|
||||
"label.reset-website": "Resetirati web stranicu",
|
||||
"label.save": "Spremi",
|
||||
"label.search": "Pretraži",
|
||||
"label.settings": "Postavke",
|
||||
"label.share-url": "Podijeli URL",
|
||||
"label.single-day": "Jedan dan",
|
||||
"label.theme": "Tema",
|
||||
"label.this-month": "Ovaj mjesec",
|
||||
"label.this-week": "Ovaj tjedan",
|
||||
"label.this-year": "Ova godina",
|
||||
"label.timezone": "Vremenska zona",
|
||||
"label.today": "Danas",
|
||||
"label.tracking-code": "Kod za praćenje",
|
||||
"label.type": "Tip",
|
||||
"label.unknown": "Nepoznato",
|
||||
"label.username": "Korisničko ime",
|
||||
"label.value": "Vrijednost",
|
||||
"label.view-details": "Pogledaj detalje",
|
||||
"label.websites": "Web stranice",
|
||||
"label.yesterday": "Jučer",
|
||||
"message.active-users": "{x} Trenutno {x, plural, one {visitor} other {visitors}}",
|
||||
"message.confirm-delete": "Jeste li sigurni da želite obrisati {target}?",
|
||||
"message.confirm-reset": "Jeste li sigurni da želite resetirati {target}'s statistiku?",
|
||||
"message.copied": "Kopirano!",
|
||||
"message.delete-warning": "Izbrisat će se svi povezani podaci.",
|
||||
"message.edit-dashboard": "Uredi nadzornu ploču",
|
||||
"message.failure": "Nešto je pošlo po zlu.",
|
||||
"message.get-share-url": "Dohvati URL za dijeljenje",
|
||||
"message.get-tracking-code": "Dohvati kod za praćenje",
|
||||
"message.go-to-settings": "Idi u postavke",
|
||||
"message.incorrect-username-password": "Neispravno korisničke ime/lozinka.",
|
||||
"message.log.visitor": "Posjetitelj iz {country} koristi {browser} na {os} {device}",
|
||||
"message.new-version-available": "Nova verzija umami {version} je dostupna!",
|
||||
"message.no-data-available": "Nema dostupnih podataka.",
|
||||
"message.no-websites-configured": "Nemate konfiguriranu nijednu web stranicu.",
|
||||
"message.page-not-found": "Stranica nije pronađena.",
|
||||
"message.powered-by": "Powered by {name}",
|
||||
"message.reset-warning": "Sve statistike za ovu web stranicu bit će izbrisane, ali će vaš kod za praćenje ostati netaknut.",
|
||||
"message.save-success": "Uspješno spremljeno.",
|
||||
"message.share-url": "Ovo je javno dijeljeni URL za {target}.",
|
||||
"message.toggle-charts": "Uključi/isključi grafikone",
|
||||
"message.track-stats": "Da biste pratili statistiku za {target}, postavite sljedeći kod u odjeljak {head} svoje web stranice.",
|
||||
"message.type-delete": "Upišite {delete} u donji okvir za potvrdu.",
|
||||
"message.type-reset": " Upišite {reset} u donji okvir za potvrdu. ",
|
||||
"metrics.actions": "Akcije",
|
||||
"metrics.average-visit-time": "Prosječno vrijeme posjeta",
|
||||
"metrics.bounce-rate": "Stopa napuštanja stranice",
|
||||
"metrics.browsers": "Web preglednici",
|
||||
"metrics.countries": "Zemlje",
|
||||
"metrics.device.desktop": "Pc",
|
||||
"metrics.device.laptop": "Laptop",
|
||||
"metrics.device.mobile": "Mobitel",
|
||||
"metrics.device.tablet": "Tablet",
|
||||
"metrics.devices": "Uređaji",
|
||||
"metrics.events": "Događaji",
|
||||
"metrics.filter.combined": "Kombinirano",
|
||||
"metrics.filter.raw": "Neobrađeni podaci",
|
||||
"metrics.languages": "Jezici",
|
||||
"metrics.operating-systems": "Operativni sustavi",
|
||||
"metrics.page-views": "Pregledi stranice",
|
||||
"metrics.pages": "Stranice",
|
||||
"metrics.query-parameters": "Parametri upita",
|
||||
"metrics.referrers": "Upučivaći",
|
||||
"metrics.screens": "Zasloni",
|
||||
"metrics.unique-visitors": "Jedinstveni posjetitelji",
|
||||
"metrics.views": "Pregledi",
|
||||
"metrics.visitors": "Posjetitelji"
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"label.accounts": "Konta",
|
||||
"label.add-account": "Dodaj konto",
|
||||
"label.add-column": "Dodaj kolumnę",
|
||||
"label.add-filter": "Dodaj filtr",
|
||||
"label.add-website": "Dodaj witrynę",
|
||||
"label.administrator": "Administrator",
|
||||
"label.all": "Wszystkie",
|
||||
|
|
@ -25,6 +27,8 @@
|
|||
"label.edit-account": "Edytuj konto",
|
||||
"label.edit-website": "Edytuj witrynę",
|
||||
"label.enable-share-url": "Włącz udostępnianie adresu URL",
|
||||
"label.event-data": "Dane zdarzenia",
|
||||
"label.field-name": "Nazwa pola",
|
||||
"label.invalid": "Nieprawidłowy",
|
||||
"label.invalid-domain": "Nieprawidłowa witryna",
|
||||
"label.language": "Język",
|
||||
|
|
@ -36,7 +40,7 @@
|
|||
"label.more": "Więcej",
|
||||
"label.name": "Nazwa",
|
||||
"label.new-password": "Nowe hasło",
|
||||
"label.none": "None",
|
||||
"label.none": "Brak",
|
||||
"label.owner": "Właściciel",
|
||||
"label.password": "Hasło",
|
||||
"label.passwords-dont-match": "Hasła się nie zgadzają",
|
||||
|
|
@ -48,6 +52,7 @@
|
|||
"label.reset": "Zresetuj",
|
||||
"label.reset-website": "Zresetuj statystyki",
|
||||
"label.save": "Zapisz",
|
||||
"label.search": "Szukaj",
|
||||
"label.settings": "Ustawienia",
|
||||
"label.share-url": "Udostępnij adres URL",
|
||||
"label.single-day": "W tym dniu",
|
||||
|
|
@ -58,16 +63,19 @@
|
|||
"label.timezone": "Strefa czasowa",
|
||||
"label.today": "Dzisiaj",
|
||||
"label.tracking-code": "Kod śledzenia",
|
||||
"label.type": "Typ",
|
||||
"label.unknown": "Nieznany",
|
||||
"label.username": "Nazwa użytkownika",
|
||||
"label.value": "Wartość",
|
||||
"label.view-details": "Pokaż szczegóły",
|
||||
"label.websites": "Witryny",
|
||||
"label.yesterday": "Wczoraj",
|
||||
"message.active-users": "{x} aktualnie {x, plural, one {odwiedzający} other {odwiedzających}}",
|
||||
"message.confirm-delete": "Czy na pewno chcesz usunąć {target}?",
|
||||
"message.confirm-reset": "Czy na pewno chcesz zresetować statystyki {target}?",
|
||||
"message.copied": "Skopiowano!",
|
||||
"message.delete-warning": "Wszystkie powiązane dane również zostaną usunięte.",
|
||||
"message.edit-dashboard": "Edit dashboard",
|
||||
"message.edit-dashboard": "Edytuj panel",
|
||||
"message.failure": "Coś poszło nie tak.",
|
||||
"message.get-share-url": "Uzyskaj adres URL udostępniania",
|
||||
"message.get-tracking-code": "Pobierz kod śledzenia",
|
||||
|
|
@ -103,7 +111,7 @@
|
|||
"metrics.operating-systems": "System operacyjny",
|
||||
"metrics.page-views": "Wyświetlenia strony",
|
||||
"metrics.pages": "Strony",
|
||||
"metrics.query-parameters": "Query parameters",
|
||||
"metrics.query-parameters": "Parametry query",
|
||||
"metrics.referrers": "Źródła odsyłające",
|
||||
"metrics.screens": "Ekrany",
|
||||
"metrics.unique-visitors": "Unikalni odwiedzający",
|
||||
|
|
|
|||
|
|
@ -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} 的数据吗?",
|
||||
|
|
|
|||
56
lib/auth.js
56
lib/auth.js
|
|
@ -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 { getAccount, getWebsite } from 'queries';
|
||||
import { SHARE_TOKEN_HEADER, TYPE_ACCOUNT, TYPE_WEBSITE } from 'lib/constants';
|
||||
import { secret } from 'lib/crypto';
|
||||
|
||||
export async function getAuthToken(req) {
|
||||
export function getAuthToken(req) {
|
||||
try {
|
||||
const token = req.headers.authorization;
|
||||
|
||||
|
|
@ -13,20 +13,20 @@ export async function getAuthToken(req) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getAuthHeader() {
|
||||
const token = getItem(AUTH_TOKEN);
|
||||
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
export function getShareToken(req) {
|
||||
try {
|
||||
return parseToken(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());
|
||||
|
||||
if (typeof validation === 'object') {
|
||||
return !Object.keys(validation).find(key => result[key] !== validation[key]);
|
||||
return !Object.keys(validation).find(key => token[key] !== validation[key]);
|
||||
} else if (typeof validation === 'function') {
|
||||
return validation(result);
|
||||
return validation(token);
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
|
|
@ -35,24 +35,28 @@ export async function isValidToken(token, validation) {
|
|||
return false;
|
||||
}
|
||||
|
||||
export async function allowQuery(req, skipToken) {
|
||||
export async function allowQuery(req, type) {
|
||||
const { id } = req.query;
|
||||
const token = req.headers[SHARE_TOKEN_HEADER];
|
||||
const websiteId = +id;
|
||||
|
||||
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, { id });
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
const { user_id, is_admin } = authToken;
|
||||
if (userId) {
|
||||
if (type === TYPE_WEBSITE) {
|
||||
const website = await getWebsite({ websiteUuid: id });
|
||||
|
||||
return is_admin || website.user_id === user_id;
|
||||
return website && website.userId === userId;
|
||||
} else if (type === TYPE_ACCOUNT) {
|
||||
const account = await getAccount({ accountUuid: id });
|
||||
|
||||
return account && account.accountUuid === id;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
|
|||
export const REALTIME_RANGE = 30;
|
||||
export const REALTIME_INTERVAL = 3000;
|
||||
|
||||
export const TYPE_WEBSITE = 'website';
|
||||
export const TYPE_ACCOUNT = 'account';
|
||||
|
||||
export const THEME_COLORS = {
|
||||
light: {
|
||||
primary: '#2680eb',
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ export const customFormats = {
|
|||
p: 'ha',
|
||||
pp: 'h:mm:ss',
|
||||
},
|
||||
'fr-FR': {
|
||||
'M/d': 'd/M',
|
||||
'MMM d': 'd MMM',
|
||||
'EEE M/d': 'EEE d/M',
|
||||
},
|
||||
};
|
||||
|
||||
export function dateFormat(date, str, locale = 'en-US') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
41
lib/kafka.js
41
lib/kafka.js
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
faIR,
|
||||
he,
|
||||
hi,
|
||||
hr,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
|
|
@ -61,6 +62,7 @@ export const languages = {
|
|||
'he-IL': { label: 'עברית', dateLocale: he },
|
||||
'hi-IN': { label: 'हिन्दी', dateLocale: hi },
|
||||
'hu-HU': { label: 'Hungarian', dateLocale: hu },
|
||||
'hr-HR': { label: 'hrvatski', dateLocale: hr },
|
||||
'it-IT': { label: 'Italiano', dateLocale: it },
|
||||
'id-ID': { label: 'Bahasa Indonesia', dateLocale: id },
|
||||
'ja-JP': { label: '日本語', dateLocale: ja },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
if (!token && !shareToken) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
req.auth = token;
|
||||
req.auth = { ...token, shareToken };
|
||||
next();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
lib/redis.js
45
lib/redis.js
|
|
@ -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 };
|
||||
|
|
|
|||
100
lib/session.js
100
lib/session.js
|
|
@ -1,9 +1,10 @@
|
|||
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';
|
||||
import { createSession, getSessionByUuid, getWebsite } from 'queries';
|
||||
|
||||
export async function getSession(req) {
|
||||
const { payload } = getJsonBody(req);
|
||||
|
|
@ -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 getWebsite({ 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
[functions]
|
||||
included_files = ["node_modules/.geo/**"]
|
||||
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "umami",
|
||||
"version": "1.38.0",
|
||||
"version": "1.39.5",
|
||||
"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",
|
||||
|
|
@ -106,6 +106,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^4.2.29",
|
||||
"@netlify/plugin-nextjs": "^4.27.3",
|
||||
"@rollup/plugin-buble": "^0.21.3",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
|
|
@ -125,7 +126,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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
70
pages/api/accounts/[id]/index.js
Normal file
70
pages/api/accounts/[id]/index.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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 (id === userId) {
|
||||
return badRequest(res, 'You cannot delete your own account.');
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
await deleteAccount(+id);
|
||||
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
return methodNotAllowed(res);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAccountById, updateAccount } from 'queries';
|
||||
import { getAccount, updateAccount } from 'queries';
|
||||
import { useAuth } from 'lib/middleware';
|
||||
import {
|
||||
badRequest,
|
||||
|
|
@ -8,28 +8,29 @@ import {
|
|||
checkPassword,
|
||||
hashPassword,
|
||||
} from 'next-basics';
|
||||
import { allowQuery } from 'lib/auth';
|
||||
import { TYPE_ACCOUNT } from 'lib/constants';
|
||||
|
||||
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 { current_password, new_password } = req.body;
|
||||
const { id: accountUuid } = req.query;
|
||||
|
||||
if (!is_admin && user_id !== auth_user_id) {
|
||||
if (!(await allowQuery(req, TYPE_ACCOUNT))) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const account = await getAccountById(user_id);
|
||||
const valid = checkPassword(current_password, account.password);
|
||||
const account = await getAccount({ accountUuid });
|
||||
|
||||
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({ password }, { accountUuid });
|
||||
|
||||
return ok(res, updated);
|
||||
}
|
||||
|
|
@ -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, getAccount, 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 account = await getAccount({ username });
|
||||
|
||||
if (account) {
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ok, unauthorized, badRequest, checkPassword, createSecureToken } from 'next-basics';
|
||||
import { getAccountByUsername } from 'queries/admin/account/getAccountByUsername';
|
||||
import { getAccount } from 'queries';
|
||||
import { secret } from 'lib/crypto';
|
||||
|
||||
export default async (req, res) => {
|
||||
|
|
@ -9,11 +9,11 @@ export default async (req, res) => {
|
|||
return badRequest(res);
|
||||
}
|
||||
|
||||
const account = await getAccountByUsername(username);
|
||||
const account = await getAccount({ 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 });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { Resolver } = require('dns').promises;
|
||||
import isbot from 'isbot';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { createToken, unauthorized, send, badRequest, forbidden } from 'next-basics';
|
||||
import { createToken, ok, send, badRequest, forbidden } from 'next-basics';
|
||||
import { savePageView, saveEvent } from 'queries';
|
||||
import { useCors, useSession } from 'lib/middleware';
|
||||
import { getJsonBody, getIpAddress } from 'lib/request';
|
||||
|
|
@ -11,7 +11,7 @@ export default async (req, res) => {
|
|||
await useCors(req, res);
|
||||
|
||||
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
|
||||
return unauthorized(res);
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
const ignoreIps = process.env.IGNORE_IP;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getWebsiteByShareId } from 'queries';
|
||||
import { getWebsite } from 'queries';
|
||||
import { ok, notFound, methodNotAllowed, createToken } from 'next-basics';
|
||||
import { secret } from 'lib/crypto';
|
||||
|
||||
|
|
@ -6,13 +6,14 @@ export default async (req, res) => {
|
|||
const { id } = req.query;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const website = await getWebsiteByShareId(id);
|
||||
const website = await getWebsite({ shareId: id });
|
||||
|
||||
if (website) {
|
||||
const websiteId = website.website_id;
|
||||
const token = createToken({ website_id: websiteId }, secret());
|
||||
const { websiteUuid } = website;
|
||||
const data = { id: websiteUuid };
|
||||
const token = createToken(data, secret());
|
||||
|
||||
return ok(res, { websiteId, token });
|
||||
return ok(res, { ...data, token });
|
||||
}
|
||||
|
||||
return notFound(res);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
export default async (req, res) => {
|
||||
if (req.method === 'GET') {
|
||||
await useCors(req, res);
|
||||
await useCors(req, res);
|
||||
await useAuth(req, res);
|
||||
|
||||
if (!(await allowQuery(req))) {
|
||||
if (req.method === 'GET') {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
const websiteId = +id;
|
||||
const { id: websiteId } = req.query;
|
||||
|
||||
const result = await getActiveVisitors(websiteId);
|
||||
|
||||
41
pages/api/websites/[id]/eventdata.js
Normal file
41
pages/api/websites/[id]/eventdata.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
export default async (req, res) => {
|
||||
await useCors(req, res);
|
||||
await useAuth(req, res);
|
||||
|
||||
if (req.method === 'POST') {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
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);
|
||||
};
|
||||
|
|
@ -2,31 +2,31 @@ 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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
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 (!(await allowQuery(req))) {
|
||||
if (req.method === 'GET') {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
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);
|
||||
71
pages/api/websites/[id]/index.js
Normal file
71
pages/api/websites/[id]/index.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
export default async (req, res) => {
|
||||
await useCors(req, res);
|
||||
await useAuth(req, res);
|
||||
|
||||
const { id: websiteUuid } = req.query;
|
||||
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const website = await getWebsite({ websiteUuid });
|
||||
|
||||
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 });
|
||||
|
||||
const newShareId = enableShareUrl ? website.shareId || getRandomChars(8) : null;
|
||||
|
||||
try {
|
||||
await updateWebsite(
|
||||
{
|
||||
name,
|
||||
domain,
|
||||
shareId: shareId ? shareId : newShareId,
|
||||
userId: +owner || account.id,
|
||||
},
|
||||
{ websiteUuid },
|
||||
);
|
||||
} 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, TYPE_WEBSITE))) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
await deleteWebsite(websiteUuid);
|
||||
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
return methodNotAllowed(res);
|
||||
};
|
||||
|
|
@ -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 { FILTER_IGNORED, TYPE_WEBSITE } from 'lib/constants';
|
||||
import { useAuth, useCors } from 'lib/middleware';
|
||||
import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||
import { getPageviewMetrics, getSessionMetrics, getWebsite } 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 (!(await allowQuery(req))) {
|
||||
if (req.method === 'GET') {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
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 getWebsite({ websiteUuid: 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);
|
||||
}
|
||||
76
pages/api/websites/[id]/pageviews.js
Normal file
76
pages/api/websites/[id]/pageviews.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
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, TYPE_WEBSITE))) {
|
||||
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);
|
||||
};
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import { resetWebsite } from 'queries';
|
||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||
import { allowQuery } from 'lib/auth';
|
||||
import { useAuth, useCors } from 'lib/middleware';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
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))) {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
|
|
@ -1,19 +1,30 @@
|
|||
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';
|
||||
import { TYPE_WEBSITE } from 'lib/constants';
|
||||
|
||||
export default async (req, res) => {
|
||||
if (req.method === 'GET') {
|
||||
await useCors(req, res);
|
||||
await useCors(req, res);
|
||||
await useAuth(req, res);
|
||||
|
||||
if (!(await allowQuery(req))) {
|
||||
if (req.method === 'GET') {
|
||||
if (!(await allowQuery(req, TYPE_WEBSITE))) {
|
||||
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 +32,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) => {
|
||||
|
|
@ -1,26 +1,50 @@
|
|||
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 { user_id, include_all } = req.query;
|
||||
const userId = +user_id;
|
||||
const { userId: currentUserId, isAdmin } = req.auth;
|
||||
const accountUuid = user_id || req.auth.accountUuid;
|
||||
let account;
|
||||
|
||||
if (accountUuid) {
|
||||
account = await getAccount({ 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: account?.id });
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,27 @@ import React from 'react';
|
|||
import Layout from 'components/layout/Layout';
|
||||
import Dashboard from 'components/pages/Dashboard';
|
||||
import useRequireLogin from 'hooks/useRequireLogin';
|
||||
import { useRouter } from 'next/router';
|
||||
import useUser from 'hooks/useUser';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const {
|
||||
query: { id },
|
||||
isReady,
|
||||
asPath,
|
||||
} = useRouter();
|
||||
const { loading } = useRequireLogin();
|
||||
const user = useUser();
|
||||
|
||||
if (loading) {
|
||||
if (!user || !isReady || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = id?.[0];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
<Dashboard key={asPath} userId={user.id || userId} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ export default function SharePage() {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { websiteId } = shareToken;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<WebsiteDetails websiteId={websiteId} />
|
||||
<WebsiteDetails websiteId={shareToken.id} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1
public/intl/country/hr-HR.json
Normal file
1
public/intl/country/hr-HR.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"AF":"Afganistan","AX":"\u00c5landski otoci","AL":"Albanija","DZ":"Al\u017eir","AS":"Ameri\u010dka Samoa","VI":"Ameri\u010dki Djevi\u010danski otoci","AD":"Andora","AO":"Angola","AI":"Angvila","AQ":"Antarktika","AG":"Antigva i Barbuda","AR":"Argentina","AM":"Armenija","AW":"Aruba","AU":"Australija","AT":"Austrija","AZ":"Azerbajd\u017ean","BS":"Bahami","BH":"Bahrein","BD":"Banglade\u0161","BB":"Barbados","BE":"Belgija","BZ":"Belize","BJ":"Benin","BM":"Bermudi","BY":"Bjelorusija","BW":"Bocvana","BO":"Bolivija","BA":"Bosna i Hercegovina","CX":"Bo\u017ei\u0107ni otok","BR":"Brazil","VG":"Britanski Djevi\u010danski otoci","IO":"Britanski Indijskooceanski teritorij","BN":"Brunej","BG":"Bugarska","BF":"Burkina Faso","BI":"Burundi","BT":"Butan","CY":"Cipar","CK":"Cookovi Otoci","ME":"Crna Gora","CW":"Cura\u00e7ao","TD":"\u010cad","CZ":"\u010ce\u0161ka","CL":"\u010cile","DK":"Danska","DM":"Dominika","DO":"Dominikanska Republika","DJ":"D\u017eibuti","EG":"Egipat","EC":"Ekvador","GQ":"Ekvatorska Gvineja","ER":"Eritreja","EE":"Estonija","SZ":"Esvatini","ET":"Etiopija","FK":"Falklandski otoci","FO":"Farski otoci","FJ":"Fid\u017ei","PH":"Filipini","FI":"Finska","FR":"Francuska","GF":"Francuska Gijana","PF":"Francuska Polinezija","TF":"Francuski ju\u017eni i antarkti\u010dki teritoriji","GA":"Gabon","GM":"Gambija","GH":"Gana","GI":"Gibraltar","GR":"Gr\u010dka","GD":"Grenada","GL":"Grenland","GE":"Gruzija","GP":"Guadalupe","GU":"Guam","GG":"Guernsey","GY":"Gvajana","GT":"Gvatemala","GN":"Gvineja","GW":"Gvineja Bisau","HT":"Haiti","HN":"Honduras","HR":"Hrvatska","IN":"Indija","ID":"Indonezija","IQ":"Irak","IR":"Iran","IE":"Irska","IS":"Island","IT":"Italija","IL":"Izrael","JM":"Jamajka","JP":"Japan","YE":"Jemen","JE":"Jersey","JO":"Jordan","GS":"Ju\u017ena Georgija i Ju\u017eni Sendvi\u010dki Otoci","KR":"Ju\u017ena Koreja","SS":"Ju\u017eni Sudan","ZA":"Ju\u017enoafri\u010dka Republika","KY":"Kajmanski otoci","KH":"Kambod\u017ea","CM":"Kamerun","CA":"Kanada","BQ":"Karipski otoci Nizozemske","QA":"Katar","KZ":"Kazahstan","KE":"Kenija","CN":"Kina","KG":"Kirgistan","KI":"Kiribati","CC":"Kokosovi (Keelingovi) otoci","CO":"Kolumbija","KM":"Komori","CG":"Kongo - Brazzaville","CD":"Kongo - Kinshasa","CR":"Kostarika","CU":"Kuba","KW":"Kuvajt","LA":"Laos","LV":"Latvija","LS":"Lesoto","LB":"Libanon","LR":"Liberija","LY":"Libija","LI":"Lihten\u0161tajn","LT":"Litva","LU":"Luksemburg","MG":"Madagaskar","HU":"Ma\u0111arska","MW":"Malavi","MV":"Maldivi","MY":"Malezija","ML":"Mali","UM":"Mali udaljeni otoci SAD-a","MT":"Malta","MA":"Maroko","MH":"Mar\u0161alovi Otoci","MQ":"Martinique","MR":"Mauretanija","MU":"Mauricijus","YT":"Mayotte","MX":"Meksiko","FM":"Mikronezija","MM":"Mjanmar (Burma)","MD":"Moldavija","MC":"Monako","MN":"Mongolija","MS":"Montserrat","MZ":"Mozambik","NA":"Namibija","NR":"Nauru","NP":"Nepal","NE":"Niger","NG":"Nigerija","NI":"Nikaragva","NU":"Niue","NL":"Nizozemska","NO":"Norve\u0161ka","NC":"Nova Kaledonija","NZ":"Novi Zeland","DE":"Njema\u010dka","CI":"Obala Bjelokosti","OM":"Oman","HM":"Otoci Heard i McDonald","PN":"Otoci Pitcairn","TC":"Otoci Turks i Caicos","BV":"Otok Bouvet","IM":"Otok Man","NF":"Otok Norfolk","PK":"Pakistan","PW":"Palau","PS":"Palestinsko podru\u010dje","PA":"Panama","PG":"Papua Nova Gvineja","PY":"Paragvaj","PE":"Peru","PL":"Poljska","PR":"Portoriko","PT":"Portugal","HK":"PUP Hong Kong Kina","MO":"PUP Makao Kina","RE":"R\u00e9union","RW":"Ruanda","RO":"Rumunjska","RU":"Rusija","BL":"Saint Barth\u00e9lemy","MF":"Saint Martin","PM":"Saint-Pierre-et-Miquelon","SB":"Salomonski Otoci","SV":"Salvador","WS":"Samoa","SM":"San Marino","SA":"Saudijska Arabija","SC":"Sej\u0161eli","SN":"Senegal","SL":"Sijera Leone","SG":"Singapur","SX":"Sint Maarten","SY":"Sirija","US":"Sjedinjene Ameri\u010dke Dr\u017eave","KP":"Sjeverna Koreja","MK":"Sjeverna Makedonija","MP":"Sjevernomarijanski otoci","SK":"Slova\u010dka","SI":"Slovenija","SO":"Somalija","RS":"Srbija","CF":"Srednjoafri\u010dka Republika","SD":"Sudan","SR":"Surinam","SJ":"Svalbard i Jan Mayen","SH":"Sveta Helena","LC":"Sveta Lucija","KN":"Sveti Kristofor i Nevis","ST":"Sveti Toma i Princip","VC":"Sveti Vincent i Grenadini","ES":"\u0160panjolska","LK":"\u0160ri Lanka","SE":"\u0160vedska","CH":"\u0160vicarska","TJ":"Tad\u017eikistan","TH":"Tajland","TW":"Tajvan","TZ":"Tanzanija","TL":"Timor-Leste","TG":"Togo","TK":"Tokelau","TO":"Tonga","TT":"Trinidad i Tobago","TN":"Tunis","TM":"Turkmenistan","TR":"Turska","TV":"Tuvalu","UG":"Uganda","AE":"Ujedinjeni Arapski Emirati","GB":"Ujedinjeno Kraljevstvo","UA":"Ukrajina","UY":"Urugvaj","UZ":"Uzbekistan","VU":"Vanuatu","VA":"Vatikanski Grad","VE":"Venezuela","VN":"Vijetnam","WF":"Wallis i Futuna","ZM":"Zambija","EH":"Zapadna Sahara","CV":"Zelenortska Republika","ZW":"Zimbabve"}
|
||||
1
public/intl/language/hr-HR.json
Normal file
1
public/intl/language/hr-HR.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@
|
|||
"label.none": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "None"
|
||||
"value": "هیچ"
|
||||
}
|
||||
],
|
||||
"label.owner": [
|
||||
|
|
@ -474,7 +474,7 @@
|
|||
"message.edit-dashboard": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Edit dashboard"
|
||||
"value": "ویرایش داشبورد"
|
||||
}
|
||||
],
|
||||
"message.failure": [
|
||||
|
|
@ -754,7 +754,7 @@
|
|||
"metrics.query-parameters": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Query parameters"
|
||||
"value": "پارامترهای کوئری"
|
||||
}
|
||||
],
|
||||
"metrics.referrers": [
|
||||
|
|
@ -766,7 +766,7 @@
|
|||
"metrics.screens": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Screens"
|
||||
"value": "نمایشگرها"
|
||||
}
|
||||
],
|
||||
"metrics.unique-visitors": [
|
||||
|
|
|
|||
854
public/intl/messages/hr-HR.json
Normal file
854
public/intl/messages/hr-HR.json
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
{
|
||||
"label.accounts": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Računi"
|
||||
}
|
||||
],
|
||||
"label.add-account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj račun"
|
||||
}
|
||||
],
|
||||
"label.add-column": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj stupac"
|
||||
}
|
||||
],
|
||||
"label.add-filter": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj filter"
|
||||
}
|
||||
],
|
||||
"label.add-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj web stranicu"
|
||||
}
|
||||
],
|
||||
"label.administrator": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Administrator"
|
||||
}
|
||||
],
|
||||
"label.all": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Sve"
|
||||
}
|
||||
],
|
||||
"label.all-time": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Svo vrijeme"
|
||||
}
|
||||
],
|
||||
"label.all-websites": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Sve web stranice"
|
||||
}
|
||||
],
|
||||
"label.back": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Natrag "
|
||||
}
|
||||
],
|
||||
"label.cancel": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Odustani"
|
||||
}
|
||||
],
|
||||
"label.change-password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Promijeni lozinku"
|
||||
}
|
||||
],
|
||||
"label.confirm-password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Potvrdi lozinku"
|
||||
}
|
||||
],
|
||||
"label.copy-to-clipboard": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Kopiraj u međuspremnik"
|
||||
}
|
||||
],
|
||||
"label.current-password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Trenutna lozinka"
|
||||
}
|
||||
],
|
||||
"label.custom-range": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Prilagođeni raspon"
|
||||
}
|
||||
],
|
||||
"label.dashboard": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nadzorna ploča"
|
||||
}
|
||||
],
|
||||
"label.date-range": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Raspon datuma"
|
||||
}
|
||||
],
|
||||
"label.default-date-range": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Zadani datumski raspon"
|
||||
}
|
||||
],
|
||||
"label.delete": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Obriši"
|
||||
}
|
||||
],
|
||||
"label.delete-account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Obriši račun"
|
||||
}
|
||||
],
|
||||
"label.delete-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Obriši web stranicu"
|
||||
}
|
||||
],
|
||||
"label.dismiss": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Odbaci"
|
||||
}
|
||||
],
|
||||
"label.domain": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Domena"
|
||||
}
|
||||
],
|
||||
"label.edit": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uredi"
|
||||
}
|
||||
],
|
||||
"label.edit-account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uredi račun"
|
||||
}
|
||||
],
|
||||
"label.edit-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uredi web stranicu"
|
||||
}
|
||||
],
|
||||
"label.enable-share-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Omogući dijeljenje URL-a"
|
||||
}
|
||||
],
|
||||
"label.event-data": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Podaci događaja"
|
||||
}
|
||||
],
|
||||
"label.field-name": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Naziv polja"
|
||||
}
|
||||
],
|
||||
"label.invalid": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nesipravno"
|
||||
}
|
||||
],
|
||||
"label.invalid-domain": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Neispravna domena"
|
||||
}
|
||||
],
|
||||
"label.language": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jezik"
|
||||
}
|
||||
],
|
||||
"label.last-days": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Zadnjih "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "x"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " dana"
|
||||
}
|
||||
],
|
||||
"label.last-hours": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Zadnjih "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "x"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " sati"
|
||||
}
|
||||
],
|
||||
"label.logged-in-as": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Prijavljen kao "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "username"
|
||||
}
|
||||
],
|
||||
"label.login": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Prijava"
|
||||
}
|
||||
],
|
||||
"label.logout": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Odjava"
|
||||
}
|
||||
],
|
||||
"label.more": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Više"
|
||||
}
|
||||
],
|
||||
"label.name": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ime"
|
||||
}
|
||||
],
|
||||
"label.new-password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nova lozinka"
|
||||
}
|
||||
],
|
||||
"label.none": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ništa"
|
||||
}
|
||||
],
|
||||
"label.owner": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Vlasnik"
|
||||
}
|
||||
],
|
||||
"label.password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Lozinka"
|
||||
}
|
||||
],
|
||||
"label.passwords-dont-match": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Lozinka nije točna"
|
||||
}
|
||||
],
|
||||
"label.profile": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Profil"
|
||||
}
|
||||
],
|
||||
"label.realtime": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Stvarno vrijeme"
|
||||
}
|
||||
],
|
||||
"label.realtime-logs": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Trenutni zapisi"
|
||||
}
|
||||
],
|
||||
"label.refresh": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Osvježi"
|
||||
}
|
||||
],
|
||||
"label.required": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Potrebna"
|
||||
}
|
||||
],
|
||||
"label.reset": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Resetirati"
|
||||
}
|
||||
],
|
||||
"label.reset-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Resetirati web stranicu"
|
||||
}
|
||||
],
|
||||
"label.save": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Spremi"
|
||||
}
|
||||
],
|
||||
"label.search": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Pretraži"
|
||||
}
|
||||
],
|
||||
"label.settings": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Postavke"
|
||||
}
|
||||
],
|
||||
"label.share-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Podijeli URL"
|
||||
}
|
||||
],
|
||||
"label.single-day": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jedan dan"
|
||||
}
|
||||
],
|
||||
"label.theme": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Tema"
|
||||
}
|
||||
],
|
||||
"label.this-month": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ovaj mjesec"
|
||||
}
|
||||
],
|
||||
"label.this-week": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ovaj tjedan"
|
||||
}
|
||||
],
|
||||
"label.this-year": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ova godina"
|
||||
}
|
||||
],
|
||||
"label.timezone": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Vremenska zona"
|
||||
}
|
||||
],
|
||||
"label.today": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Danas"
|
||||
}
|
||||
],
|
||||
"label.tracking-code": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Kod za praćenje"
|
||||
}
|
||||
],
|
||||
"label.type": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Tip"
|
||||
}
|
||||
],
|
||||
"label.unknown": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nepoznato"
|
||||
}
|
||||
],
|
||||
"label.username": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Korisničko ime"
|
||||
}
|
||||
],
|
||||
"label.value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Vrijednost"
|
||||
}
|
||||
],
|
||||
"label.view-details": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Pogledaj detalje"
|
||||
}
|
||||
],
|
||||
"label.websites": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Web stranice"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jučer"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
"value": "x"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " Trenutno "
|
||||
},
|
||||
{
|
||||
"offset": 0,
|
||||
"options": {
|
||||
"one": {
|
||||
"value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "visitor"
|
||||
}
|
||||
]
|
||||
},
|
||||
"other": {
|
||||
"value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "visitors"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"pluralType": "cardinal",
|
||||
"type": 6,
|
||||
"value": "x"
|
||||
}
|
||||
],
|
||||
"message.confirm-delete": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jeste li sigurni da želite obrisati "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "target"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": "?"
|
||||
}
|
||||
],
|
||||
"message.confirm-reset": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jeste li sigurni da želite resetirati "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "target"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": "'s statistiku?"
|
||||
}
|
||||
],
|
||||
"message.copied": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Kopirano!"
|
||||
}
|
||||
],
|
||||
"message.delete-warning": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Izbrisat će se svi povezani podaci."
|
||||
}
|
||||
],
|
||||
"message.edit-dashboard": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uredi nadzornu ploču"
|
||||
}
|
||||
],
|
||||
"message.failure": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nešto je pošlo po zlu."
|
||||
}
|
||||
],
|
||||
"message.get-share-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dohvati URL za dijeljenje"
|
||||
}
|
||||
],
|
||||
"message.get-tracking-code": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dohvati kod za praćenje"
|
||||
}
|
||||
],
|
||||
"message.go-to-settings": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Idi u postavke"
|
||||
}
|
||||
],
|
||||
"message.incorrect-username-password": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Neispravno korisničke ime/lozinka."
|
||||
}
|
||||
],
|
||||
"message.log.visitor": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Posjetitelj iz "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "country"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " koristi "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "browser"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " na "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "os"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "device"
|
||||
}
|
||||
],
|
||||
"message.new-version-available": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nova verzija umami "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "version"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " je dostupna!"
|
||||
}
|
||||
],
|
||||
"message.no-data-available": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nema dostupnih podataka."
|
||||
}
|
||||
],
|
||||
"message.no-websites-configured": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nemate konfiguriranu nijednu web stranicu."
|
||||
}
|
||||
],
|
||||
"message.page-not-found": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Stranica nije pronađena."
|
||||
}
|
||||
],
|
||||
"message.powered-by": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Powered by "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"message.reset-warning": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Sve statistike za ovu web stranicu bit će izbrisane, ali će vaš kod za praćenje ostati netaknut."
|
||||
}
|
||||
],
|
||||
"message.save-success": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uspješno spremljeno."
|
||||
}
|
||||
],
|
||||
"message.share-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ovo je javno dijeljeni URL za "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "target"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": "."
|
||||
}
|
||||
],
|
||||
"message.toggle-charts": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uključi/isključi grafikone"
|
||||
}
|
||||
],
|
||||
"message.track-stats": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Da biste pratili statistiku za "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "target"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": ", postavite sljedeći kod u odjeljak "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "head"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " svoje web stranice."
|
||||
}
|
||||
],
|
||||
"message.type-delete": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Upišite "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "delete"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " u donji okvir za potvrdu."
|
||||
}
|
||||
],
|
||||
"message.type-reset": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": " Upišite "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"value": "reset"
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " u donji okvir za potvrdu. "
|
||||
}
|
||||
],
|
||||
"metrics.actions": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Akcije"
|
||||
}
|
||||
],
|
||||
"metrics.average-visit-time": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Prosječno vrijeme posjeta"
|
||||
}
|
||||
],
|
||||
"metrics.bounce-rate": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Stopa napuštanja stranice"
|
||||
}
|
||||
],
|
||||
"metrics.browsers": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Web preglednici"
|
||||
}
|
||||
],
|
||||
"metrics.countries": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Zemlje"
|
||||
}
|
||||
],
|
||||
"metrics.device.desktop": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Pc"
|
||||
}
|
||||
],
|
||||
"metrics.device.laptop": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Laptop"
|
||||
}
|
||||
],
|
||||
"metrics.device.mobile": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Mobitel"
|
||||
}
|
||||
],
|
||||
"metrics.device.tablet": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Tablet"
|
||||
}
|
||||
],
|
||||
"metrics.devices": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Uređaji"
|
||||
}
|
||||
],
|
||||
"metrics.events": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Događaji"
|
||||
}
|
||||
],
|
||||
"metrics.filter.combined": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Kombinirano"
|
||||
}
|
||||
],
|
||||
"metrics.filter.raw": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Neobrađeni podaci"
|
||||
}
|
||||
],
|
||||
"metrics.languages": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jezici"
|
||||
}
|
||||
],
|
||||
"metrics.operating-systems": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Operativni sustavi"
|
||||
}
|
||||
],
|
||||
"metrics.page-views": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Pregledi stranice"
|
||||
}
|
||||
],
|
||||
"metrics.pages": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Stranice"
|
||||
}
|
||||
],
|
||||
"metrics.query-parameters": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Parametri upita"
|
||||
}
|
||||
],
|
||||
"metrics.referrers": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Upučivaći"
|
||||
}
|
||||
],
|
||||
"metrics.screens": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Zasloni"
|
||||
}
|
||||
],
|
||||
"metrics.unique-visitors": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Jedinstveni posjetitelji"
|
||||
}
|
||||
],
|
||||
"metrics.views": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Pregledi"
|
||||
}
|
||||
],
|
||||
"metrics.visitors": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Posjetitelji"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@
|
|||
"value": "Dodaj konto"
|
||||
}
|
||||
],
|
||||
"label.add-column": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj kolumnę"
|
||||
}
|
||||
],
|
||||
"label.add-filter": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dodaj filtr"
|
||||
}
|
||||
],
|
||||
"label.add-website": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -155,6 +167,18 @@
|
|||
"value": "Włącz udostępnianie adresu URL"
|
||||
}
|
||||
],
|
||||
"label.event-data": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Dane zdarzenia"
|
||||
}
|
||||
],
|
||||
"label.field-name": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nazwa pola"
|
||||
}
|
||||
],
|
||||
"label.invalid": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -244,7 +268,7 @@
|
|||
"label.none": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "None"
|
||||
"value": "Brak"
|
||||
}
|
||||
],
|
||||
"label.owner": [
|
||||
|
|
@ -313,6 +337,12 @@
|
|||
"value": "Zapisz"
|
||||
}
|
||||
],
|
||||
"label.search": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Szukaj"
|
||||
}
|
||||
],
|
||||
"label.settings": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -373,6 +403,12 @@
|
|||
"value": "Kod śledzenia"
|
||||
}
|
||||
],
|
||||
"label.type": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Typ"
|
||||
}
|
||||
],
|
||||
"label.unknown": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -385,6 +421,12 @@
|
|||
"value": "Nazwa użytkownika"
|
||||
}
|
||||
],
|
||||
"label.value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Wartość"
|
||||
}
|
||||
],
|
||||
"label.view-details": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -397,6 +439,12 @@
|
|||
"value": "Witryny"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Wczoraj"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
|
@ -474,7 +522,7 @@
|
|||
"message.edit-dashboard": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Edit dashboard"
|
||||
"value": "Edytuj panel"
|
||||
}
|
||||
],
|
||||
"message.failure": [
|
||||
|
|
@ -770,7 +818,7 @@
|
|||
"metrics.query-parameters": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Query parameters"
|
||||
"value": "Parametry query"
|
||||
}
|
||||
],
|
||||
"metrics.referrers": [
|
||||
|
|
|
|||
|
|
@ -397,6 +397,12 @@
|
|||
"value": "网站"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "昨天"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
if (redis.enabled) {
|
||||
for (let i = 0; i < websiteUuids.length; i++) {
|
||||
await redis.client.set(`website:${websiteUuids[i]}`, DELETED);
|
||||
await redis.set(`website:${websiteUuids[i]}`, DELETED);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import prisma from 'lib/prisma';
|
||||
|
||||
export async function getAccountById(user_id) {
|
||||
export async function getAccount(where) {
|
||||
return prisma.client.account.findUnique({
|
||||
where: {
|
||||
user_id,
|
||||
},
|
||||
where,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue