Changed route ids to be more explicit.

This commit is contained in:
Mike Cao 2024-01-29 14:47:52 -08:00
parent 1a70350936
commit 18e36aa7b3
105 changed files with 86 additions and 76 deletions

View file

@ -0,0 +1,16 @@
.container {
display: flex;
}
.chart {
margin-bottom: 30px;
}
.sticky {
position: fixed;
top: 0;
background: var(--base50);
border-bottom: 1px solid var(--base300);
z-index: var(--z-index-overlay);
padding: 10px 0;
}

View file

@ -0,0 +1,115 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import thenby from 'thenby';
import { Grid, GridRow } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import WorldMap from 'components/metrics/WorldMap';
import { useApi } from 'components/hooks';
import { useWebsite } from 'components/hooks';
import { percentFilter } from 'lib/filters';
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import { RealtimeData } from 'lib/types';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';
import RealtimeCountries from './RealtimeCountries';
import WebsiteHeader from '../WebsiteHeader';
import styles from './Realtime.module.css';
function mergeData(state = [], data = [], time: number) {
const ids = state.map(({ id }) => id);
return state
.concat(data.filter(({ id }) => !ids.includes(id)))
.filter(({ timestamp }) => timestamp >= time);
}
export function Realtime({ websiteId }) {
const [currentData, setCurrentData] = useState<RealtimeData>();
const { get, useQuery } = useApi();
const { data: website } = useWebsite(websiteId);
const { data, isLoading, error } = useQuery({
queryKey: ['realtime', websiteId],
queryFn: () => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
enabled: !!(websiteId && website),
refetchInterval: REALTIME_INTERVAL,
});
useEffect(() => {
if (data) {
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
const time = date.getTime();
const { pageviews, sessions, events, timestamp } = data;
setCurrentData(state => ({
pageviews: mergeData(state?.pageviews, pageviews, time),
sessions: mergeData(state?.sessions, sessions, time),
events: mergeData(state?.events, events, time),
timestamp,
}));
}
}, [data]);
const realtimeData: RealtimeData = useMemo(() => {
if (!currentData) {
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 };
}
currentData.countries = percentFilter(
currentData.sessions
.reduce((arr, data) => {
if (!arr.find(({ id }) => id === data.id)) {
return arr.concat(data);
}
return arr;
}, [])
.reduce((arr: { x: any; y: number }[], { country }: any) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1)),
);
currentData.visitors = currentData.sessions.reduce((arr, val) => {
if (!arr.find(({ id }) => id === val.id)) {
return arr.concat(val);
}
return arr;
}, []);
return currentData;
}, [currentData]);
if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader data={realtimeData} />
<RealtimeChart className={styles.chart} data={realtimeData} unit="minute" />
<Grid>
<GridRow columns="one-two">
<RealtimeUrls websiteDomain={website?.domain} data={realtimeData} />
<RealtimeLog websiteDomain={website?.domain} data={realtimeData} />
</GridRow>
<GridRow columns="one-two">
<RealtimeCountries data={realtimeData?.countries} />
<WorldMap data={realtimeData?.countries} />
</GridRow>
</Grid>
</>
);
}
export default Realtime;

View file

@ -0,0 +1,5 @@
.row {
display: flex;
align-items: center;
gap: 10px;
}

View file

