Updates to realtime. Fixed refresh button.

This commit is contained in:
Mike Cao 2023-02-14 17:23:20 -08:00
parent 638a674e99
commit 28921a7cd5
31 changed files with 373 additions and 314 deletions

View file

@ -1,21 +1,25 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Row, Column } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import { subMinutes, startOfMinute } from 'date-fns';
import { useIntl } from 'react-intl';
import { subMinutes, startOfMinute, differenceInMinutes } from 'date-fns';
import firstBy from 'thenby';
import { GridRow, GridColumn } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import RealtimeLog from 'components/metrics/RealtimeLog';
import RealtimeHeader from 'components/metrics/RealtimeHeader';
import RealtimeLog from 'components/pages/realtime/RealtimeLog';
import RealtimeHeader from 'components/pages/realtime/RealtimeHeader';
import WorldMap from 'components/common/WorldMap';
import DataTable from 'components/metrics/DataTable';
import RealtimeViews from 'components/metrics/RealtimeViews';
import RealtimeViews from 'components/pages/realtime/RealtimeViews';
import useApi from 'hooks/useApi';
import useLocale from 'hooks/useLocale';
import useCountryNames from 'hooks/useCountryNames';
import { percentFilter } from 'lib/filters';
import { labels } from 'components/messages';
import { SHARE_TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import styles from './RealtimeDashboard.module.css';
import StickyHeader from 'components/helpers/StickyHeader';
import PageHeader from 'components/layout/PageHeader';
import ActiveUsers from 'components/metrics/ActiveUsers';
function mergeData(state, data, time) {
const ids = state.map(({ __id }) => __id);
@ -29,18 +33,19 @@ function filterWebsite(data, id) {
}
export default function RealtimeDashboard() {
const { formatMessage } = useIntl();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const [data, setData] = useState();
const [websiteId, setWebsiteId] = useState(null);
const [websiteId, setWebsiteId] = useState();
const { get, useQuery } = useApi();
const { data: init, isLoading } = useQuery(['realtime:init'], () => get('/realtime/init'));
const { data: websites, isLoading } = useQuery(['websites:me'], () => get('/me/websites'));
const { data: updates } = useQuery(
['realtime:updates'],
() =>
get('/realtime/update', { startAt: data?.timestamp }, { [SHARE_TOKEN_HEADER]: init?.token }),
() => get('/realtime/update', { startAt: data?.timestamp }),
{
disabled: !init?.websites?.length || !data,
enabled: !!websiteId,
retryInterval: REALTIME_INTERVAL,
},
);
@ -55,7 +60,7 @@ export default function RealtimeDashboard() {
const { pageviews, sessions, events } = data;
if (websiteId) {
const { id } = init.websites.find(n => n.id === websiteId);
const { id } = websites.find(n => n.id === websiteId);
return {
pageviews: filterWebsite(pageviews, id),
sessions: filterWebsite(sessions, id),
@ -67,6 +72,15 @@ export default function RealtimeDashboard() {
return data;
}, [data, websiteId]);
const count = useMemo(() => {
if (data) {
const { sessions } = data;
return sessions.filter(
({ createdAt }) => differenceInMinutes(new Date(), new Date(createdAt)) <= 5,
).length;
}
}, [data, websiteId]);
const countries = useMemo(() => {
if (realtimeData?.sessions) {
return percentFilter(
@ -89,14 +103,6 @@ export default function RealtimeDashboard() {
return [];
}, [realtimeData?.sessions]);
useEffect(() => {
if (init && !data) {
const { websites, data } = init;
setData({ websites, ...data });
}
}, [init]);
useEffect(() => {
if (updates) {
const { pageviews, sessions, events, timestamp } = updates;
@ -112,44 +118,43 @@ export default function RealtimeDashboard() {
}
}, [updates]);
if (!init || !data || isLoading) {
return null;
}
const { websites } = data;
return (
<Page>
<RealtimeHeader
websites={websites}
websiteId={websiteId}
data={{ ...realtimeData, countries }}
onSelect={setWebsiteId}
/>
<Page loading={isLoading || !websites}>
<PageHeader title={formatMessage(labels.realtime)}>
<ActiveUsers value={count} />
</PageHeader>
<StickyHeader stickyClassName={styles.sticky}>
<RealtimeHeader
websites={websites}
websiteId={websiteId}
data={{ ...realtimeData, countries }}
onSelect={setWebsiteId}
/>
</StickyHeader>
<div className={styles.chart}>
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
</div>
<Row>
<Column xs={12} lg={4}>
<GridRow>
<GridColumn xs={12} sm={12} md={12} lg={4} xl={4}>
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
</Column>
<Column xs={12} lg={8}>
</GridColumn>
<GridColumn xs={12} sm={12} md={12} lg={8} xl={8}>
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
</Column>
</Row>
<Row>
<Column xs={12} lg={4}>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn xs={12} lg={4}>
<DataTable
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
title={formatMessage(labels.countries)}
metric={formatMessage(labels.visitors)}
data={countries}
renderLabel={renderCountryName}
/>
</Column>
<Column xs={12} lg={8}>
</GridColumn>
<GridColumn xs={12} lg={8}>
<WorldMap data={countries} />
</Column>
</Row>
</GridColumn>
</GridRow>
</Page>
);
}

View file

@ -5,3 +5,12 @@
.chart {
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,44 @@
import { useIntl } from 'react-intl';
import { Dropdown, Item } from 'react-basics';
import MetricCard from 'components/metrics/MetricCard';
import { labels } from 'components/messages';
import styles from './RealtimeHeader.module.css';
export default function RealtimeHeader({ data, websiteId, websites, onSelect }) {
const { formatMessage } = useIntl();
const { pageviews, sessions, events, countries } = data;
const renderValue = value => {
return websites?.find(({ id }) => id === value)?.name;
};
return (
<div className={styles.header}>
<div className={styles.metrics}>
<MetricCard label={formatMessage(labels.views)} value={pageviews?.length} hideComparison />
<MetricCard
label={formatMessage(labels.visitors)}
value={sessions?.length}
hideComparison
/>
<MetricCard label={formatMessage(labels.events)} value={events?.length} hideComparison />
<MetricCard
label={formatMessage(labels.countries)}
value={countries.length}
hideComparison
/>
</div>
<Dropdown
items={websites}
value={websiteId}
renderValue={renderValue}
onChange={onSelect}
alignment="end"
placeholder={formatMessage(labels.selectWebsite)}
>
{item => <Item key={item.id}>{item.name}</Item>}
</Dropdown>
</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,187 @@
import { useMemo, useState } from 'react';
import { StatusLight, Icon } 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 } 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 = 'type-all';
const TYPE_PAGEVIEW = 'type-pageview';
const TYPE_SESSION = 'type-session';
const TYPE_EVENT = 'type-event';
const TYPE_ICONS = {
[TYPE_PAGEVIEW]: <Icons.Eye />,
[TYPE_SESSION]: <Icons.Visitor />,
[TYPE_EVENT]: <Icons.Bolt />,
};
export default function RealtimeLog({ data, websites, websiteId }) {
const { formatMessage } = useIntl();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const [filter, setFilter] = useState(TYPE_ALL);
const logs = useMemo(() => {
if (!data) {
return [];
}
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(() => {
if (!data) {
return [];
}
return data.sessions.reduce((obj, { sessionId, sessionUuid }) => {
obj[sessionId] = sessionUuid;
return obj;
}, {});
}, [data]);
const buttons = [
{
label: formatMessage(labels.all),
key: TYPE_ALL,
},
{
label: formatMessage(labels.views),
key: TYPE_PAGEVIEW,
},
{
label: formatMessage(labels.sessions),
key: TYPE_SESSION,
},
{
label: formatMessage(labels.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] || formatMessage(labels.unknown)}</b>,
browser: <b>{BROWSERS[browser]}</b>,
os: <b>{os}</b>,
device: <b>{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

@ -0,0 +1,59 @@
.table {
font-size: var(--font-size-xs);
overflow: hidden;
height: 100%;
display: grid;
grid-template-rows: fit-content(100%) fit-content(100%) auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
line-height: 40px;
font-weight: 600;
}
.row {
display: flex;
align-items: center;
height: 40px;
border-bottom: 1px solid var(--base300);
}
.body {
overflow: auto;
height: 100%;
}
.icon {
margin-right: 10px;
}
.time {
min-width: 60px;
overflow: hidden;
}
.website {
text-align: right;
padding: 0 20px;
}
.detail {
display: flex;
flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.row .link {
color: var(--base900);
text-decoration: none;
}
.row .link:hover {
color: var(--primary400);
}

View file

@ -0,0 +1,115 @@
import { useMemo, useState, useCallback } from 'react';
import { ButtonGroup, Button } from 'react-basics';
import { useIntl } from 'react-intl';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import DataTable from 'components/metrics/DataTable';
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
import { labels } from 'components/messages';
export default function RealtimeViews({ websiteId, data = {}, websites }) {
const { formatMessage } = useIntl();
const { pageviews } = data;
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 = [
{
label: formatMessage(labels.referrers),
key: FILTER_REFERRERS,
},
{
label: formatMessage(labels.pages),
key: FILTER_PAGES,
},
];
const renderLink = ({ x }) => {
const domain = x.startsWith('/') ? getDomain(websiteId) : '';
return (
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
{x}
</a>
);
};
const [referrers = [], pages = []] = useMemo(() => {
if (pageviews) {
const referrers = percentFilter(
pageviews
.reduce((arr, { referrer }) => {
if (referrer?.startsWith('http')) {
const hostname = new URL(referrer).hostname.replace(/^www\./, '');
if (hostname && !domains.includes(hostname)) {
const row = arr.find(({ x }) => x === hostname);
if (!row) {
arr.push({ x: hostname, y: 1 });
} else {
row.y += 1;
}
}
}
return arr;
}, [])
.sort(firstBy('y', -1)),
);
const pages = percentFilter(
pageviews
.reduce((arr, { url, websiteId }) => {
if (url?.startsWith('/')) {
if (!websiteId && websites.length > 1) {
url = `${getDomain(websiteId)}${url}`;
}
const row = arr.find(({ x }) => x === url);
if (!row) {
arr.push({ x: url, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(firstBy('y', -1)),
);
return [referrers, pages];
}
return [];
}, [pageviews]);
return (
<>
<ButtonGroup items={buttons} selectedKey={filter} onSelect={setFilter}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
{filter === FILTER_REFERRERS && (
<DataTable
title={formatMessage(labels.referrers)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={referrers}
/>
)}
{filter === FILTER_PAGES && (
<DataTable
title={formatMessage(labels.pages)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={pages}
/>
)}
</>
);
}

View file

@ -8,13 +8,10 @@ import WebsiteChart from 'components/metrics/WebsiteChart';
import useApi from 'hooks/useApi';
import usePageQuery from 'hooks/usePageQuery';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { labels } from 'components/messages';
import styles from './WebsiteDetails.module.css';
import WebsiteTableView from './WebsiteTableView';
import WebsiteMenuView from './WebsiteMenuView';
export default function WebsiteDetails({ websiteId }) {
const { formatMessage } = useIntl();
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery(['websites', websiteId], () =>
get(`/websites/${websiteId}`),
@ -22,7 +19,6 @@ export default function WebsiteDetails({ websiteId }) {
const [chartLoaded, setChartLoaded] = useState(false);
const {
resolve,
query: { view },
} = 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 Link from 'next/link';
import classNames from 'classnames';
import { GridRow, GridColumn } from 'components/layout/Grid';
import BrowsersTable from 'components/metrics/BrowsersTable';
import CountriesTable from 'components/metrics/CountriesTable';
import DevicesTable from 'components/metrics/DevicesTable';
@ -33,7 +33,7 @@ const views = {
export default function WebsiteMenuView({ websiteId, websiteDomain }) {
const { formatMessage } = useIntl();
const {
resolve,
resolveUrl,
query: { view },
} = usePageQuery();
@ -80,12 +80,12 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
},
];
const DetailsComponent = views[view];
const DetailsComponent = views[view] || (() => null);
return (
<Row className={styles.row}>
<Column defaultSize={3} className={classNames(styles.col, styles.menu)}>
<Link href={resolve({ view: undefined })}>
<GridRow>
<GridColumn xs={12} sm={12} md={12} defaultSize={3} className={styles.menu}>
<Link href={resolveUrl({ view: undefined })}>
<a>
<Flexbox justifyContent="center">
<Button variant="quiet">
@ -100,14 +100,14 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
<Menu items={items} selectedKey={view}>
{({ key, label }) => (
<Item key={key} className={styles.item}>
<Link href={resolve({ view: key })} shallow={true}>
<Link href={resolveUrl({ view: key })} shallow={true}>
<a>{label}</a>
</Link>
</Item>
)}
</Menu>
</Column>
<Column defaultSize={9} className={classNames(styles.col, styles.data)}>
</GridColumn>
<GridColumn xs={12} sm={12} md={12} defaultSize={9} className={styles.data}>
<DetailsComponent
websiteId={websiteId}
websiteDomain={websiteDomain}
@ -117,7 +117,7 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
showFilters={true}
virtualize={true}
/>
</Column>
</Row>
</GridColumn>
</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 {
gap: 20px;
}

View file

@ -1,5 +1,6 @@
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 ReferrersTable from 'components/metrics/ReferrersTable';
import BrowsersTable from 'components/metrics/BrowsersTable';
@ -9,7 +10,6 @@ import WorldMap from 'components/common/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
import styles from './WebsiteTableView.module.css';
export default function WebsiteTableView({ websiteId }) {
const [countryData, setCountryData] = useState();
@ -20,41 +20,41 @@ export default function WebsiteTableView({ websiteId }) {
return (
<>
<Row className={styles.row}>
<Column className={styles.col} variant="two">
<GridRow>
<GridColumn variant="two">
<PagesTable {...tableProps} />
</Column>
<Column className={styles.col} variant="two">
</GridColumn>
<GridColumn variant="two">
<ReferrersTable {...tableProps} />
</Column>
</Row>
<Row className={styles.row}>
<Column className={styles.col} variant="three">
</GridColumn>
</GridRow>
<GridRow>
<GridColumn variant="three">
<BrowsersTable {...tableProps} />
</Column>
<Column className={styles.col} variant="three">
</GridColumn>
<GridColumn variant="three">
<OSTable {...tableProps} />
</Column>
<Column className={styles.col} variant="three">
</GridColumn>
<GridColumn variant="three">
<DevicesTable {...tableProps} />
</Column>
</Row>
<Row className={styles.row}>
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={8}>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn xs={12} sm={12} md={12} defaultSize={8}>
<WorldMap data={countryData} />
</Column>
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={4}>
</GridColumn>
<GridColumn xs={12} sm={12} md={12} defaultSize={4}>
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
</Column>
</Row>
<Row className={styles.row}>
<Column className={styles.col} xs={12} md={12} lg={4} defaultSize={4}>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn xs={12} sm={12} md={12} lg={4} defaultSize={4}>
<EventsTable {...tableProps} />
</Column>
<Column className={styles.col} xs={12} md={12} lg={8} defaultSize={8}>
</GridColumn>
<GridColumn xs={12} sm={12} md={12} lg={8} defaultSize={8}>
<EventsChart websiteId={websiteId} />
</Column>
</Row>
</GridColumn>
</GridRow>
</>
);
}