mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-15 09:45:04 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-171-cloud-mode-env-variable
This commit is contained in:
commit
a777b2916f
@ -6,13 +6,13 @@ import { Icon, Icons } from 'react-basics';
|
|||||||
import styles from './FilterLink.module.css';
|
import styles from './FilterLink.module.css';
|
||||||
|
|
||||||
export default function FilterLink({ id, value, label, externalUrl }) {
|
export default function FilterLink({ id, value, label, externalUrl }) {
|
||||||
const { resolve, query } = usePageQuery();
|
const { resolveUrl, query } = usePageQuery();
|
||||||
const active = query[id] !== undefined;
|
const active = query[id] !== undefined;
|
||||||
const selected = query[id] === value;
|
const selected = query[id] === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Link href={resolve({ [id]: value })} replace>
|
<Link href={resolveUrl({ [id]: value })} replace>
|
||||||
<a
|
<a
|
||||||
className={classNames(styles.label, {
|
className={classNames(styles.label, {
|
||||||
[styles.inactive]: active && !selected,
|
[styles.inactive]: active && !selected,
|
||||||
|
@ -1,60 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import ReactTooltip from 'react-tooltip';
|
|
||||||
|
|
||||||
import styles from './OverflowText.module.css';
|
|
||||||
|
|
||||||
const OverflowText = ({ children, tooltipId }) => {
|
|
||||||
const measureEl = useRef();
|
|
||||||
const [isOverflown, setIsOverflown] = useState(false);
|
|
||||||
|
|
||||||
const measure = useCallback(
|
|
||||||
el => {
|
|
||||||
if (!el) return;
|
|
||||||
setIsOverflown(el.scrollWidth > el.clientWidth);
|
|
||||||
},
|
|
||||||
[setIsOverflown],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Do one measure on mount
|
|
||||||
useEffect(() => {
|
|
||||||
measure(measureEl.current);
|
|
||||||
}, [measure]);
|
|
||||||
|
|
||||||
// Set up resize listener for subsequent measures
|
|
||||||
useEffect(() => {
|
|
||||||
if (!measureEl.current) return;
|
|
||||||
|
|
||||||
// Destructure ref in case it changes out from under us
|
|
||||||
const el = measureEl.current;
|
|
||||||
|
|
||||||
if ('ResizeObserver' in global) {
|
|
||||||
// Ideally, we have access to ResizeObservers
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
measure(el);
|
|
||||||
});
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.unobserve(el);
|
|
||||||
} else {
|
|
||||||
// Otherwise, fall back to measuring on window resizes
|
|
||||||
const handler = () => measure(el);
|
|
||||||
|
|
||||||
window.addEventListener('resize', handler, { passive: true });
|
|
||||||
return () => window.removeEventListener('resize', handler, { passive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={measureEl}
|
|
||||||
data-tip={children.toString()}
|
|
||||||
data-effect="solid"
|
|
||||||
data-for={tooltipId}
|
|
||||||
className={styles.root}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{isOverflown && <ReactTooltip id={tooltipId}>{children}</ReactTooltip>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OverflowText;
|
|
@ -1,6 +0,0 @@
|
|||||||
.root {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
@ -4,6 +4,7 @@ import Bolt from 'assets/bolt.svg';
|
|||||||
import Calendar from 'assets/calendar.svg';
|
import Calendar from 'assets/calendar.svg';
|
||||||
import Clock from 'assets/clock.svg';
|
import Clock from 'assets/clock.svg';
|
||||||
import Dashboard from 'assets/dashboard.svg';
|
import Dashboard from 'assets/dashboard.svg';
|
||||||
|
import Eye from 'assets/eye.svg';
|
||||||
import Gear from 'assets/gear.svg';
|
import Gear from 'assets/gear.svg';
|
||||||
import Globe from 'assets/globe.svg';
|
import Globe from 'assets/globe.svg';
|
||||||
import Lock from 'assets/lock.svg';
|
import Lock from 'assets/lock.svg';
|
||||||
@ -13,6 +14,7 @@ import Profile from 'assets/profile.svg';
|
|||||||
import Sun from 'assets/sun.svg';
|
import Sun from 'assets/sun.svg';
|
||||||
import User from 'assets/user.svg';
|
import User from 'assets/user.svg';
|
||||||
import Users from 'assets/users.svg';
|
import Users from 'assets/users.svg';
|
||||||
|
import Visitor from 'assets/visitor.svg';
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
...Icons,
|
...Icons,
|
||||||
@ -21,6 +23,7 @@ const icons = {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
|
Eye,
|
||||||
Gear,
|
Gear,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
@ -30,6 +33,7 @@ const icons = {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
Visitor,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default icons;
|
export default icons;
|
||||||
|
@ -1,68 +1,74 @@
|
|||||||
import { endOfYear, isSameDay } from 'date-fns';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Icon, Modal, Dropdown, Item } from 'react-basics';
|
import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { endOfYear, isSameDay } from 'date-fns';
|
||||||
import DatePickerForm from 'components/metrics/DatePickerForm';
|
import DatePickerForm from 'components/metrics/DatePickerForm';
|
||||||
import useLocale from 'hooks/useLocale';
|
import useLocale from 'hooks/useLocale';
|
||||||
import { dateFormat } from 'lib/date';
|
import { dateFormat, getDateRangeValues } from 'lib/date';
|
||||||
import Calendar from 'assets/calendar.svg';
|
import Icons from 'components/icons';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
import useDateRange from 'hooks/useDateRange';
|
||||||
|
|
||||||
const messages = defineMessages({
|
function DateFilter({ websiteId, value, className }) {
|
||||||
today: { id: 'label.today', defaultMessage: 'Today' },
|
|
||||||
lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
|
|
||||||
yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
|
|
||||||
thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
|
|
||||||
lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
|
|
||||||
thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
|
|
||||||
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
|
|
||||||
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
|
|
||||||
customRange: { id: 'label.custom-range', defaultMessage: 'Custom-range' },
|
|
||||||
});
|
|
||||||
|
|
||||||
function DateFilter({ value, startDate, endDate, onChange, className }) {
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const { get } = useApi();
|
||||||
|
const [dateRange, setDateRange] = useDateRange(websiteId);
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
|
||||||
|
async function handleDateChange(value) {
|
||||||
|
if (value === 'all') {
|
||||||
|
const data = await get(`/websites/${websiteId}`);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDateRange(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ label: formatMessage(messages.today), value: '1day' },
|
{ label: formatMessage(labels.today), value: '1day' },
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.lastHours, { x: 24 }),
|
label: formatMessage(labels.lastHours, { x: 24 }),
|
||||||
value: '24hour',
|
value: '24hour',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.yesterday),
|
label: formatMessage(labels.yesterday),
|
||||||
value: '-1day',
|
value: '-1day',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.thisWeek),
|
label: formatMessage(labels.thisWeek),
|
||||||
value: '1week',
|
value: '1week',
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.lastDays, { x: 7 }),
|
label: formatMessage(labels.lastDays, { x: 7 }),
|
||||||
value: '7day',
|
value: '7day',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.thisMonth),
|
label: formatMessage(labels.thisMonth),
|
||||||
value: '1month',
|
value: '1month',
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.lastDays, { x: 30 }),
|
label: formatMessage(labels.lastDays, { x: 30 }),
|
||||||
value: '30day',
|
value: '30day',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.lastDays, { x: 90 }),
|
label: formatMessage(labels.lastDays, { x: 90 }),
|
||||||
value: '90day',
|
value: '90day',
|
||||||
},
|
},
|
||||||
{ label: formatMessage(messages.thisYear), value: '1year' },
|
{ label: formatMessage(labels.thisYear), value: '1year' },
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.allTime),
|
label: formatMessage(labels.allTime),
|
||||||
value: 'all',
|
value: 'all',
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.customRange),
|
label: formatMessage(labels.customRange),
|
||||||
value: 'custom',
|
value: 'custom',
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
@ -76,17 +82,17 @@ function DateFilter({ value, startDate, endDate, onChange, className }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = async value => {
|
const handleChange = value => {
|
||||||
if (value === 'custom') {
|
if (value === 'custom') {
|
||||||
setShowPicker(true);
|
setShowPicker(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onChange(value);
|
handleDateChange(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePickerChange = value => {
|
const handlePickerChange = value => {
|
||||||
setShowPicker(false);
|
setShowPicker(false);
|
||||||
onChange(value);
|
handleDateChange(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => setShowPicker(false);
|
const handleClose = () => setShowPicker(false);
|
||||||
@ -98,9 +104,14 @@ function DateFilter({ value, startDate, endDate, onChange, className }) {
|
|||||||
items={options}
|
items={options}
|
||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
value={value}
|
value={value}
|
||||||
|
alignment="end"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
{({ label, value }) => <Item key={value}>{label}</Item>}
|
{({ label, value, divider }) => (
|
||||||
|
<Item key={value} divider={divider}>
|
||||||
|
{label}
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<Modal onClose={handleClose}>
|
<Modal onClose={handleClose}>
|
||||||
@ -128,13 +139,15 @@ const CustomRange = ({ startDate, endDate, onClick }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Flexbox gap={10} alignItems="center" wrap="nowrap">
|
||||||
<Icon className="mr-2" onClick={handleClick}>
|
<Icon className="mr-2" onClick={handleClick}>
|
||||||
<Calendar />
|
<Icons.Calendar />
|
||||||
</Icon>
|
</Icon>
|
||||||
{dateFormat(startDate, 'd LLL y', locale)}
|
<Text>
|
||||||
{!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`}
|
{dateFormat(startDate, 'd LLL y', locale)}
|
||||||
</>
|
{!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`}
|
||||||
|
</Text>
|
||||||
|
</Flexbox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,22 +1,16 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Button, Icon, Tooltip } from 'react-basics';
|
import { LoadingButton, Icon, Tooltip } from 'react-basics';
|
||||||
import useStore from 'store/queries';
|
|
||||||
import { setDateRange } from 'store/websites';
|
import { setDateRange } from 'store/websites';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import Icons from 'components/icons';
|
import Icons from 'components/icons';
|
||||||
import { labels } from 'components/messages';
|
import { labels } from 'components/messages';
|
||||||
|
|
||||||
function RefreshButton({ websiteId }) {
|
function RefreshButton({ websiteId, isLoading }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [dateRange] = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const selector = useCallback(state => state[`/websites/${websiteId}/stats`], [websiteId]);
|
|
||||||
const completed = useStore(selector);
|
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (!loading && dateRange) {
|
if (!isLoading && dateRange) {
|
||||||
setLoading(true);
|
|
||||||
if (/^\d+/.test(dateRange.value)) {
|
if (/^\d+/.test(dateRange.value)) {
|
||||||
setDateRange(websiteId, dateRange.value);
|
setDateRange(websiteId, dateRange.value);
|
||||||
} else {
|
} else {
|
||||||
@ -25,17 +19,13 @@ function RefreshButton({ websiteId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(false);
|
|
||||||
}, [completed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={formatMessage(labels.refresh)}>
|
<Tooltip label={formatMessage(labels.refresh)}>
|
||||||
<Button onClick={handleClick}>
|
<LoadingButton loading={isLoading} onClick={handleClick}>
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Refresh />
|
<Icons.Refresh />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Button>
|
</LoadingButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
28
components/input/WebsiteSelect.js
Normal file
28
components/input/WebsiteSelect.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { Dropdown, Item } from 'react-basics';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
|
||||||
|
export default function WebsiteSelect({ websiteId, onSelect }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { get, useQuery } = useApi();
|
||||||
|
const { data } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||||
|
|
||||||
|
const renderValue = value => {
|
||||||
|
return data?.find(({ id }) => id === value)?.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
items={data}
|
||||||
|
value={websiteId}
|
||||||
|
renderValue={renderValue}
|
||||||
|
onChange={onSelect}
|
||||||
|
alignment="end"
|
||||||
|
placeholder={formatMessage(labels.selectWebsite)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
>
|
||||||
|
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
@ -2,13 +2,15 @@ import { Container } from 'react-basics';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import NavBar from 'components/layout/NavBar';
|
import NavBar from 'components/layout/NavBar';
|
||||||
import useRequireLogin from 'hooks/useRequireLogin';
|
import useRequireLogin from 'hooks/useRequireLogin';
|
||||||
|
import useConfig from 'hooks/useConfig';
|
||||||
import { UI_LAYOUT_BODY } from 'lib/constants';
|
import { UI_LAYOUT_BODY } from 'lib/constants';
|
||||||
import styles from './AppLayout.module.css';
|
import styles from './AppLayout.module.css';
|
||||||
|
|
||||||
export default function AppLayout({ title, children }) {
|
export default function AppLayout({ title, children }) {
|
||||||
const { user } = useRequireLogin();
|
const { user } = useRequireLogin();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
if (!user) {
|
if (!user || !config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
components/layout/Grid.js
Normal file
13
components/layout/Grid.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Row, Column } from 'react-basics';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import styles from './Grid.module.css';
|
||||||
|
|
||||||
|
export function GridRow(props) {
|
||||||
|
const { className, ...otherProps } = props;
|
||||||
|
return <Row {...otherProps} className={classNames(styles.row, className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridColumn(props) {
|
||||||
|
const { className, ...otherProps } = props;
|
||||||
|
return <Column {...otherProps} className={classNames(styles.col, className)} />;
|
||||||
|
}
|
35
components/layout/Grid.module.css
Normal file
35
components/layout/Grid.module.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
border-top: 1px solid var(--base300);
|
||||||
|
min-height: 430px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > .col {
|
||||||
|
border-left: 1px solid var(--base300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > .col:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > .col:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 992px) {
|
||||||
|
.row {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > .col {
|
||||||
|
border-top: 1px solid var(--base300);
|
||||||
|
border-left: 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
@ -10,9 +10,11 @@ import { labels } from 'components/messages';
|
|||||||
import useUser from 'hooks/useUser';
|
import useUser from 'hooks/useUser';
|
||||||
import NavGroup from './NavGroup';
|
import NavGroup from './NavGroup';
|
||||||
import styles from './NavBar.module.css';
|
import styles from './NavBar.module.css';
|
||||||
|
import useConfig from 'hooks/useConfig';
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { cloudMode } = useConfig();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [minimized, setMinimized] = useState(false);
|
const [minimized, setMinimized] = useState(false);
|
||||||
const tooltipPosition = minimized ? 'right' : 'top';
|
const tooltipPosition = minimized ? 'right' : 'top';
|
||||||
@ -24,13 +26,21 @@ export default function NavBar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const settings = [
|
const settings = [
|
||||||
{ label: formatMessage(labels.websites), url: '/settings/websites', icon: <Icons.Globe /> },
|
!cloudMode && {
|
||||||
|
label: formatMessage(labels.websites),
|
||||||
|
url: '/settings/websites',
|
||||||
|
icon: <Icons.Globe />,
|
||||||
|
},
|
||||||
user?.isAdmin && {
|
user?.isAdmin && {
|
||||||
label: formatMessage(labels.users),
|
label: formatMessage(labels.users),
|
||||||
url: '/settings/users',
|
url: '/settings/users',
|
||||||
icon: <Icons.User />,
|
icon: <Icons.User />,
|
||||||
},
|
},
|
||||||
{ label: formatMessage(labels.teams), url: '/settings/teams', icon: <Icons.Users /> },
|
!cloudMode && {
|
||||||
|
label: formatMessage(labels.teams),
|
||||||
|
url: '/settings/teams',
|
||||||
|
icon: <Icons.Users />,
|
||||||
|
},
|
||||||
{ label: formatMessage(labels.profile), url: '/settings/profile', icon: <Icons.Profile /> },
|
{ label: formatMessage(labels.profile), url: '/settings/profile', icon: <Icons.Profile /> },
|
||||||
].filter(n => n);
|
].filter(n => n);
|
||||||
|
|
||||||
@ -53,7 +63,7 @@ export default function NavBar() {
|
|||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
<ThemeButton tooltipPosition={tooltipPosition} />
|
<ThemeButton tooltipPosition={tooltipPosition} />
|
||||||
<LanguageButton tooltipPosition={tooltipPosition} />
|
<LanguageButton tooltipPosition={tooltipPosition} />
|
||||||
<LogoutButton tooltipPosition={tooltipPosition} />
|
{!cloudMode && <LogoutButton tooltipPosition={tooltipPosition} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
.title {
|
.title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
@ -81,9 +81,23 @@ export const labels = defineMessages({
|
|||||||
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
|
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
|
||||||
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
|
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
|
||||||
filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
|
filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
|
||||||
views: { id: 'label.views', defaultMessage: 'View' },
|
views: { id: 'label.views', defaultMessage: 'Views' },
|
||||||
none: { id: 'label.none', defaultMessage: 'None' },
|
none: { id: 'label.none', defaultMessage: 'None' },
|
||||||
clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
|
clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
|
||||||
|
today: { id: 'label.today', defaultMessage: 'Today' },
|
||||||
|
lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
|
||||||
|
yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
|
||||||
|
thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
|
||||||
|
lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
|
||||||
|
thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
|
||||||
|
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
|
||||||
|
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
|
||||||
|
customRange: { id: 'label.custom-range', defaultMessage: 'Custom-range' },
|
||||||
|
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
||||||
|
all: { id: 'label.all', defaultMessage: 'All' },
|
||||||
|
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
||||||
|
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
|
||||||
|
logs: { id: 'label.activity-log', defaultMessage: 'Activity log' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
@ -155,6 +169,14 @@ export const messages = defineMessages({
|
|||||||
id: 'message.team-not-found',
|
id: 'message.team-not-found',
|
||||||
defaultMessage: 'Team not found.',
|
defaultMessage: 'Team not found.',
|
||||||
},
|
},
|
||||||
|
visitorLog: {
|
||||||
|
id: 'message.visitor-log',
|
||||||
|
defaultMessage: 'Visitor from {country} using {browser} on {os} {device}',
|
||||||
|
},
|
||||||
|
eventLog: {
|
||||||
|
id: 'message.event-log',
|
||||||
|
defaultMessage: '{event} on {url}',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const devices = defineMessages({
|
export const devices = defineMessages({
|
||||||
|
@ -13,6 +13,7 @@ export default function ActiveUsers({ websiteId, value, refetchInterval = 60000
|
|||||||
() => get(`/websites/${websiteId}/active`),
|
() => get(`/websites/${websiteId}/active`),
|
||||||
{
|
{
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
|
enabled: !!websiteId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import { formatNumber, formatLongNumber } from 'lib/format';
|
|||||||
import styles from './DataTable.module.css';
|
import styles from './DataTable.module.css';
|
||||||
|
|
||||||
export default function DataTable({
|
export default function DataTable({
|
||||||
data,
|
data = [],
|
||||||
title,
|
title,
|
||||||
metric,
|
metric,
|
||||||
className,
|
className,
|
||||||
|
@ -42,7 +42,7 @@ export default function DatePickerForm({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.filter}>
|
<div className={styles.filter}>
|
||||||
<ButtonGroup size="sm" selectedKey={selected} onSelect={setSelected}>
|
<ButtonGroup selectedKey={selected} onSelect={setSelected}>
|
||||||
<Button key={FILTER_DAY}>{formatMessage(labels.singleDay)}</Button>
|
<Button key={FILTER_DAY}>{formatMessage(labels.singleDay)}</Button>
|
||||||
<Button key={FILTER_RANGE}>{formatMessage(labels.dateRange)}</Button>
|
<Button key={FILTER_RANGE}>{formatMessage(labels.dateRange)}</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import DateFilter from 'components/common/DateFilter';
|
import DateFilter from 'components/input/DateFilter';
|
||||||
import DataTable from 'components/metrics/DataTable';
|
import DataTable from 'components/metrics/DataTable';
|
||||||
import FilterTags from 'components/metrics/FilterTags';
|
import FilterTags from 'components/metrics/FilterTags';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
|
@ -1,26 +1,39 @@
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { safeDecodeURI } from 'next-basics';
|
import { safeDecodeURI } from 'next-basics';
|
||||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||||
import { labels } from 'components/messages';
|
import { labels } from 'components/messages';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import styles from './FilterTags.module.css';
|
import styles from './FilterTags.module.css';
|
||||||
|
|
||||||
export default function FilterTags({ className, params, onClick }) {
|
export default function FilterTags({ websiteId, params, onClick }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const {
|
||||||
|
router,
|
||||||
|
resolveUrl,
|
||||||
|
query: { view },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
if (Object.keys(params).filter(key => params[key]).length === 0) {
|
if (Object.keys(params).filter(key => params[key]).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCloseFilter(param) {
|
||||||
|
if (param === null) {
|
||||||
|
router.push(`/websites/${websiteId}/?view=${view}`);
|
||||||
|
} else {
|
||||||
|
router.push(resolveUrl({ [param]: undefined }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.filters, className)}>
|
<div className={styles.filters}>
|
||||||
{Object.keys(params).map(key => {
|
{Object.keys(params).map(key => {
|
||||||
if (!params[key]) {
|
if (!params[key]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={key} className={styles.tag}>
|
<div key={key} className={styles.tag}>
|
||||||
<Button onClick={() => onClick(key)} variant="primary" size="sm">
|
<Button onClick={() => handleCloseFilter(key)} variant="primary" size="sm">
|
||||||
<Text>
|
<Text>
|
||||||
<b>{`${key}`}</b> — {`${safeDecodeURI(params[key])}`}
|
<b>{`${key}`}</b> — {`${safeDecodeURI(params[key])}`}
|
||||||
</Text>
|
</Text>
|
||||||
@ -31,7 +44,7 @@ export default function FilterTags({ className, params, onClick }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button size="sm" variant="quiet" onClick={() => onClick(null)}>
|
<Button size="sm" variant="quiet" onClick={() => handleCloseFilter(null)}>
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Close />
|
<Icons.Close />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
@ -31,7 +31,7 @@ export default function MetricsTable({
|
|||||||
}) {
|
}) {
|
||||||
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
||||||
const {
|
const {
|
||||||
resolve,
|
resolveUrl,
|
||||||
router,
|
router,
|
||||||
query: { url, referrer, os, browser, device, country },
|
query: { url, referrer, os, browser, device, country },
|
||||||
} = usePageQuery();
|
} = usePageQuery();
|
||||||
@ -79,7 +79,7 @@ export default function MetricsTable({
|
|||||||
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{data && !error && limit && (
|
{data && !error && limit && (
|
||||||
<Link href={router.pathname} as={resolve({ view: type })}>
|
<Link href={router.pathname} as={resolveUrl({ view: type })}>
|
||||||
<a>
|
<a>
|
||||||
<Button variant="quiet">
|
<Button variant="quiet">
|
||||||
<Text>{formatMessage(messages.more)}</Text>
|
<Text>{formatMessage(messages.more)}</Text>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { format, parseISO, startOfMinute, subMinutes, isBefore } from 'date-fns';
|
import { format, startOfMinute, subMinutes, isBefore } from 'date-fns';
|
||||||
import PageviewsChart from './PageviewsChart';
|
import PageviewsChart from './PageviewsChart';
|
||||||
import { getDateArray } from 'lib/date';
|
import { getDateArray } from 'lib/date';
|
||||||
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from 'lib/constants';
|
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from 'lib/constants';
|
||||||
@ -8,13 +8,12 @@ function mapData(data) {
|
|||||||
let last = 0;
|
let last = 0;
|
||||||
const arr = [];
|
const arr = [];
|
||||||
|
|
||||||
data.reduce((obj, val) => {
|
data?.reduce((obj, { timestamp }) => {
|
||||||
const { createdAt } = val;
|
const t = startOfMinute(new Date(timestamp));
|
||||||
const t = startOfMinute(parseISO(createdAt));
|
|
||||||
if (t.getTime() > last) {
|
if (t.getTime() > last) {
|
||||||
obj = { t: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
|
obj = { t: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
|
||||||
arr.push(obj);
|
arr.push(obj);
|
||||||
last = t;
|
last = t.getTime();
|
||||||
} else {
|
} else {
|
||||||
obj.y += 1;
|
obj.y += 1;
|
||||||
}
|
}
|
||||||
@ -30,14 +29,15 @@ export default function RealtimeChart({ data, unit, ...props }) {
|
|||||||
const prevEndDate = useRef(endDate);
|
const prevEndDate = useRef(endDate);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (data) {
|
if (!data) {
|
||||||
return {
|
return { pageviews: [], sessions: [] };
|
||||||
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
|
|
||||||
sessions: getDateArray(mapData(data.sessions), startDate, endDate, unit),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { pageviews: [], sessions: [] };
|
|
||||||
}, [data]);
|
return {
|
||||||
|
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
|
||||||
|
sessions: getDateArray(mapData(data.visitors), startDate, endDate, unit),
|
||||||
|
};
|
||||||
|
}, [data, startDate, endDate, unit]);
|
||||||
|
|
||||||
// Don't animate the bars shifting over because it looks weird
|
// Don't animate the bars shifting over because it looks weird
|
||||||
const animationDuration = useMemo(() => {
|
const animationDuration = useMemo(() => {
|
||||||
@ -46,7 +46,7 @@ export default function RealtimeChart({ data, unit, ...props }) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return DEFAULT_ANIMATION_DURATION;
|
return DEFAULT_ANIMATION_DURATION;
|
||||||
}, [data]);
|
}, [data, endDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageviewsChart
|
<PageviewsChart
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { differenceInMinutes } from 'date-fns';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import ActiveUsers from './ActiveUsers';
|
|
||||||
import MetricCard from './MetricCard';
|
|
||||||
import styles from './RealtimeHeader.module.css';
|
|
||||||
|
|
||||||
export default function RealtimeHeader({ data, websiteId }) {
|
|
||||||
const { pageviews, sessions, events, countries } = data;
|
|
||||||
|
|
||||||
const count = useMemo(() => {
|
|
||||||
return sessions.filter(
|
|
||||||
({ createdAt }) => differenceInMinutes(new Date(), new Date(createdAt)) <= 5,
|
|
||||||
).length;
|
|
||||||
}, [sessions, websiteId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader>
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<ActiveUsers className={styles.active} value={count} />
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<div className={styles.metrics}>
|
|
||||||
<MetricCard
|
|
||||||
label={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
|
||||||
value={pageviews.length}
|
|
||||||
hideComparison
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
|
||||||
value={sessions.length}
|
|
||||||
hideComparison
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
|
||||||
value={events.length}
|
|
||||||
hideComparison
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
|
||||||
value={countries.length}
|
|
||||||
hideComparison
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
.metrics {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.active {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,182 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { StatusLight } from 'react-basics';
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import { FixedSizeList } from 'react-window';
|
|
||||||
import firstBy from 'thenby';
|
|
||||||
import { Icon } from 'react-basics';
|
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
|
||||||
import NoData from 'components/common/NoData';
|
|
||||||
import { getDeviceMessage, labels } from 'components/messages';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
import useCountryNames from 'hooks/useCountryNames';
|
|
||||||
import { BROWSERS } from 'lib/constants';
|
|
||||||
import Bolt from 'assets/bolt.svg';
|
|
||||||
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;
|
|
||||||
const TYPE_PAGEVIEW = 1;
|
|
||||||
const TYPE_SESSION = 2;
|
|
||||||
const TYPE_EVENT = 3;
|
|
||||||
|
|
||||||
const TYPE_ICONS = {
|
|
||||||
[TYPE_PAGEVIEW]: <Eye />,
|
|
||||||
[TYPE_SESSION]: <Visitor />,
|
|
||||||
[TYPE_EVENT]: <Bolt />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RealtimeLog({ data, websites, websiteId }) {
|
|
||||||
const intl = useIntl();
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const countryNames = useCountryNames(locale);
|
|
||||||
const [filter, setFilter] = useState(TYPE_ALL);
|
|
||||||
|
|
||||||
const logs = useMemo(() => {
|
|
||||||
const { pageviews, sessions, events } = data;
|
|
||||||
const logs = [...pageviews, ...sessions, ...events].sort(firstBy('createdAt', -1));
|
|
||||||
if (filter) {
|
|
||||||
return logs.filter(row => getType(row) === filter);
|
|
||||||
}
|
|
||||||
return logs;
|
|
||||||
}, [data, filter]);
|
|
||||||
|
|
||||||
const uuids = useMemo(() => {
|
|
||||||
return data.sessions.reduce((obj, { sessionId, sessionUuid }) => {
|
|
||||||
obj[sessionId] = sessionUuid;
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
label: <FormattedMessage id="label.all" defaultMessage="All" />,
|
|
||||||
key: TYPE_ALL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: <FormattedMessage id="metrics.views" defaultMessage="Views" />,
|
|
||||||
key: TYPE_PAGEVIEW,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: <FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />,
|
|
||||||
key: TYPE_SESSION,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
|
|
||||||
key: TYPE_EVENT,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getType({ pageviewId, sessionId, eventId }) {
|
|
||||||
if (eventId) {
|
|
||||||
return TYPE_EVENT;
|
|
||||||
}
|
|
||||||
if (pageviewId) {
|
|
||||||
return TYPE_PAGEVIEW;
|
|
||||||
}
|
|
||||||
if (sessionId) {
|
|
||||||
return TYPE_SESSION;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIcon(row) {
|
|
||||||
return TYPE_ICONS[getType(row)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWebsite({ websiteId }) {
|
|
||||||
return websites.find(n => n.id === websiteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDetail({
|
|
||||||
eventName,
|
|
||||||
pageviewId,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
browser,
|
|
||||||
os,
|
|
||||||
country,
|
|
||||||
device,
|
|
||||||
websiteId,
|
|
||||||
}) {
|
|
||||||
if (eventName) {
|
|
||||||
return <div>{eventName}</div>;
|
|
||||||
}
|
|
||||||
if (pageviewId) {
|
|
||||||
const domain = getWebsite({ websiteId })?.domain;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={styles.link}
|
|
||||||
href={`//${domain}${url}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
{safeDecodeURI(url)}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sessionId) {
|
|
||||||
return (
|
|
||||||
<FormattedMessage
|
|
||||||
id="message.log.visitor"
|
|
||||||
defaultMessage="Visitor from {country} using {browser} on {os} {device}"
|
|
||||||
values={{
|
|
||||||
country: <b>{countryNames[country] || intl.formatMessage(labels.unknown)}</b>,
|
|
||||||
browser: <b>{BROWSERS[browser]}</b>,
|
|
||||||
os: <b>{os}</b>,
|
|
||||||
device: <b>{intl.formatMessage(getDeviceMessage(device))}</b>,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTime({ createdAt }) {
|
|
||||||
return dateFormat(new Date(createdAt), 'pp', locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getColor(row) {
|
|
||||||
const { sessionId } = row;
|
|
||||||
|
|
||||||
return stringToColor(uuids[sessionId] || `${sessionId}${getWebsite(row)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Row = ({ index, style }) => {
|
|
||||||
const row = logs[index];
|
|
||||||
return (
|
|
||||||
<div className={styles.row} style={style}>
|
|
||||||
<div>
|
|
||||||
<StatusLight color={getColor(row)} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.time}>{getTime(row)}</div>
|
|
||||||
<div className={styles.detail}>
|
|
||||||
<Icon className={styles.icon} icon={getIcon(row)} />
|
|
||||||
{getDetail(row)}
|
|
||||||
</div>
|
|
||||||
{!websiteId && websites.length > 1 && (
|
|
||||||
<div className={styles.website}>{getWebsite(row)?.domain}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.table}>
|
|
||||||
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
|
||||||
<div className={styles.header}>
|
|
||||||
<FormattedMessage id="label.realtime-logs" defaultMessage="Realtime logs" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.body}>
|
|
||||||
{logs?.length === 0 && <NoData />}
|
|
||||||
{logs?.length > 0 && (
|
|
||||||
<FixedSizeList height={400} itemCount={logs.length} itemSize={40}>
|
|
||||||
{Row}
|
|
||||||
</FixedSizeList>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,14 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Button, Icon, Text, Row, Column, Container } from 'react-basics';
|
import { Button, Icon, Text, Row, Column, Flexbox } from 'react-basics';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PageviewsChart from './PageviewsChart';
|
import PageviewsChart from './PageviewsChart';
|
||||||
import MetricsBar from './MetricsBar';
|
import MetricsBar from './MetricsBar';
|
||||||
import WebsiteHeader from './WebsiteHeader';
|
import WebsiteHeader from './WebsiteHeader';
|
||||||
import DateFilter from 'components/common/DateFilter';
|
import DateFilter from 'components/input/DateFilter';
|
||||||
import StickyHeader from 'components/helpers/StickyHeader';
|
import StickyHeader from 'components/helpers/StickyHeader';
|
||||||
import ErrorMessage from 'components/common/ErrorMessage';
|
import ErrorMessage from 'components/common/ErrorMessage';
|
||||||
import FilterTags from 'components/metrics/FilterTags';
|
import FilterTags from 'components/metrics/FilterTags';
|
||||||
|
import RefreshButton from 'components/input/RefreshButton';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import useTimezone from 'hooks/useTimezone';
|
import useTimezone from 'hooks/useTimezone';
|
||||||
@ -28,13 +29,11 @@ export default function WebsiteChart({
|
|||||||
onDataLoad = () => {},
|
onDataLoad = () => {},
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [dateRange, setDateRange] = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, unit, value, modified } = dateRange;
|
const { startDate, endDate, unit, value, modified } = dateRange;
|
||||||
const [timezone] = useTimezone();
|
const [timezone] = useTimezone();
|
||||||
const {
|
const {
|
||||||
router,
|
query: { url, referrer, os, browser, device, country },
|
||||||
resolve,
|
|
||||||
query: { view, url, referrer, os, browser, device, country },
|
|
||||||
} = usePageQuery();
|
} = usePageQuery();
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
|
|
||||||
@ -66,26 +65,6 @@ export default function WebsiteChart({
|
|||||||
return { pageviews: [], sessions: [] };
|
return { pageviews: [], sessions: [] };
|
||||||
}, [data, startDate, endDate, unit]);
|
}, [data, startDate, endDate, unit]);
|
||||||
|
|
||||||
function handleCloseFilter(param) {
|
|
||||||
if (param === null) {
|
|
||||||
router.push(`/websites/${websiteId}/?view=${view}`);
|
|
||||||
} else {
|
|
||||||
router.push(resolve({ [param]: undefined }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDateChange(value) {
|
|
||||||
if (value === 'all') {
|
|
||||||
const data = await get(`/websites/${websiteId}`);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDateRange(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
|
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
|
||||||
@ -102,22 +81,15 @@ export default function WebsiteChart({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</WebsiteHeader>
|
</WebsiteHeader>
|
||||||
<FilterTags
|
<FilterTags websiteId={websiteId} params={{ url, referrer, os, browser, device, country }} />
|
||||||
params={{ url, referrer, os, browser, device, country }}
|
|
||||||
onClick={handleCloseFilter}
|
|
||||||
/>
|
|
||||||
<StickyHeader stickyClassName={styles.sticky} enabled={stickyHeader}>
|
<StickyHeader stickyClassName={styles.sticky} enabled={stickyHeader}>
|
||||||
<Row className={styles.header}>
|
<Row className={styles.header}>
|
||||||
<Column xs={12} sm={12} md={12} defaultSize={10}>
|
<Column>
|
||||||
<MetricsBar websiteId={websiteId} />
|
<MetricsBar websiteId={websiteId} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column className={styles.filter} xs={12} sm={12} md={12} defaultSize={2}>
|
<Column className={styles.actions}>
|
||||||
<DateFilter
|
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
|
||||||
value={value}
|
<DateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onChange={handleDateChange}
|
|
||||||
/>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
</StickyHeader>
|
</StickyHeader>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@ -32,9 +32,17 @@
|
|||||||
border-bottom: 1px solid var(--base300);
|
border-bottom: 1px solid var(--base300);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: inherit;
|
width: inherit;
|
||||||
padding-top: 20px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter {
|
.actions {
|
||||||
align-self: center;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Row, Column } from 'react-basics';
|
import { Row, Column, Text } from 'react-basics';
|
||||||
import Favicon from 'components/common/Favicon';
|
import Favicon from 'components/common/Favicon';
|
||||||
import OverflowText from 'components/common/OverflowText';
|
|
||||||
import ActiveUsers from './ActiveUsers';
|
import ActiveUsers from './ActiveUsers';
|
||||||
import styles from './WebsiteHeader.module.css';
|
import styles from './WebsiteHeader.module.css';
|
||||||
|
|
||||||
@ -9,7 +8,7 @@ export default function WebsiteHeader({ websiteId, title, domain, children }) {
|
|||||||
<Row className={styles.header} justifyContent="center">
|
<Row className={styles.header} justifyContent="center">
|
||||||
<Column className={styles.title} variant="two">
|
<Column className={styles.title} variant="two">
|
||||||
<Favicon domain={domain} />
|
<Favicon domain={domain} />
|
||||||
<OverflowText tooltipId={`${websiteId}-title`}>{title}</OverflowText>
|
<Text>{title}</Text>
|
||||||
</Column>
|
</Column>
|
||||||
<Column className={styles.body} variant="two">
|
<Column className={styles.body} variant="two">
|
||||||
<ActiveUsers websiteId={websiteId} />
|
<ActiveUsers websiteId={websiteId} />
|
||||||
|
@ -6,12 +6,13 @@ import Page from 'components/layout/Page';
|
|||||||
import PageHeader from 'components/layout/PageHeader';
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
import EventsChart from 'components/metrics/EventsChart';
|
import EventsChart from 'components/metrics/EventsChart';
|
||||||
import WebsiteChart from 'components/metrics/WebsiteChart';
|
import WebsiteChart from 'components/metrics/WebsiteChart';
|
||||||
|
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import styles from './TestConsole.module.css';
|
import styles from './TestConsole.module.css';
|
||||||
|
|
||||||
export default function TestConsole() {
|
export default function TestConsole() {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error } = useQuery(['websites:test-console'], () => get('/websites'));
|
const { data, isLoading, error } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
basePath,
|
basePath,
|
||||||
@ -50,15 +51,7 @@ export default function TestConsole() {
|
|||||||
)}
|
)}
|
||||||
</Head>
|
</Head>
|
||||||
<PageHeader title="Test console">
|
<PageHeader title="Test console">
|
||||||
<Dropdown
|
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
|
||||||
items={data}
|
|
||||||
renderValue={() => website?.name || 'Select website'}
|
|
||||||
value={website?.id}
|
|
||||||
onChange={handleChange}
|
|
||||||
style={{ width: 300 }}
|
|
||||||
>
|
|
||||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
|
||||||
</Dropdown>
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{website && (
|
{website && (
|
||||||
<>
|
<>
|
||||||
|
26
components/pages/realtime/RealtimeCountries.js
Normal file
26
components/pages/realtime/RealtimeCountries.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import DataTable from 'components/metrics/DataTable';
|
||||||
|
import useLocale from 'hooks/useLocale';
|
||||||
|
import useCountryNames from 'hooks/useCountryNames';
|
||||||
|
|
||||||
|
export default function RealtimeCountries({ data }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const countryNames = useCountryNames(locale);
|
||||||
|
|
||||||
|
const renderCountryName = useCallback(
|
||||||
|
({ x }) => <span className={locale}>{countryNames[x]}</span>,
|
||||||
|
[countryNames, locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title={formatMessage(labels.countries)}
|
||||||
|
metric={formatMessage(labels.visitors)}
|
||||||
|
data={data}
|
||||||
|
renderLabel={renderCountryName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -1,155 +1,131 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Row, Column } from 'react-basics';
|
import { useIntl } from 'react-intl';
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { subMinutes, startOfMinute } from 'date-fns';
|
import { subMinutes, startOfMinute } from 'date-fns';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import firstBy from 'thenby';
|
import firstBy from 'thenby';
|
||||||
|
import { GridRow, GridColumn } from 'components/layout/Grid';
|
||||||
import Page from 'components/layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import RealtimeChart from 'components/metrics/RealtimeChart';
|
import RealtimeChart from 'components/metrics/RealtimeChart';
|
||||||
import RealtimeLog from 'components/metrics/RealtimeLog';
|
import StickyHeader from 'components/helpers/StickyHeader';
|
||||||
import RealtimeHeader from 'components/metrics/RealtimeHeader';
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
import WorldMap from 'components/common/WorldMap';
|
import WorldMap from 'components/common/WorldMap';
|
||||||
import DataTable from 'components/metrics/DataTable';
|
import RealtimeLog from 'components/pages/realtime/RealtimeLog';
|
||||||
import RealtimeViews from 'components/metrics/RealtimeViews';
|
import RealtimeHeader from 'components/pages/realtime/RealtimeHeader';
|
||||||
|
import RealtimeUrls from 'components/pages/realtime/RealtimeUrls';
|
||||||
|
import RealtimeCountries from 'components/pages/realtime/RealtimeCountries';
|
||||||
|
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
import useCountryNames from 'hooks/useCountryNames';
|
|
||||||
import { percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import { SHARE_TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
import { labels } from 'components/messages';
|
||||||
|
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
||||||
import styles from './RealtimeDashboard.module.css';
|
import styles from './RealtimeDashboard.module.css';
|
||||||
|
|
||||||
function mergeData(state, data, time) {
|
function mergeData(state = [], data = [], time) {
|
||||||
const ids = state.map(({ __id }) => __id);
|
const ids = state.map(({ __id }) => __id);
|
||||||
return state
|
return state
|
||||||
.concat(data.filter(({ __id }) => !ids.includes(__id)))
|
.concat(data.filter(({ __id }) => !ids.includes(__id)))
|
||||||
.filter(({ createdAt }) => new Date(createdAt).getTime() >= time);
|
.filter(({ timestamp }) => timestamp >= time);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterWebsite(data, id) {
|
export default function RealtimeDashboard({ websiteId }) {
|
||||||
return data.filter(({ websiteId }) => websiteId === id);
|
const { formatMessage } = useIntl();
|
||||||
}
|
const router = useRouter();
|
||||||
|
const [currentData, setCurrentData] = useState();
|
||||||
export default function RealtimeDashboard() {
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const countryNames = useCountryNames(locale);
|
|
||||||
const [data, setData] = useState();
|
|
||||||
const [websiteId, setWebsiteId] = useState(null);
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data: init, isLoading } = useQuery(['realtime:init'], () => get('/realtime/init'));
|
const { data: website } = useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`));
|
||||||
const { data: updates } = useQuery(
|
const { data, isLoading, error } = useQuery(
|
||||||
['realtime:updates'],
|
['realtime', websiteId],
|
||||||
() =>
|
() => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
|
||||||
get('/realtime/update', { startAt: data?.timestamp }, { [SHARE_TOKEN_HEADER]: init?.token }),
|
|
||||||
{
|
{
|
||||||
disabled: !init?.websites?.length || !data,
|
enabled: !!(websiteId && website),
|
||||||
retryInterval: REALTIME_INTERVAL,
|
refetchInterval: REALTIME_INTERVAL,
|
||||||
|
cache: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCountryName = useCallback(
|
useEffect(() => {
|
||||||
({ x }) => <span className={locale}>{countryNames[x]}</span>,
|
|
||||||
[countryNames],
|
|
||||||
);
|
|
||||||
|
|
||||||
const realtimeData = useMemo(() => {
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const { pageviews, sessions, events } = data;
|
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
|
||||||
|
const time = date.getTime();
|
||||||
|
|
||||||
if (websiteId) {
|
setCurrentData(state => ({
|
||||||
const { id } = init.websites.find(n => n.id === websiteId);
|
pageviews: mergeData(state?.pageviews, data.pageviews, time),
|
||||||
return {
|
sessions: mergeData(state?.sessions, data.sessions, time),
|
||||||
pageviews: filterWebsite(pageviews, id),
|
events: mergeData(state?.events, data.events, time),
|
||||||
sessions: filterWebsite(sessions, id),
|
timestamp: data.timestamp,
|
||||||
events: filterWebsite(events, id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}, [data, websiteId]);
|
|
||||||
|
|
||||||
const countries = useMemo(() => {
|
|
||||||
if (realtimeData?.sessions) {
|
|
||||||
return percentFilter(
|
|
||||||
realtimeData.sessions
|
|
||||||
.reduce((arr, { country }) => {
|
|
||||||
if (country) {
|
|
||||||
const row = arr.find(({ x }) => x === country);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
arr.push({ x: country, y: 1 });
|
|
||||||
} else {
|
|
||||||
row.y += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, [])
|
|
||||||
.sort(firstBy('y', -1)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [realtimeData?.sessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (init && !data) {
|
|
||||||
const { websites, data } = init;
|
|
||||||
|
|
||||||
setData({ websites, ...data });
|
|
||||||
}
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updates) {
|
|
||||||
const { pageviews, sessions, events, timestamp } = updates;
|
|
||||||
const time = subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime();
|
|
||||||
|
|
||||||
setData(state => ({
|
|
||||||
...state,
|
|
||||||
pageviews: mergeData(state.pageviews, pageviews, time),
|
|
||||||
sessions: mergeData(state.sessions, sessions, time),
|
|
||||||
events: mergeData(state.events, events, time),
|
|
||||||
timestamp,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [updates]);
|
}, [data]);
|
||||||
|
|
||||||
if (!init || !data || isLoading) {
|
const realtimeData = useMemo(() => {
|
||||||
return null;
|
if (!currentData) {
|
||||||
}
|
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const { websites } = data;
|
currentData.countries = percentFilter(
|
||||||
|
currentData.sessions
|
||||||
|
.reduce((arr, data) => {
|
||||||
|
if (!arr.find(({ sessionId }) => sessionId === data.sessionId)) {
|
||||||
|
return arr.concat(data);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [])
|
||||||
|
.reduce((arr, { country }) => {
|
||||||
|
if (country) {
|
||||||
|
const row = arr.find(({ x }) => x === country);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
arr.push({ x: country, y: 1 });
|
||||||
|
} else {
|
||||||
|
row.y += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [])
|
||||||
|
.sort(firstBy('y', -1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
currentData.visitors = currentData.sessions.reduce((arr, val) => {
|
||||||
|
if (!arr.find(({ sessionId }) => sessionId === val.sessionId)) {
|
||||||
|
return arr.concat(val);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return currentData;
|
||||||
|
}, [currentData]);
|
||||||
|
|
||||||
|
const handleSelect = id => {
|
||||||
|
router.push(`/realtime/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page loading={isLoading} error={error}>
|
||||||
<RealtimeHeader
|
<PageHeader title={formatMessage(labels.realtime)}>
|
||||||
websites={websites}
|
<WebsiteSelect websiteId={websiteId} onSelect={handleSelect} />
|
||||||
websiteId={websiteId}
|
</PageHeader>
|
||||||
data={{ ...realtimeData, countries }}
|
<StickyHeader stickyClassName={styles.sticky}>
|
||||||
onSelect={setWebsiteId}
|
<RealtimeHeader websiteId={websiteId} data={currentData} />
|
||||||
/>
|
</StickyHeader>
|
||||||
<div className={styles.chart}>
|
<div className={styles.chart}>
|
||||||
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
|
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
|
||||||
</div>
|
</div>
|
||||||
<Row>
|
<GridRow>
|
||||||
<Column xs={12} lg={4}>
|
<GridColumn xs={12} sm={12} md={12} lg={4} xl={4}>
|
||||||
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
|
<RealtimeUrls websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column xs={12} lg={8}>
|
<GridColumn xs={12} sm={12} md={12} lg={8} xl={8}>
|
||||||
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
|
<RealtimeLog websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
<Row>
|
<GridRow>
|
||||||
<Column xs={12} lg={4}>
|
<GridColumn xs={12} lg={4}>
|
||||||
<DataTable
|
<RealtimeCountries data={realtimeData?.countries} />
|
||||||
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
</GridColumn>
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
<GridColumn xs={12} lg={8}>
|
||||||
data={countries}
|
<WorldMap data={realtimeData?.countries} />
|
||||||
renderLabel={renderCountryName}
|
</GridColumn>
|
||||||
/>
|
</GridRow>
|
||||||
</Column>
|
|
||||||
<Column xs={12} lg={8}>
|
|
||||||
<WorldMap data={countries} />
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,3 +5,12 @@
|
|||||||
.chart {
|
.chart {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sticky {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
background: var(--base50);
|
||||||
|
border-bottom: 1px solid var(--base300);
|
||||||
|
z-index: 3;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
28
components/pages/realtime/RealtimeHeader.js
Normal file
28
components/pages/realtime/RealtimeHeader.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import MetricCard from 'components/metrics/MetricCard';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import styles from './RealtimeHeader.module.css';
|
||||||
|
|
||||||
|
export default function RealtimeHeader({ data = {} }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { pageviews, visitors, events, countries } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<MetricCard label={formatMessage(labels.views)} value={pageviews?.length} hideComparison />
|
||||||
|
<MetricCard
|
||||||
|
label={formatMessage(labels.visitors)}
|
||||||
|
value={visitors?.length}
|
||||||
|
hideComparison
|
||||||
|
/>
|
||||||
|
<MetricCard label={formatMessage(labels.events)} value={events?.length} hideComparison />
|
||||||
|
<MetricCard
|
||||||
|
label={formatMessage(labels.countries)}
|
||||||
|
value={countries?.length}
|
||||||
|
hideComparison
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
components/pages/realtime/RealtimeHeader.module.css
Normal file
9
components/pages/realtime/RealtimeHeader.module.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
}
|
28
components/pages/realtime/RealtimeHome.js
Normal file
28
components/pages/realtime/RealtimeHome.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import Page from 'components/layout/Page';
|
||||||
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
import { labels, messages } from 'components/messages';
|
||||||
|
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||||
|
|
||||||
|
export default function RealtimeHome() {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { get, useQuery } = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const { data, isLoading, error } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.length) {
|
||||||
|
router.push(`realtime/${data[0].id}`);
|
||||||
|
}
|
||||||
|
}, [data, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page loading={isLoading || data?.length > 0} error={error}>
|
||||||
|
<PageHeader title={formatMessage(labels.realtime)} />
|
||||||
|
{data?.length === 0 && <EmptyPlaceholder message={formatMessage(messages.noWebsites)} />}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
157
components/pages/realtime/RealtimeLog.js
Normal file
157
components/pages/realtime/RealtimeLog.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { StatusLight, Icon, Text } from 'react-basics';
|
||||||
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { FixedSizeList } from 'react-window';
|
||||||
|
import firstBy from 'thenby';
|
||||||
|
import FilterButtons from 'components/common/FilterButtons';
|
||||||
|
import NoData from 'components/common/NoData';
|
||||||
|
import { getDeviceMessage, labels, messages } from 'components/messages';
|
||||||
|
import useLocale from 'hooks/useLocale';
|
||||||
|
import useCountryNames from 'hooks/useCountryNames';
|
||||||
|
import { BROWSERS } from 'lib/constants';
|
||||||
|
import { stringToColor } from 'lib/format';
|
||||||
|
import { dateFormat } from 'lib/date';
|
||||||
|
import { safeDecodeURI } from 'next-basics';
|
||||||
|
import Icons from 'components/icons';
|
||||||
|
import styles from './RealtimeLog.module.css';
|
||||||
|
|
||||||
|
const TYPE_ALL = 'all';
|
||||||
|
const TYPE_PAGEVIEW = 'pageview';
|
||||||
|
const TYPE_SESSION = 'session';
|
||||||
|
const TYPE_EVENT = 'event';
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
[TYPE_PAGEVIEW]: <Icons.Eye />,
|
||||||
|
[TYPE_SESSION]: <Icons.Visitor />,
|
||||||
|
[TYPE_EVENT]: <Icons.Bolt />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RealtimeLog({ data, websiteDomain }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const countryNames = useCountryNames(locale);
|
||||||
|
const [filter, setFilter] = useState(TYPE_ALL);
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.all),
|
||||||
|
key: TYPE_ALL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
key: TYPE_PAGEVIEW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.visitors),
|
||||||
|
key: TYPE_SESSION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.events),
|
||||||
|
key: TYPE_EVENT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTime = ({ createdAt }) => dateFormat(new Date(createdAt), 'pp', locale);
|
||||||
|
|
||||||
|
const getColor = ({ sessionId }) => stringToColor(sessionId);
|
||||||
|
|
||||||
|
const getIcon = ({ __type }) => icons[__type];
|
||||||
|
|
||||||
|
const getDetail = log => {
|
||||||
|
const { __type, eventName, url, browser, os, country, device } = log;
|
||||||
|
|
||||||
|
if (__type === TYPE_EVENT) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
{...messages.eventLog}
|
||||||
|
values={{
|
||||||
|
event: <b>{eventName || formatMessage(labels.unknown)}</b>,
|
||||||
|
url: (
|
||||||
|
<a
|
||||||
|
href={`//${websiteDomain}${url}`}
|
||||||
|
className={styles.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__type === TYPE_PAGEVIEW) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`//${websiteDomain}${url}`}
|
||||||
|
className={styles.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{safeDecodeURI(url)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__type === TYPE_SESSION) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
{...messages.visitorLog}
|
||||||
|
values={{
|
||||||
|
country: <b>{countryNames[country] || formatMessage(labels.unknown)}</b>,
|
||||||
|
browser: <b>{BROWSERS[browser]}</b>,
|
||||||
|
os: <b>{os}</b>,
|
||||||
|
device: <b>{formatMessage(getDeviceMessage(device))}</b>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ index, style }) => {
|
||||||
|
const row = logs[index];
|
||||||
|
return (
|
||||||
|
<div className={styles.row} style={style}>
|
||||||
|
<div>
|
||||||
|
<StatusLight color={getColor(row)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.time}>{getTime(row)}</div>
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<Icon className={styles.icon}>{getIcon(row)}</Icon>
|
||||||
|
<Text>{getDetail(row)}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logs = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageviews, visitors, events } = data;
|
||||||
|
const logs = [...pageviews, ...visitors, ...events].sort(firstBy('createdAt', -1));
|
||||||
|
|
||||||
|
if (filter !== TYPE_ALL) {
|
||||||
|
return logs.filter(({ __type }) => __type === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}, [data, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.table}>
|
||||||
|
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
||||||
|
<div className={styles.header}>{formatMessage(labels.logs)}</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
{logs?.length === 0 && <NoData />}
|
||||||
|
{logs?.length > 0 && (
|
||||||
|
<FixedSizeList height={400} itemCount={logs.length} itemSize={40}>
|
||||||
|
{Row}
|
||||||
|
</FixedSizeList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,16 +1,14 @@
|
|||||||
.table {
|
.table {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
|
||||||
grid-template-rows: fit-content(100%) fit-content(100%) auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 16px;
|
font-size: var(--font-size-md);
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@ -18,6 +16,7 @@
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-bottom: 1px solid var(--base300);
|
border-bottom: 1px solid var(--base300);
|
||||||
}
|
}
|
||||||
@ -44,6 +43,7 @@
|
|||||||
.detail {
|
.detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
gap: 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
@ -1,36 +1,30 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { ButtonGroup, Button, Flexbox } from 'react-basics';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import firstBy from 'thenby';
|
import firstBy from 'thenby';
|
||||||
import { percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import DataTable from './DataTable';
|
import DataTable from 'components/metrics/DataTable';
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
|
||||||
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
|
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
|
||||||
export default function RealtimeViews({ websiteId, data, websites }) {
|
export default function RealtimeUrls({ websiteDomain, data = {} }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
const { pageviews } = data;
|
const { pageviews } = data;
|
||||||
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
||||||
const domains = useMemo(() => websites.map(({ domain }) => domain), [websites]);
|
|
||||||
const getDomain = useCallback(
|
|
||||||
id =>
|
|
||||||
websites.length === 1
|
|
||||||
? websites[0]?.domain
|
|
||||||
: websites.find(({ websiteId }) => websiteId === id)?.domain,
|
|
||||||
[websites],
|
|
||||||
);
|
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
label: formatMessage(labels.referrers),
|
||||||
key: FILTER_REFERRERS,
|
key: FILTER_REFERRERS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
|
label: formatMessage(labels.pages),
|
||||||
key: FILTER_PAGES,
|
key: FILTER_PAGES,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderLink = ({ x }) => {
|
const renderLink = ({ x }) => {
|
||||||
const domain = x.startsWith('/') ? getDomain(websiteId) : '';
|
const domain = x.startsWith('/') ? websiteDomain : '';
|
||||||
return (
|
return (
|
||||||
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||||
{x}
|
{x}
|
||||||
@ -38,7 +32,7 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [referrers, pages] = useMemo(() => {
|
const [referrers = [], pages = []] = useMemo(() => {
|
||||||
if (pageviews) {
|
if (pageviews) {
|
||||||
const referrers = percentFilter(
|
const referrers = percentFilter(
|
||||||
pageviews
|
pageviews
|
||||||
@ -46,7 +40,7 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||||||
if (referrer?.startsWith('http')) {
|
if (referrer?.startsWith('http')) {
|
||||||
const hostname = new URL(referrer).hostname.replace(/^www\./, '');
|
const hostname = new URL(referrer).hostname.replace(/^www\./, '');
|
||||||
|
|
||||||
if (hostname && !domains.includes(hostname)) {
|
if (hostname) {
|
||||||
const row = arr.find(({ x }) => x === hostname);
|
const row = arr.find(({ x }) => x === hostname);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@ -63,11 +57,8 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||||||
|
|
||||||
const pages = percentFilter(
|
const pages = percentFilter(
|
||||||
pageviews
|
pageviews
|
||||||
.reduce((arr, { url, websiteId }) => {
|
.reduce((arr, { url }) => {
|
||||||
if (url?.startsWith('/')) {
|
if (url?.startsWith('/')) {
|
||||||
if (!websiteId && websites.length > 1) {
|
|
||||||
url = `${getDomain(websiteId)}${url}`;
|
|
||||||
}
|
|
||||||
const row = arr.find(({ x }) => x === url);
|
const row = arr.find(({ x }) => x === url);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@ -83,24 +74,29 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||||||
|
|
||||||
return [referrers, pages];
|
return [referrers, pages];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [pageviews]);
|
}, [pageviews]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
<Flexbox justifyContent="center">
|
||||||
|
<ButtonGroup items={buttons} selectedKey={filter} onSelect={setFilter}>
|
||||||
|
{({ key, label }) => <Button key={key}>{label}</Button>}
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flexbox>
|
||||||
{filter === FILTER_REFERRERS && (
|
{filter === FILTER_REFERRERS && (
|
||||||
<DataTable
|
<DataTable
|
||||||
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
title={formatMessage(labels.referrers)}
|
||||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
metric={formatMessage(labels.views)}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
data={referrers}
|
data={referrers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filter === FILTER_PAGES && (
|
{filter === FILTER_PAGES && (
|
||||||
<DataTable
|
<DataTable
|
||||||
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
title={formatMessage(labels.pages)}
|
||||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
metric={formatMessage(labels.views)}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
data={pages}
|
data={pages}
|
||||||
/>
|
/>
|
@ -1,5 +1,5 @@
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import DateFilter from 'components/common/DateFilter';
|
import DateFilter from 'components/input/DateFilter';
|
||||||
import { Button, Flexbox } from 'react-basics';
|
import { Button, Flexbox } from 'react-basics';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import { DEFAULT_DATE_RANGE } from 'lib/constants';
|
import { DEFAULT_DATE_RANGE } from 'lib/constants';
|
||||||
|
@ -4,14 +4,16 @@ import PageHeader from 'components/layout/PageHeader';
|
|||||||
import ProfileDetails from './ProfileDetails';
|
import ProfileDetails from './ProfileDetails';
|
||||||
import PasswordChangeButton from './PasswordChangeButton';
|
import PasswordChangeButton from './PasswordChangeButton';
|
||||||
import { labels } from 'components/messages';
|
import { labels } from 'components/messages';
|
||||||
|
import useConfig from 'hooks/useConfig';
|
||||||
|
|
||||||
export default function ProfileSettings() {
|
export default function ProfileSettings() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const { cloudMode } = useConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title={formatMessage(labels.profile)}>
|
<PageHeader title={formatMessage(labels.profile)}>
|
||||||
<PasswordChangeButton />
|
{!cloudMode && <PasswordChangeButton />}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<ProfileDetails />
|
<ProfileDetails />
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -65,7 +65,7 @@ export default function TeamsList() {
|
|||||||
return (
|
return (
|
||||||
<Page loading={isLoading} error={error}>
|
<Page loading={isLoading} error={error}>
|
||||||
{toast}
|
{toast}
|
||||||
<PageHeader title={formatMessage(labels.team)}>
|
<PageHeader title={formatMessage(labels.teams)}>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<Flexbox gap={10}>
|
<Flexbox gap={10}>
|
||||||
{joinButton}
|
{joinButton}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Button, Form, FormRow, Modal, ModalTrigger } from 'react-basics';
|
import { Button, Modal, ModalTrigger, ActionForm } from 'react-basics';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import WebsiteDeleteForm from 'components/pages/settings/websites/WebsiteDeleteForm';
|
import WebsiteDeleteForm from 'components/pages/settings/websites/WebsiteDeleteForm';
|
||||||
import WebsiteResetForm from 'components/pages/settings/websites/WebsiteResetForm';
|
import WebsiteResetForm from 'components/pages/settings/websites/WebsiteResetForm';
|
||||||
import { labels, messages } from 'components/messages';
|
import { labels, messages } from 'components/messages';
|
||||||
|
|
||||||
export default function WebsiteReset({ websiteId, onSave }) {
|
export default function WebsiteData({ websiteId, onSave }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
@ -16,29 +16,33 @@ export default function WebsiteReset({ websiteId, onSave }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<>
|
||||||
<FormRow label={formatMessage(labels.resetWebsite)}>
|
<ActionForm
|
||||||
<p>{formatMessage(messages.resetWebsiteWarning)}</p>
|
label={formatMessage(labels.resetWebsite)}
|
||||||
|
description={formatMessage(messages.resetWebsiteWarning)}
|
||||||
|
>
|
||||||
<ModalTrigger>
|
<ModalTrigger>
|
||||||
<Button>{formatMessage(labels.reset)}</Button>
|
<Button variant="secondary">{formatMessage(labels.reset)}</Button>
|
||||||
<Modal title={formatMessage(labels.resetWebsite)}>
|
<Modal title={formatMessage(labels.resetWebsite)}>
|
||||||
{close => (
|
{close => (
|
||||||
<WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
|
<WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
</FormRow>
|
</ActionForm>
|
||||||
<FormRow label={formatMessage(labels.deleteWebsite)}>
|
<ActionForm
|
||||||
<p>{formatMessage(messages.deleteWebsiteWarning)}</p>
|
label={formatMessage(labels.deleteWebsite)}
|
||||||
|
description={formatMessage(messages.deleteWebsiteWarning)}
|
||||||
|
>
|
||||||
<ModalTrigger>
|
<ModalTrigger>
|
||||||
<Button>Delete</Button>
|
<Button variant="danger">Delete</Button>
|
||||||
<Modal title={formatMessage(labels.deleteWebsite)}>
|
<Modal title={formatMessage(labels.deleteWebsite)}>
|
||||||
{close => (
|
{close => (
|
||||||
<WebsiteDeleteForm websiteId={websiteId} onSave={handleDelete} onClose={close} />
|
<WebsiteDeleteForm websiteId={websiteId} onSave={handleDelete} onClose={close} />
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
</FormRow>
|
</ActionForm>
|
||||||
</Form>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -6,7 +6,7 @@ import Link from 'next/link';
|
|||||||
import Page from 'components/layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
import WebsiteEditForm from 'components/pages/settings/websites/WebsiteEditForm';
|
import WebsiteEditForm from 'components/pages/settings/websites/WebsiteEditForm';
|
||||||
import WebsiteReset from 'components/pages/settings/websites/WebsiteReset';
|
import WebsiteData from 'components/pages/settings/websites/WebsiteData';
|
||||||
import TrackingCode from 'components/pages/settings/websites/TrackingCode';
|
import TrackingCode from 'components/pages/settings/websites/TrackingCode';
|
||||||
import ShareUrl from 'components/pages/settings/websites/ShareUrl';
|
import ShareUrl from 'components/pages/settings/websites/ShareUrl';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
@ -59,8 +59,8 @@ export default function WebsiteSettings({ websiteId }) {
|
|||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link href={`/websites/${websiteId}`}>
|
<Link href={`/analytics/websites/${websiteId}`}>
|
||||||
<a>
|
<a target="_blank">
|
||||||
<Button variant="primary">
|
<Button variant="primary">
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.External />
|
<Icons.External />
|
||||||
@ -81,7 +81,7 @@ export default function WebsiteSettings({ websiteId }) {
|
|||||||
)}
|
)}
|
||||||
{tab === 'tracking' && <TrackingCode websiteId={websiteId} data={values} />}
|
{tab === 'tracking' && <TrackingCode websiteId={websiteId} data={values} />}
|
||||||
{tab === 'share' && <ShareUrl websiteId={websiteId} data={values} onSave={handleSave} />}
|
{tab === 'share' && <ShareUrl websiteId={websiteId} data={values} onSave={handleSave} />}
|
||||||
{tab === 'data' && <WebsiteReset websiteId={websiteId} onSave={handleReset} />}
|
{tab === 'data' && <WebsiteData websiteId={websiteId} onSave={handleReset} />}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,13 +8,10 @@ import WebsiteChart from 'components/metrics/WebsiteChart';
|
|||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||||
import { labels } from 'components/messages';
|
|
||||||
import styles from './WebsiteDetails.module.css';
|
|
||||||
import WebsiteTableView from './WebsiteTableView';
|
import WebsiteTableView from './WebsiteTableView';
|
||||||
import WebsiteMenuView from './WebsiteMenuView';
|
import WebsiteMenuView from './WebsiteMenuView';
|
||||||
|
|
||||||
export default function WebsiteDetails({ websiteId }) {
|
export default function WebsiteDetails({ websiteId }) {
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error } = useQuery(['websites', websiteId], () =>
|
const { data, isLoading, error } = useQuery(['websites', websiteId], () =>
|
||||||
get(`/websites/${websiteId}`),
|
get(`/websites/${websiteId}`),
|
||||||
@ -22,7 +19,6 @@ export default function WebsiteDetails({ websiteId }) {
|
|||||||
const [chartLoaded, setChartLoaded] = useState(false);
|
const [chartLoaded, setChartLoaded] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
resolve,
|
|
||||||
query: { view },
|
query: { view },
|
||||||
} = usePageQuery();
|
} = usePageQuery();
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Row, Column, Menu, Item, Icon, Button, Flexbox, Text } from 'react-basics';
|
import { Menu, Item, Icon, Button, Flexbox, Text } from 'react-basics';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import classNames from 'classnames';
|
import { GridRow, GridColumn } from 'components/layout/Grid';
|
||||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||||
import CountriesTable from 'components/metrics/CountriesTable';
|
import CountriesTable from 'components/metrics/CountriesTable';
|
||||||
import DevicesTable from 'components/metrics/DevicesTable';
|
import DevicesTable from 'components/metrics/DevicesTable';
|
||||||
@ -33,7 +33,7 @@ const views = {
|
|||||||
export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const {
|
const {
|
||||||
resolve,
|
resolveUrl,
|
||||||
query: { view },
|
query: { view },
|
||||||
} = usePageQuery();
|
} = usePageQuery();
|
||||||
|
|
||||||
@ -80,12 +80,12 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DetailsComponent = views[view];
|
const DetailsComponent = views[view] || (() => null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={styles.row}>
|
<GridRow>
|
||||||
<Column defaultSize={3} className={classNames(styles.col, styles.menu)}>
|
<GridColumn xs={12} sm={12} md={12} defaultSize={3} className={styles.menu}>
|
||||||
<Link href={resolve({ view: undefined })}>
|
<Link href={resolveUrl({ view: undefined })}>
|
||||||
<a>
|
<a>
|
||||||
<Flexbox justifyContent="center">
|
<Flexbox justifyContent="center">
|
||||||
<Button variant="quiet">
|
<Button variant="quiet">
|
||||||
@ -100,14 +100,14 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
|||||||
<Menu items={items} selectedKey={view}>
|
<Menu items={items} selectedKey={view}>
|
||||||
{({ key, label }) => (
|
{({ key, label }) => (
|
||||||
<Item key={key} className={styles.item}>
|
<Item key={key} className={styles.item}>
|
||||||
<Link href={resolve({ view: key })} shallow={true}>
|
<Link href={resolveUrl({ view: key })} shallow={true}>
|
||||||
<a>{label}</a>
|
<a>{label}</a>
|
||||||
</Link>
|
</Link>
|
||||||
</Item>
|
</Item>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column defaultSize={9} className={classNames(styles.col, styles.data)}>
|
<GridColumn xs={12} sm={12} md={12} defaultSize={9} className={styles.data}>
|
||||||
<DetailsComponent
|
<DetailsComponent
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
websiteDomain={websiteDomain}
|
websiteDomain={websiteDomain}
|
||||||
@ -117,7 +117,7 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
|||||||
showFilters={true}
|
showFilters={true}
|
||||||
virtualize={true}
|
virtualize={true}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,3 @@
|
|||||||
.row {
|
|
||||||
border-top: 1px solid var(--base300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col {
|
|
||||||
border-left: 1px solid var(--base300);
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col:first-child {
|
|
||||||
padding-left: 0;
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Row, Column } from 'react-basics';
|
import { GridRow, GridColumn } from 'components/layout/Grid';
|
||||||
|
//import { Row as GridRow, Column as GridColumn } from 'react-basics';
|
||||||
import PagesTable from 'components/metrics/PagesTable';
|
import PagesTable from 'components/metrics/PagesTable';
|
||||||
import ReferrersTable from 'components/metrics/ReferrersTable';
|
import ReferrersTable from 'components/metrics/ReferrersTable';
|
||||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||||
@ -9,7 +10,6 @@ import WorldMap from 'components/common/WorldMap';
|
|||||||
import CountriesTable from 'components/metrics/CountriesTable';
|
import CountriesTable from 'components/metrics/CountriesTable';
|
||||||
import EventsTable from 'components/metrics/EventsTable';
|
import EventsTable from 'components/metrics/EventsTable';
|
||||||
import EventsChart from 'components/metrics/EventsChart';
|
import EventsChart from 'components/metrics/EventsChart';
|
||||||
import styles from './WebsiteTableView.module.css';
|
|
||||||
|
|
||||||
export default function WebsiteTableView({ websiteId }) {
|
export default function WebsiteTableView({ websiteId }) {
|
||||||
const [countryData, setCountryData] = useState();
|
const [countryData, setCountryData] = useState();
|
||||||
@ -20,41 +20,41 @@ export default function WebsiteTableView({ websiteId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className={styles.row}>
|
<GridRow>
|
||||||
<Column className={styles.col} variant="two">
|
<GridColumn variant="two">
|
||||||
<PagesTable {...tableProps} />
|
<PagesTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column className={styles.col} variant="two">
|
<GridColumn variant="two">
|
||||||
<ReferrersTable {...tableProps} />
|
<ReferrersTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
<Row className={styles.row}>
|
<GridRow>
|
||||||
<Column className={styles.col} variant="three">
|
<GridColumn variant="three">
|
||||||
<BrowsersTable {...tableProps} />
|
<BrowsersTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column className={styles.col} variant="three">
|
<GridColumn variant="three">
|
||||||
<OSTable {...tableProps} />
|
<OSTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column className={styles.col} variant="three">
|
<GridColumn variant="three">
|
||||||
<DevicesTable {...tableProps} />
|
<DevicesTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
<Row className={styles.row}>
|
<GridRow>
|
||||||
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={8}>
|
<GridColumn xs={12} sm={12} md={12} defaultSize={8}>
|
||||||
<WorldMap data={countryData} />
|
<WorldMap data={countryData} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={4}>
|
<GridColumn xs={12} sm={12} md={12} defaultSize={4}>
|
||||||
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
|
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
<Row className={styles.row}>
|
<GridRow>
|
||||||
<Column className={styles.col} xs={12} md={12} lg={4} defaultSize={4}>
|
<GridColumn xs={12} sm={12} md={12} lg={4} defaultSize={4}>
|
||||||
<EventsTable {...tableProps} />
|
<EventsTable {...tableProps} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
<Column className={styles.col} xs={12} md={12} lg={8} defaultSize={8}>
|
<GridColumn xs={12} sm={12} md={12} lg={8} defaultSize={8}>
|
||||||
<EventsChart websiteId={websiteId} />
|
<EventsChart websiteId={websiteId} />
|
||||||
</Column>
|
</GridColumn>
|
||||||
</Row>
|
</GridRow>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ export default function useLocale() {
|
|||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window?.location?.href);
|
||||||
const locale = url.searchParams.get('locale');
|
const locale = url.searchParams.get('locale');
|
||||||
|
|
||||||
if (locale) {
|
if (locale) {
|
||||||
|
@ -23,9 +23,9 @@ export default function usePageQuery() {
|
|||||||
}, {});
|
}, {});
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
function resolve(params) {
|
function resolveUrl(params) {
|
||||||
return buildUrl(asPath.split('?')[0], { ...query, ...params });
|
return buildUrl(asPath.split('?')[0], { ...query, ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pathname, query, resolve, router };
|
return { pathname, query, resolveUrl, router };
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ export default function useTheme() {
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window?.location?.href);
|
||||||
const theme = url.searchParams.get('theme');
|
const theme = url.searchParams.get('theme');
|
||||||
|
|
||||||
if (['light', 'dark'].includes(theme)) {
|
if (['light', 'dark'].includes(theme)) {
|
||||||
|
@ -21,7 +21,7 @@ export const DEFAULT_DATE_RANGE = '24hour';
|
|||||||
export const DEFAULT_WEBSITE_LIMIT = 10;
|
export const DEFAULT_WEBSITE_LIMIT = 10;
|
||||||
|
|
||||||
export const REALTIME_RANGE = 30;
|
export const REALTIME_RANGE = 30;
|
||||||
export const REALTIME_INTERVAL = 3000;
|
export const REALTIME_INTERVAL = 5000;
|
||||||
|
|
||||||
export const UI_LAYOUT_BODY = 'ui-layout-body';
|
export const UI_LAYOUT_BODY = 'ui-layout-body';
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
import { v4, v5 } from 'uuid';
|
import { v4, v5 } from 'uuid';
|
||||||
import { startOfMonth } from 'date-fns';
|
import { startOfMonth } from 'date-fns';
|
||||||
import { hash } from 'next-basics';
|
import { hash } from 'next-basics';
|
||||||
@ -17,3 +18,7 @@ export function uuid(...args) {
|
|||||||
|
|
||||||
return v5(hash(...args, salt()), v5.DNS);
|
return v5(hash(...args, salt()), v5.DNS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function md5(...args) {
|
||||||
|
return crypto.createHash('md5').update(args.join('')).digest('hex');
|
||||||
|
}
|
||||||
|
@ -24,6 +24,13 @@ export interface NextApiRequestAuth extends NextApiRequest {
|
|||||||
headers: any;
|
headers: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Website {
|
export interface Website {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "2.0.0-beta.3",
|
"version": "2.0.0-beta.4",
|
||||||
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
|
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
|
||||||
"author": "Mike Cao <mike@mikecao.com>",
|
"author": "Mike Cao <mike@mikecao.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
18
pages/404.js
18
pages/404.js
@ -1,18 +1,20 @@
|
|||||||
|
import { Row, Column, Flexbox } from 'react-basics';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { labels } from 'components/messages';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
notFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Custom404() {
|
export default function Custom404() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="row justify-content-center">
|
<Row>
|
||||||
<h1 style={{ textAlign: 'center' }}>{formatMessage(messages.notFound)}</h1>
|
<Column>
|
||||||
</div>
|
<Flexbox alignItems="center" justifyContent="center" flex={1} style={{ minHeight: 600 }}>
|
||||||
|
<h1>{formatMessage(labels.pageNotFound)}</h1>
|
||||||
|
</Flexbox>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import '@fontsource/inter/600.css';
|
|||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -8,9 +8,9 @@ import {
|
|||||||
getRandomChars,
|
getRandomChars,
|
||||||
} from 'next-basics';
|
} from 'next-basics';
|
||||||
import redis from '@umami/redis-client';
|
import redis from '@umami/redis-client';
|
||||||
import { getUser, User } from 'queries';
|
import { getUser } from 'queries';
|
||||||
import { secret } from 'lib/crypto';
|
import { secret } from 'lib/crypto';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, User } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
|
|
||||||
export interface LoginRequestBody {
|
export interface LoginRequestBody {
|
||||||
|
@ -7,6 +7,7 @@ export interface ConfigResponse {
|
|||||||
updatesDisabled: boolean;
|
updatesDisabled: boolean;
|
||||||
telemetryDisabled: boolean;
|
telemetryDisabled: boolean;
|
||||||
adminDisabled: boolean;
|
adminDisabled: boolean;
|
||||||
|
cloudMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse<ConfigResponse>) => {
|
export default async (req: NextApiRequest, res: NextApiResponse<ConfigResponse>) => {
|
||||||
@ -16,7 +17,8 @@ export default async (req: NextApiRequest, res: NextApiResponse<ConfigResponse>)
|
|||||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||||
adminDisabled: !!process.env.CLOUD_MODE,
|
adminDisabled: !!process.env.DISABLE_ADMIN,
|
||||||
|
cloudMode: process.env.CLOUD_MODE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
pages/api/me/index.ts
Normal file
13
pages/api/me/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { useAuth } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody, User } from 'lib/types';
|
||||||
|
import { ok } from 'next-basics';
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<unknown, unknown>,
|
||||||
|
res: NextApiResponse<User>,
|
||||||
|
) => {
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
return ok(res, req.auth.user);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, User } from 'lib/types';
|
||||||
import { canUpdateUser } from 'lib/auth';
|
import { canUpdateUser } from 'lib/auth';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
@ -7,10 +7,11 @@ import {
|
|||||||
checkPassword,
|
checkPassword,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
methodNotAllowed,
|
methodNotAllowed,
|
||||||
|
forbidden,
|
||||||
ok,
|
ok,
|
||||||
unauthorized,
|
unauthorized,
|
||||||
} from 'next-basics';
|
} from 'next-basics';
|
||||||
import { getUser, updateUser, User } from 'queries';
|
import { getUser, updateUser } from 'queries';
|
||||||
|
|
||||||
export interface UserPasswordRequestQuery {
|
export interface UserPasswordRequestQuery {
|
||||||
id: string;
|
id: string;
|
||||||
@ -25,6 +26,10 @@ export default async (
|
|||||||
req: NextApiRequestQueryBody<UserPasswordRequestQuery, UserPasswordRequestBody>,
|
req: NextApiRequestQueryBody<UserPasswordRequestQuery, UserPasswordRequestBody>,
|
||||||
res: NextApiResponse<User>,
|
res: NextApiResponse<User>,
|
||||||
) => {
|
) => {
|
||||||
|
if (process.env.CLOUD_MODE) {
|
||||||
|
return forbidden(res);
|
||||||
|
}
|
||||||
|
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
25
pages/api/realtime/[id].ts
Normal file
25
pages/api/realtime/[id].ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { subMinutes } from 'date-fns';
|
||||||
|
import { RealtimeInit, NextApiRequestAuth } from 'lib/types';
|
||||||
|
import { useAuth } from 'lib/middleware';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok } from 'next-basics';
|
||||||
|
import { getRealtimeData } from 'queries';
|
||||||
|
|
||||||
|
export default async (req: NextApiRequestAuth, res: NextApiResponse<RealtimeInit>) => {
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const { id, startAt } = req.query;
|
||||||
|
let startTime = subMinutes(new Date(), 30);
|
||||||
|
|
||||||
|
if (+startAt > startTime.getTime()) {
|
||||||
|
startTime = new Date(+startAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRealtimeData(id, startTime);
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
@ -1,29 +0,0 @@
|
|||||||
import { subMinutes } from 'date-fns';
|
|
||||||
import { RealtimeInit } from 'lib/types';
|
|
||||||
import { NextApiRequestAuth } from 'lib/types';
|
|
||||||
import { secret } from 'lib/crypto';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { createToken, methodNotAllowed, ok } from 'next-basics';
|
|
||||||
import { getRealtimeData, getUserWebsites } from 'queries';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequestAuth, res: NextApiResponse<RealtimeInit>) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
const { id: userId } = req.auth.user;
|
|
||||||
|
|
||||||
const websites = await getUserWebsites(userId);
|
|
||||||
const ids = websites.map(({ id }) => id);
|
|
||||||
const token = createToken({ websites: ids }, secret());
|
|
||||||
const data = await getRealtimeData(ids, subMinutes(new Date(), 30));
|
|
||||||
|
|
||||||
return ok(res, {
|
|
||||||
websites,
|
|
||||||
token,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
@ -1,37 +0,0 @@
|
|||||||
import { ok, methodNotAllowed, badRequest, parseToken } from 'next-basics';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { getRealtimeData } from 'queries';
|
|
||||||
import { SHARE_TOKEN_HEADER } from 'lib/constants';
|
|
||||||
import { secret } from 'lib/crypto';
|
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { RealtimeUpdate } from 'lib/types';
|
|
||||||
|
|
||||||
export interface InitUpdateRequestQuery {
|
|
||||||
startAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<InitUpdateRequestQuery>,
|
|
||||||
res: NextApiResponse<RealtimeUpdate>,
|
|
||||||
) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
const { startAt } = req.query;
|
|
||||||
|
|
||||||
const token = req.headers[SHARE_TOKEN_HEADER];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return badRequest(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websites } = parseToken(token, secret());
|
|
||||||
|
|
||||||
const data = await getRealtimeData(websites, new Date(+startAt));
|
|
||||||
|
|
||||||
return ok(res, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
@ -2,10 +2,10 @@ import { canCreateUser, canViewUsers } from 'lib/auth';
|
|||||||
import { ROLES } from 'lib/constants';
|
import { ROLES } from 'lib/constants';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, User } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createUser, getUser, getUsers, User } from 'queries';
|
import { createUser, getUser, getUsers } from 'queries';
|
||||||
|
|
||||||
export interface UsersRequestBody {
|
export interface UsersRequestBody {
|
||||||
username: string;
|
username: string;
|
||||||
@ -36,7 +36,7 @@ export default async (
|
|||||||
|
|
||||||
const { username, password, id } = req.body;
|
const { username, password, id } = req.body;
|
||||||
|
|
||||||
const existingUser = await getUser({ username });
|
const existingUser = await getUser({ username }, { showDeleted: true });
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return badRequest(res, 'User already exists');
|
return badRequest(res, 'User already exists');
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import TestConsole from 'components/pages/console/TestConsole';
|
import TestConsole from 'components/pages/console/TestConsole';
|
||||||
|
|
||||||
export default function ConsolePage({ pageDisabled }) {
|
export default function ConsolePage({ disabled }) {
|
||||||
if (pageDisabled) {
|
if (disabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export default function ConsolePage({ pageDisabled }) {
|
|||||||
export async function getServerSideProps() {
|
export async function getServerSideProps() {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
pageDisabled: !process.env.ENABLE_TEST_CONSOLE,
|
disabled: !process.env.ENABLE_TEST_CONSOLE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import LoginLayout from 'components/pages/login/LoginLayout';
|
import LoginLayout from 'components/pages/login/LoginLayout';
|
||||||
import LoginForm from 'components/pages/login/LoginForm';
|
import LoginForm from 'components/pages/login/LoginForm';
|
||||||
|
|
||||||
export default function LoginPage({ pageDisabled }) {
|
export default function LoginPage({ disabled }) {
|
||||||
if (pageDisabled) {
|
if (disabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export default function LoginPage({ pageDisabled }) {
|
|||||||
export async function getServerSideProps() {
|
export async function getServerSideProps() {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
pageDisabled: !!process.env.CLOUD_MODE,
|
disabled: !!(process.env.DISABLE_LOGIN || process.env.CLOUD_MODE),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import useApi from 'hooks/useApi';
|
|||||||
import { setUser } from 'store/app';
|
import { setUser } from 'store/app';
|
||||||
import { removeClientAuthToken } from 'lib/client';
|
import { removeClientAuthToken } from 'lib/client';
|
||||||
|
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage({ disabled }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { post } = useApi();
|
const { post } = useApi();
|
||||||
|
|
||||||
@ -13,14 +13,24 @@ export default function LogoutPage() {
|
|||||||
await post('/logout');
|
await post('/logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
removeClientAuthToken();
|
if (!disabled) {
|
||||||
|
removeClientAuthToken();
|
||||||
|
|
||||||
logout();
|
logout();
|
||||||
|
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
|
||||||
return () => setUser(null);
|
return () => setUser(null);
|
||||||
}, []);
|
}
|
||||||
|
}, [disabled, router, post]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!(process.env.DISABLE_LOGIN || process.env.CLOUD_MODE),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
18
pages/realtime/[id]/index.js
Normal file
18
pages/realtime/[id]/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
|
import RealtimeDashboard from 'components/pages/realtime/RealtimeDashboard';
|
||||||
|
|
||||||
|
export default function RealtimeDetailsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id: websiteId } = router.query;
|
||||||
|
|
||||||
|
if (!websiteId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<RealtimeDashboard key={websiteId} websiteId={websiteId} />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -1,10 +1,10 @@
|
|||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import RealtimeDashboard from 'components/pages/realtime/RealtimeDashboard';
|
import RealtimeHome from 'components/pages/realtime/RealtimeHome';
|
||||||
|
|
||||||
export default function RealtimePage() {
|
export default function RealtimePage() {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<RealtimeDashboard />
|
<RealtimeHome />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,9 +1,11 @@
|
|||||||
export default () => null;
|
export default () => null;
|
||||||
|
|
||||||
export async function getServerSideProps() {
|
export async function getServerSideProps() {
|
||||||
|
const destination = process.env.CLOUD_MODE ? 'https://cloud.umami.is' : '/settings/websites';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/settings/websites',
|
destination,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -2,13 +2,25 @@ import AppLayout from 'components/layout/AppLayout';
|
|||||||
import TeamSettings from 'components/pages/settings/teams/TeamSettings';
|
import TeamSettings from 'components/pages/settings/teams/TeamSettings';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
export default function TeamDetailPage() {
|
export default function TeamDetailPage({ disabled }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
|
||||||
|
if (!id || disabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<TeamSettings teamId={id} />
|
<TeamSettings teamId={id} />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -1,10 +1,22 @@
|
|||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import TeamsList from 'components/pages/settings/teams/TeamsList';
|
import TeamsList from 'components/pages/settings/teams/TeamsList';
|
||||||
|
|
||||||
export default function TeamsPage() {
|
export default function TeamsPage({ disabled }) {
|
||||||
|
if (disabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<TeamsList />
|
<TeamsList />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -2,13 +2,25 @@ import AppLayout from 'components/layout/AppLayout';
|
|||||||
import UserSettings from 'components/pages/settings/users/UserSettings';
|
import UserSettings from 'components/pages/settings/users/UserSettings';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
export default function TeamDetailPage() {
|
export default function TeamDetailPage({ disabled }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
|
||||||
|
if (!id || disabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<UserSettings userId={id} />
|
<UserSettings userId={id} />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -1,12 +1,8 @@
|
|||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
|
|
||||||
import UsersList from 'components/pages/settings/users/UsersList';
|
import UsersList from 'components/pages/settings/users/UsersList';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage({ disabled }) {
|
||||||
const { adminDisabled } = useConfig();
|
if (disabled) {
|
||||||
|
|
||||||
if (adminDisabled) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,3 +12,11 @@ export default function UsersPage() {
|
|||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -2,11 +2,11 @@ import { useRouter } from 'next/router';
|
|||||||
import WebsiteSettings from 'components/pages/settings/websites/WebsiteSettings';
|
import WebsiteSettings from 'components/pages/settings/websites/WebsiteSettings';
|
||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
|
|
||||||
export default function WebsiteSettingsPage() {
|
export default function WebsiteSettingsPage({ disabled }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
|
|
||||||
if (!id) {
|
if (!id || disabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,3 +16,11 @@ export default function WebsiteSettingsPage() {
|
|||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,10 +1,22 @@
|
|||||||
import AppLayout from 'components/layout/AppLayout';
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
import WebsitesList from 'components/pages/settings/websites/WebsitesList';
|
import WebsitesList from 'components/pages/settings/websites/WebsitesList';
|
||||||
|
|
||||||
export default function WebsitesPage() {
|
export default function WebsitesPage({ disabled }) {
|
||||||
|
if (disabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<WebsitesList />
|
<WebsitesList />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
disabled: !!process.env.CLOUD_MODE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { uuid } from 'lib/crypto';
|
|||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
export async function getTeamWebsite(teamId: string, userId: string): Promise<TeamWebsite> {
|
export async function getTeamWebsite(teamId: string, userId: string): Promise<TeamWebsite> {
|
||||||
return prisma.client.TeamWebsite.findFirst({
|
return prisma.client.teamWebsite.findFirst({
|
||||||
where: {
|
where: {
|
||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
@ -12,7 +12,7 @@ export async function getTeamWebsite(teamId: string, userId: string): Promise<Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
|
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
|
||||||
return prisma.client.TeamWebsite.findMany({
|
return prisma.client.teamWebsite.findMany({
|
||||||
where: {
|
where: {
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
@ -28,7 +28,7 @@ export async function createTeamWebsite(
|
|||||||
teamId: string,
|
teamId: string,
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
): Promise<TeamWebsite> {
|
): Promise<TeamWebsite> {
|
||||||
return prisma.client.TeamWebsite.create({
|
return prisma.client.teamWebsite.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
userId,
|
userId,
|
||||||
@ -37,11 +37,3 @@ export async function createTeamWebsite(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTeamWebsite(TeamWebsiteId: string): Promise<TeamWebsite> {
|
|
||||||
return prisma.client.teamUser.delete({
|
|
||||||
where: {
|
|
||||||
id: TeamWebsiteId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
@ -1,23 +1,16 @@
|
|||||||
import { Prisma, Team } from '@prisma/client';
|
import { Prisma, Team } from '@prisma/client';
|
||||||
import cache from 'lib/cache';
|
import cache from 'lib/cache';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { Website } from 'lib/types';
|
import { Website, User } from 'lib/types';
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUser(
|
export async function getUser(
|
||||||
where: Prisma.UserWhereUniqueInput,
|
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput,
|
||||||
options: { includePassword?: boolean } = {},
|
options: { includePassword?: boolean; showDeleted?: boolean } = {},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const { includePassword = false } = options;
|
const { includePassword = false, showDeleted = false } = options;
|
||||||
|
|
||||||
return prisma.client.user.findUnique({
|
return prisma.client.user.findFirst({
|
||||||
where,
|
where: { ...where, ...(showDeleted ? {} : { deletedAt: null }) },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
@ -69,6 +62,7 @@ export async function getUserWebsites(userId: string): Promise<Website[]> {
|
|||||||
return prisma.client.website.findMany({
|
return prisma.client.website.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
@ -118,6 +112,7 @@ export async function deleteUser(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Prisma.BatchPayload, User]> {
|
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Prisma.BatchPayload, User]> {
|
||||||
const { client } = prisma;
|
const { client } = prisma;
|
||||||
|
const cloudMode = process.env.CLOUD_MODE;
|
||||||
|
|
||||||
const websites = await client.website.findMany({
|
const websites = await client.website.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@ -137,20 +132,30 @@ export async function deleteUser(
|
|||||||
client.session.deleteMany({
|
client.session.deleteMany({
|
||||||
where: { websiteId: { in: websiteIds } },
|
where: { websiteId: { in: websiteIds } },
|
||||||
}),
|
}),
|
||||||
client.website.updateMany({
|
cloudMode
|
||||||
data: {
|
? client.website.updateMany({
|
||||||
deletedAt: new Date(),
|
data: {
|
||||||
},
|
deletedAt: new Date(),
|
||||||
where: { id: { in: websiteIds } },
|
},
|
||||||
}),
|
where: { id: { in: websiteIds } },
|
||||||
client.user.update({
|
})
|
||||||
data: {
|
: client.website.deleteMany({
|
||||||
deletedAt: new Date(),
|
where: { id: { in: websiteIds } },
|
||||||
},
|
}),
|
||||||
where: {
|
cloudMode
|
||||||
id: userId,
|
? client.user.update({
|
||||||
},
|
data: {
|
||||||
}),
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: client.user.delete({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
.then(async data => {
|
.then(async data => {
|
||||||
if (cache.enabled) {
|
if (cache.enabled) {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Prisma, Website } from '@prisma/client';
|
import { Prisma, Website } from '@prisma/client';
|
||||||
import cache from 'lib/cache';
|
import cache from 'lib/cache';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db';
|
|
||||||
|
|
||||||
export async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise<Website> {
|
export async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise<Website> {
|
||||||
return prisma.client.website.findUnique({
|
return prisma.client.website.findUnique({
|
||||||
@ -70,34 +69,33 @@ export async function resetWebsite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWebsite(
|
export async function deleteWebsite(
|
||||||
websiteId: string,
|
websiteId,
|
||||||
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
|
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
|
||||||
const { client, transaction } = prisma;
|
const { client, transaction } = prisma;
|
||||||
|
const cloudMode = process.env.CLOUD_MODE;
|
||||||
|
|
||||||
if (process.env.CLOUD_MODE) {
|
return transaction([
|
||||||
return prisma.client.website.update({
|
client.websiteEvent.deleteMany({
|
||||||
data: {
|
where: { websiteId },
|
||||||
deletedAt: new Date(),
|
}),
|
||||||
},
|
client.session.deleteMany({
|
||||||
where: { id: websiteId },
|
where: { websiteId },
|
||||||
});
|
}),
|
||||||
} else {
|
cloudMode
|
||||||
return transaction([
|
? prisma.client.website.update({
|
||||||
client.websiteEvent.deleteMany({
|
data: {
|
||||||
where: { websiteId },
|
deletedAt: new Date(),
|
||||||
}),
|
},
|
||||||
client.session.deleteMany({
|
where: { id: websiteId },
|
||||||
where: { websiteId },
|
})
|
||||||
}),
|
: client.website.delete({
|
||||||
client.website.delete({
|
where: { id: websiteId },
|
||||||
where: { id: websiteId },
|
}),
|
||||||
}),
|
]).then(async data => {
|
||||||
]).then(async data => {
|
if (cache.enabled) {
|
||||||
if (cache.enabled) {
|
await cache.deleteWebsite(websiteId);
|
||||||
await cache.deleteWebsite(websiteId);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -3,19 +3,17 @@ import clickhouse from 'lib/clickhouse';
|
|||||||
import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db';
|
import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db';
|
||||||
import { EVENT_TYPE } from 'lib/constants';
|
import { EVENT_TYPE } from 'lib/constants';
|
||||||
|
|
||||||
export function getEvents(...args: [websites: string[], startAt: Date]) {
|
export function getEvents(...args: [websiteId: string, startAt: Date]) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationalQuery(websites: string[], startAt: Date) {
|
function relationalQuery(websiteId: string, startAt: Date) {
|
||||||
return prisma.client.event.findMany({
|
return prisma.client.websiteEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
websiteId: {
|
websiteId,
|
||||||
in: websites,
|
|
||||||
},
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: startAt,
|
gte: startAt,
|
||||||
},
|
},
|
||||||
@ -23,23 +21,24 @@ function relationalQuery(websites: string[], startAt: Date) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickhouseQuery(websites: string[], startAt: Date) {
|
function clickhouseQuery(websiteId: string, startAt: Date) {
|
||||||
const { rawQuery } = clickhouse;
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`select
|
`select
|
||||||
event_id,
|
event_id as id,
|
||||||
website_id,
|
website_id as websiteId,
|
||||||
session_id,
|
session_id as sessionId,
|
||||||
created_at,
|
created_at as createdAt,
|
||||||
|
toUnixTimestamp(created_at) as timestamp,
|
||||||
url,
|
url,
|
||||||
event_name
|
event_name as eventName
|
||||||
from event
|
from event
|
||||||
where event_type = ${EVENT_TYPE.customEvent}
|
where event_type = ${EVENT_TYPE.customEvent}
|
||||||
and ${websites && websites.length > 0 ? `website_id in {websites:Array(UUID)}` : '0 = 0'}
|
and website_id = {websiteId:UUID}
|
||||||
and created_at >= {startAt:DateTime('UTC')}`,
|
and created_at >= {startAt:DateTime('UTC')}`,
|
||||||
{
|
{
|
||||||
websites,
|
websiteId,
|
||||||
startAt,
|
startAt,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -3,19 +3,17 @@ import clickhouse from 'lib/clickhouse';
|
|||||||
import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db';
|
import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db';
|
||||||
import { EVENT_TYPE } from 'lib/constants';
|
import { EVENT_TYPE } from 'lib/constants';
|
||||||
|
|
||||||
export async function getPageviews(...args: [websites: string[], startAt: Date]) {
|
export async function getPageviews(...args: [websiteId: string, startAt: Date]) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function relationalQuery(websites: string[], startAt: Date) {
|
async function relationalQuery(websiteId: string, startAt: Date) {
|
||||||
return prisma.client.pageview.findMany({
|
return prisma.client.websiteEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
websiteId: {
|
websiteId,
|
||||||
in: websites,
|
|
||||||
},
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: startAt,
|
gte: startAt,
|
||||||
},
|
},
|
||||||
@ -23,21 +21,22 @@ async function relationalQuery(websites: string[], startAt: Date) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickhouseQuery(websites: string[], startAt: Date) {
|
async function clickhouseQuery(websiteId: string, startAt: Date) {
|
||||||
const { rawQuery } = clickhouse;
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`select
|
`select
|
||||||
website_id,
|
website_id as websiteId,
|
||||||
session_id,
|
session_id as sessionId,
|
||||||
created_at,
|
created_at as createdAt,
|
||||||
|
toUnixTimestamp(created_at) as timestamp,
|
||||||
url
|
url
|
||||||
from event
|
from event
|
||||||
where event_type = ${EVENT_TYPE.pageView}
|
where event_type = ${EVENT_TYPE.pageView}
|
||||||
and ${websites && websites.length > 0 ? `website_id in {websites:Array(UUID)}` : '0 = 0'}
|
and website_id = {websiteId:UUID}
|
||||||
and created_at >= {startAt:DateTime('UTC')}`,
|
and created_at >= {startAt:DateTime('UTC')}`,
|
||||||
{
|
{
|
||||||
websites,
|
websiteId,
|
||||||
startAt,
|
startAt,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -2,23 +2,17 @@ import prisma from 'lib/prisma';
|
|||||||
import clickhouse from 'lib/clickhouse';
|
import clickhouse from 'lib/clickhouse';
|
||||||
import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db';
|
import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db';
|
||||||
|
|
||||||
export async function getSessions(...args: [websites: string[], startAt: Date]) {
|
export async function getSessions(...args: [websiteId: string, startAt: Date]) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function relationalQuery(websites: string[], startAt: Date) {
|
async function relationalQuery(websiteId: string, startAt: Date) {
|
||||||
return prisma.client.session.findMany({
|
return prisma.client.session.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(websites && websites.length > 0
|
websiteId,
|
||||||
? {
|
|
||||||
websiteId: {
|
|
||||||
in: websites,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: startAt,
|
gte: startAt,
|
||||||
},
|
},
|
||||||
@ -26,14 +20,15 @@ async function relationalQuery(websites: string[], startAt: Date) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickhouseQuery(websites: string[], startAt: Date) {
|
async function clickhouseQuery(websiteId: string, startAt: Date) {
|
||||||
const { rawQuery } = clickhouse;
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`select distinct
|
`select distinct
|
||||||
session_id,
|
session_id as sessionId,
|
||||||
website_id,
|
website_id as websiteId,
|
||||||
created_at,
|
created_at as createdAt,
|
||||||
|
toUnixTimestamp(created_at) as timestamp,
|
||||||
hostname,
|
hostname,
|
||||||
browser,
|
browser,
|
||||||
os,
|
os,
|
||||||
@ -45,10 +40,10 @@ async function clickhouseQuery(websites: string[], startAt: Date) {
|
|||||||
subdivision2,
|
subdivision2,
|
||||||
city
|
city
|
||||||
from event
|
from event
|
||||||
where ${websites && websites.length > 0 ? `website_id in {websites:Array(UUID)}` : '0 = 0'}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at >= {startAt:DateTime('UTC')}`,
|
and created_at >= {startAt:DateTime('UTC')}`,
|
||||||
{
|
{
|
||||||
websites,
|
websiteId,
|
||||||
startAt,
|
startAt,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -1,30 +1,28 @@
|
|||||||
|
import { md5 } from 'lib/crypto';
|
||||||
import { getPageviews } from '../pageview/getPageviews';
|
import { getPageviews } from '../pageview/getPageviews';
|
||||||
import { getSessions } from '../session/getSessions';
|
import { getSessions } from '../session/getSessions';
|
||||||
import { getEvents } from '../event/getEvents';
|
import { getEvents } from '../event/getEvents';
|
||||||
|
|
||||||
export async function getRealtimeData(websites, time) {
|
export async function getRealtimeData(websiteId, time) {
|
||||||
const [pageviews, sessions, events] = await Promise.all([
|
const [pageviews, sessions, events] = await Promise.all([
|
||||||
getPageviews(websites, time),
|
getPageviews(websiteId, time),
|
||||||
getSessions(websites, time),
|
getSessions(websiteId, time),
|
||||||
getEvents(websites, time),
|
getEvents(websiteId, time),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const decorate = (id, data) => {
|
||||||
|
return data.map(props => ({
|
||||||
|
...props,
|
||||||
|
__id: md5(id, ...Object.values(props)),
|
||||||
|
__type: id,
|
||||||
|
timestamp: props.timestamp ? props.timestamp * 1000 : new Date(props.createdAt).getTime(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageviews: pageviews.map(({ id, ...props }) => ({
|
pageviews: decorate('pageview', pageviews),
|
||||||
__id: `p${id}`,
|
sessions: decorate('session', sessions),
|
||||||
pageviewId: id,
|
events: decorate('event', events),
|
||||||
...props,
|
|
||||||
})),
|
|
||||||
sessions: sessions.map(({ id, ...props }) => ({
|
|
||||||
__id: `s${id}`,
|
|
||||||
sessionId: id,
|
|
||||||
...props,
|
|
||||||
})),
|
|
||||||
events: events.map(({ id, ...props }) => ({
|
|
||||||
__id: `e${id}`,
|
|
||||||
eventId: id,
|
|
||||||
...props,
|
|
||||||
})),
|
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console, @typescript-eslint/no-var-requires */
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
require('dotenv').config();
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const maxmind = require('maxmind');
|
|
||||||
|
|
||||||
async function getLocation() {
|
|
||||||
const lookup = await maxmind.open(path.resolve('../node_modules/.geo/GeoLite2-City.mmdb'));
|
|
||||||
const result = lookup.get('46.135.3.1');
|
|
||||||
|
|
||||||
const country = result?.country?.iso_code ?? result?.registered_country?.iso_code;
|
|
||||||
const subdivision = result?.subdivisions[0]?.iso_code;
|
|
||||||
const subdivision2 = result?.subdivisions[0]?.names?.en;
|
|
||||||
const subdivision3 = result?.subdivisions[1]?.names?.en;
|
|
||||||
const city = result?.city?.names?.en;
|
|
||||||
console.log(result);
|
|
||||||
console.log(country, subdivision, city, subdivision2, subdivision3);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocation();
|
|
@ -2,8 +2,12 @@ import create from 'zustand';
|
|||||||
|
|
||||||
const store = create(() => ({}));
|
const store = create(() => ({}));
|
||||||
|
|
||||||
export function saveQuery(url, data) {
|
export function saveQuery(key, data) {
|
||||||
store.setState({ [url]: data });
|
store.setState({ [key]: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuery(key) {
|
||||||
|
return store.getState()[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default store;
|
export default store;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import create from 'zustand';
|
import create from 'zustand';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import app from './app';
|
import app from './app';
|
||||||
import { getDateRange } from '../lib/date';
|
import { getDateRange } from 'lib/date';
|
||||||
|
|
||||||
const store = create(() => ({}));
|
const store = create(() => ({}));
|
||||||
|
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin-right: calc(-1 * (100vw - 100%));
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
|
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
|
||||||
|
Loading…
Reference in New Issue
Block a user