Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-171-cloud-mode-env-variable

This commit is contained in:
Francis Cao 2023-03-01 11:40:34 -08:00
commit a777b2916f
88 changed files with 1014 additions and 945 deletions

View File

@ -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,

View File

@ -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;

View File

@ -1,6 +0,0 @@
.root {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -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;

View File

@ -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>
); );
}; };

View File

@ -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>
); );
} }

View 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>
);
}

View File

@ -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
View 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)} />;
}

View 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;
}
}

View File

@ -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>

View File

@ -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;
} }

View File

@ -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({

View File

@ -13,6 +13,7 @@ export default function ActiveUsers({ websiteId, value, refetchInterval = 60000
() => get(`/websites/${websiteId}/active`), () => get(`/websites/${websiteId}/active`),
{ {
refetchInterval, refetchInterval,
enabled: !!websiteId,
}, },
); );

View File

@ -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,

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>
</>
);
}

View File

@ -1,11 +0,0 @@
.metrics {
display: flex;
margin-bottom: 10px;
overflow: auto;
}
@media only screen and (max-width: 576px) {
.active {
display: none;
}
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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;
} }

View File

@ -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} />

View File

@ -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 && (
<> <>

View 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}
/>
);
}

View File

@ -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>
); );
} }

View File

@ -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;
}

View 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>
);
}

View File

@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.metrics {
display: flex;
}

View 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>
);
}

View 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>
);
}

View File

@ -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;

View File

@ -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}
/> />

View File

@ -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';

View File

@ -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>

View File

@ -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}

View File

@ -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> </>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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();

View File

@ -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>
); );
} }

View File

@ -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;
} }

View File

@ -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>
</> </>
); );
} }

View File

@ -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) {

View File

@ -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 };
} }

View File

@ -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)) {

View File

@ -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';

View File

@ -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');
}

View File

@ -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;

View File

@ -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');

View File

@ -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",

View File

@ -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>
); );
} }

View File

@ -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,
}, },
}, },

View File

@ -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 {

View File

@ -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
View 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);
};

View File

@ -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;

View 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);
};

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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');

View File

@ -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,
}, },
}; };
} }

View File

@ -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),
}, },
}; };
} }

View File

@ -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),
},
};
}

View 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>
);
}

View File

@ -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>
); );
} }

View File

@ -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,
}, },
}; };

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -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,
},
});
}

View File

@ -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) {

View File

@ -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;
}); });
}
} }

View File

@ -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,
}, },
); );

View File

@ -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,
}, },
); );

View File

@ -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,
}, },
); );

View File

@ -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(),
}; };
} }

View File

@ -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');

View File

@ -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();

View File

@ -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;

View File

@ -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(() => ({}));

View File

@ -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,