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

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

View file

@ -42,7 +42,7 @@ export default function DatePickerForm({
return (
<div className={styles.container}>
<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_RANGE}>{formatMessage(labels.dateRange)}</Button>
</ButtonGroup>

View file

@ -1,26 +1,39 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import { Button, Icon, Icons, Text } from 'react-basics';
import { labels } from 'components/messages';
import usePageQuery from 'hooks/usePageQuery';
import styles from './FilterTags.module.css';
export default function FilterTags({ className, params, onClick }) {
export default function FilterTags({ websiteId, params, onClick }) {
const { formatMessage } = useIntl();
const {
router,
resolveUrl,
query: { view },
} = usePageQuery();
if (Object.keys(params).filter(key => params[key]).length === 0) {
return null;
}
function handleCloseFilter(param) {
if (param === null) {
router.push(`/websites/${websiteId}/?view=${view}`);
} else {
router.push(resolveUrl({ [param]: undefined }));
}
}
return (
<div className={classNames(styles.filters, className)}>
<div className={styles.filters}>
{Object.keys(params).map(key => {
if (!params[key]) {
return null;
}
return (
<div key={key} className={styles.tag}>
<Button onClick={() => onClick(key)} variant="primary" size="sm">
<Button onClick={() => handleCloseFilter(key)} variant="primary" size="sm">
<Text>
<b>{`${key}`}</b> {`${safeDecodeURI(params[key])}`}
</Text>
@ -31,7 +44,7 @@ export default function FilterTags({ className, params, onClick }) {
</div>
);
})}
<Button size="sm" variant="quiet" onClick={() => onClick(null)}>
<Button size="sm" variant="quiet" onClick={() => handleCloseFilter(null)}>
<Icon>
<Icons.Close />
</Icon>

View file

@ -31,7 +31,7 @@ export default function MetricsTable({
}) {
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
const {
resolve,
resolveUrl,
router,
query: { url, referrer, os, browser, device, country },
} = usePageQuery();
@ -79,7 +79,7 @@ export default function MetricsTable({
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
<div className={styles.footer}>
{data && !error && limit && (
<Link href={router.pathname} as={resolve({ view: type })}>
<Link href={router.pathname} as={resolveUrl({ view: type })}>
<a>
<Button variant="quiet">
<Text>{formatMessage(messages.more)}</Text>

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,59 +0,0 @@
.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

@ -1,110 +0,0 @@
import { useMemo, useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import DataTable from './DataTable';
import FilterButtons from 'components/common/FilterButtons';
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
export default function RealtimeViews({ websiteId, data, websites }) {
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: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
key: FILTER_REFERRERS,
},
{
label: <FormattedMessage id="metrics.pages" defaultMessage="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 (
<>
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
{filter === FILTER_REFERRERS && (
<DataTable
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
renderLabel={renderLink}
data={referrers}
/>
)}
{filter === FILTER_PAGES && (
<DataTable
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
renderLabel={renderLink}
data={pages}
/>
)}
</>
);
}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
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 PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
@ -9,6 +9,7 @@ import DateFilter from 'components/common/DateFilter';
import StickyHeader from 'components/helpers/StickyHeader';
import ErrorMessage from 'components/common/ErrorMessage';
import FilterTags from 'components/metrics/FilterTags';
import RefreshButton from 'components/input/RefreshButton';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
@ -28,13 +29,11 @@ export default function WebsiteChart({
onDataLoad = () => {},
}) {
const { formatMessage } = useIntl();
const [dateRange, setDateRange] = useDateRange(websiteId);
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const [timezone] = useTimezone();
const {
router,
resolve,
query: { view, url, referrer, os, browser, device, country },
query: { url, referrer, os, browser, device, country },
} = usePageQuery();
const { get, useQuery } = useApi();
@ -66,26 +65,6 @@ export default function WebsiteChart({
return { pageviews: [], sessions: [] };
}, [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 (
<>
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
@ -102,22 +81,15 @@ export default function WebsiteChart({
</Link>
)}
</WebsiteHeader>
<FilterTags
params={{ url, referrer, os, browser, device, country }}
onClick={handleCloseFilter}
/>
<FilterTags websiteId={websiteId} params={{ url, referrer, os, browser, device, country }} />
<StickyHeader stickyClassName={styles.sticky} enabled={stickyHeader}>
<Row className={styles.header}>
<Column xs={12} sm={12} md={12} defaultSize={10}>
<Column>
<MetricsBar websiteId={websiteId} />
</Column>
<Column className={styles.filter} xs={12} sm={12} md={12} defaultSize={2}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
/>
<Column className={styles.actions}>
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
<DateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
</Column>
</Row>
</StickyHeader>

View file

@ -7,7 +7,7 @@
.chart {
position: relative;
padding-bottom: 20px;
padding-bottom: 10px;
}
.title {
@ -32,9 +32,17 @@
border-bottom: 1px solid var(--base300);
z-index: 3;
width: inherit;
padding-top: 20px;
padding-top: 10px;
}
.filter {
align-self: center;
.actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex: 1;
}
.dropdown {
min-width: 200px;
}