@ -0,0 +1,37 @@
import { useCallback } from 'react';
import ListTable from 'components/metrics/ListTable';
import { useLocale } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import { useMessages } from 'components/hooks';
import classNames from 'classnames';
import styles from './RealtimeCountries.module.css';
export function RealtimeCountries({ data }) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const renderCountryName = useCallback(
({ x: code }) => (
<span className={classNames(locale, styles.row)}>
<img
src={`${process.env.basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
{countryNames[code]}
</span>
),
[countryNames, locale],
);
return (
<ListTable
title={formatMessage(labels.countries)}
metric={formatMessage(labels.visitors)}
data={data}
renderLabel={renderCountryName}
/>
);
}
export default RealtimeCountries;

View file

@ -0,0 +1,21 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.metrics {
display: flex;
flex-wrap: wrap;
}
.card {
justify-self: flex-start;
}
@media only screen and (max-width: 992px) {
.card {
flex-basis: calc(50% - 20px);
}
}

View file

@ -0,0 +1,42 @@
import MetricCard from 'components/metrics/MetricCard';
import { useMessages } from 'components/hooks';
import { RealtimeData } from 'lib/types';
import styles from './RealtimeHeader.module.css';
export function RealtimeHeader({ data }: { data: RealtimeData }) {
const { formatMessage, labels } = useMessages();
const { pageviews, visitors, events, countries } = data || {};
return (
<div className={styles.header}>
<div className={styles.metrics}>
<MetricCard
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={visitors?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.events)}
value={events?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.countries)}
value={countries?.length}
hideComparison
/>
</div>
</div>
);
}
export default RealtimeHeader;

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import { useApi } from 'components/hooks';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { useMessages } from 'components/hooks';
export function RealtimeHome() {
const { formatMessage, labels, messages } = useMessages();
const { get, useQuery } = useApi();
const router = useRouter();
const { data, isLoading, error } = useQuery({
queryKey: ['websites:me'],
queryFn: () => get('/me/websites'),
});
useEffect(() => {
if (data?.length) {
router.push(`realtime/${data[0].id}`);
}
}, [data, router]);
return (
<Page isLoading={isLoading || data?.length > 0} error={error}>
<PageHeader title={formatMessage(labels.realtime)} />
{data?.length === 0 && (
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)} />
)}
</Page>
);
}
export default RealtimeHome;

View file

@ -0,0 +1,90 @@
.table {
font-size: var(--font-size-sm);
overflow: hidden;
height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-md);
line-height: 40px;
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: 10px;
height: 50px;
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;
align-items: center;
flex: 1;
gap: 10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.detail > span {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.row .link {
color: var(--base900);
text-decoration: none;
}
.row .link:hover {
color: var(--primary400);
}
.search {
max-width: 300px;
}
.actions {
display: flex;
gap: 20px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
@media only screen and (max-width: 992px) {
.actions {
flex-direction: column;
}
.search {
max-width: 100%;
}
}

View file

@ -0,0 +1,189 @@
import { useMemo, useState } from 'react';
import { StatusLight, Icon, Text, SearchField } from 'react-basics';
import { FixedSizeList } from 'react-window';
import { format } from 'date-fns';
import thenby from 'thenby';
import { safeDecodeURI } from 'next-basics';
import FilterButtons from 'components/common/FilterButtons';
import Empty from 'components/common/Empty';
import { useLocale } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import Icons from 'components/icons';
import { useMessages } from 'components/hooks';
import useFormat from 'components//hooks/useFormat';
import { BROWSERS } from 'lib/constants';
import { stringToColor } from 'lib/format';
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 function RealtimeLog({ data, websiteDomain }) {
const [search, setSearch] = useState('');
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { formatValue } = useFormat();
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 = ({ timestamp }) => format(timestamp, 'h:mm:ss');
const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
const getIcon = ({ __type }) => icons[__type];
const getDetail = (log: {
__type: any;
eventName: any;
urlPath: any;
browser: any;
os: any;
country: any;
device: any;
}) => {
const { __type, eventName, urlPath: 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(labels[device] || labels.unknown)}</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;
let logs = [...pageviews, ...visitors, ...events].sort(thenby.firstBy('createdAt', -1));
if (search) {
logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => {
return [
eventName,
urlPath,
os,
formatValue(browser, 'browser'),
formatValue(country, 'country'),
formatValue(device, 'device'),
]
.filter(n => n)
.map(n => n.toLowerCase())
.join('')
.includes(search.toLowerCase());
});
}
if (filter !== TYPE_ALL) {
return logs.filter(({ __type }) => __type === filter);
}
return logs;
}, [data, filter, formatValue, search]);
return (
<div className={styles.table}>
<div className={styles.actions}>
<SearchField className={styles.search} value={search} onSearch={setSearch} />
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
</div>
<div className={styles.header}>{formatMessage(labels.activityLog)}</div>
<div className={styles.body}>
{logs?.length === 0 && <Empty />}
{logs?.length > 0 && (
<FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
{Row}
</FixedSizeList>
)}
</div>
</div>
);
}
export default RealtimeLog;

View file

@ -0,0 +1,111 @@
import { Key, useMemo, useState } from 'react';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
import thenby from 'thenby';
import { percentFilter } from 'lib/filters';
import ListTable from 'components/metrics/ListTable';
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
import { useMessages } from 'components/hooks';
import { RealtimeData } from 'lib/types';
export function RealtimeUrls({
websiteDomain,
data,
}: {
websiteDomain: string;
data: RealtimeData;
}) {
const { formatMessage, labels } = useMessages();
const { pageviews } = data || {};
const [filter, setFilter] = useState<Key>(FILTER_REFERRERS);
const limit = 15;
const buttons = [
{
label: formatMessage(labels.referrers),
key: FILTER_REFERRERS,
},
{
label: formatMessage(labels.pages),
key: FILTER_PAGES,
},
];
const renderLink = ({ x }) => {
const domain = x.startsWith('/') ? websiteDomain : '';
return (
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
{x}
</a>
);
};
const [referrers = [], pages = []] = useMemo(() => {
if (pageviews) {
const referrers = percentFilter(
pageviews
.reduce((arr, { referrerDomain }) => {
if (referrerDomain) {
const row = arr.find(({ x }) => x === referrerDomain);
if (!row) {
arr.push({ x: referrerDomain, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1))
.slice(0, limit),
);
const pages = percentFilter(
pageviews
.reduce((arr, { urlPath }) => {
const row = arr.find(({ x }) => x === urlPath);
if (!row) {
arr.push({ x: urlPath, y: 1 });
} else {
row.y += 1;
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1))
.slice(0, limit),
);
return [referrers, pages];
}
return [];
}, [pageviews]);
return (
<>
<Flexbox justifyContent="center">
<ButtonGroup items={buttons} selectedKey={filter} onSelect={setFilter}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
{filter === FILTER_REFERRERS && (
<ListTable
title={formatMessage(labels.referrers)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={referrers}
/>
)}
{filter === FILTER_PAGES && (
<ListTable
title={formatMessage(labels.pages)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={pages}
/>
)}
</>
);
}
export default RealtimeUrls;

View file

@ -0,0 +1,9 @@
import Realtime from './Realtime';
export default function WebsiteRealtimePage({ params: { id } }) {
if (!id) {
return null;
}
return <Realtime websiteId={id} />;
}