Metrics components refactoring. New event data page.

This commit is contained in:
Mike Cao 2023-07-10 04:35:19 -07:00
parent 4e6d24e932
commit c865f43b11
47 changed files with 756 additions and 672 deletions

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 512 512"><path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60zm20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20v240z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60zm20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20v240z"/></svg>

Before

Width:  |  Height:  |  Size: 413 B

After

Width:  |  Height:  |  Size: 371 B

View File

@ -15,7 +15,6 @@ export function HamburgerButton() {
label: formatMessage(labels.dashboard),
url: '/dashboard',
},
{ label: formatMessage(labels.realtime), url: '/realtime' },
!cloudMode && {
label: formatMessage(labels.settings),
url: '/settings',

View File

@ -1,11 +1,12 @@
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import DateFilter from './DateFilter';
import styles from './WebsiteDateFilter.module.css';
export default function WebsiteDateFilter({ websiteId, value }) {
export default function WebsiteDateFilter({ websiteId }) {
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const { value, startDate, endDate } = dateRange;
const handleChange = async value => {
if (value === 'all' && websiteId) {
@ -20,6 +21,12 @@ export default function WebsiteDateFilter({ websiteId, value }) {
};
return (
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
<DateFilter
className={styles.dropdown}
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleChange}
/>
);
}

View File

@ -0,0 +1,3 @@
.dropdown {
min-width: 200px;
}

View File

@ -18,8 +18,6 @@ export function NavBar() {
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
{ label: formatMessage(labels.reports), url: '/reports' },
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);

View File

@ -1,114 +1,13 @@
import { useState } from 'react';
import { Loading } from 'react-basics';
import ErrorMessage from 'components/common/ErrorMessage';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import MetricCard from './MetricCard';
import useMessages from 'hooks/useMessages';
import styles from './MetricsBar.module.css';
export function MetricsBar({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
const {
query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
}),
);
const formatFunc = format
? n => (n >= 0 ? formatLongNumber(n) : `-${formatLongNumber(Math.abs(n))}`)
: formatNumber;
function handleSetFormat() {
setFormat(state => !state);
}
const { pageviews, uniques, bounces, totaltime } = data || {};
const num = Math.min(data && uniques.value, data && bounces.value);
const diffs = data && {
pageviews: pageviews.value - pageviews.change,
uniques: uniques.value - uniques.change,
bounces: bounces.value - bounces.change,
totaltime: totaltime.value - totaltime.change,
};
export function MetricsBar({ children, onClick, isLoading, isFetched, error }) {
return (
<div className={styles.bar} onClick={handleSetFormat}>
<div className={styles.bar} onClick={onClick}>
{isLoading && !isFetched && <Loading icon="dots" />}
{error && <ErrorMessage />}
{data && !error && isFetched && (
<>
<MetricCard
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews.value}
change={pageviews.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={uniques.value}
change={uniques.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.bounceRate)}
value={uniques.value ? (num / uniques.value) * 100 : 0}
change={
uniques.value && uniques.change
? (num / uniques.value) * 100 -
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) * 100 || 0
: 0
}
format={n => Number(n).toFixed(0) + '%'}
reverseColors
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.averageVisitTime)}
value={
totaltime.value && pageviews.value
? totaltime.value / (pageviews.value - bounces.value)
: 0
}
change={
totaltime.value && pageviews.value
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
totaltime.value / (pageviews.value - bounces.value)) *
-1 || 0
: 0
}
format={n => `${n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
/>
</>
)}
{children}
</div>
);
}

View File

@ -1,5 +1,6 @@
.bar {
display: flex;
flex-direction: row;
cursor: pointer;
min-height: 110px;
gap: 20px;

View File

@ -1,132 +0,0 @@
import { useMemo } from 'react';
import { Button, Icon, Text, Row, Column } from 'react-basics';
import Link from 'next/link';
import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
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';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray, getDateLength } from 'lib/date';
import Icons from 'components/icons';
import useSticky from 'hooks/useSticky';
import useMessages from 'hooks/useMessages';
import styles from './WebsiteChart.module.css';
import useLocale from 'hooks/useLocale';
export function WebsiteChart({
websiteId,
name,
domain,
stickyHeader = false,
showChart = true,
showDetailsButton = false,
onDataLoad = () => {},
}) {
const { formatMessage, labels } = useMessages();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const [timezone] = useTimezone();
const {
query: { url, referrer, os, browser, device, country, region, city, title },
} = usePageQuery();
const { get, useQuery } = useApi();
const { ref, isSticky } = useSticky({ enabled: stickyHeader });
const { data, isLoading, error } = useQuery(
[
'websites:pageviews',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city, title },
],
() =>
get(`/websites/${websiteId}/pageviews`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
referrer,
os,
browser,
device,
country,
region,
city,
title,
}),
{ onSuccess: onDataLoad },
);
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data, modified]);
const { dir } = useLocale();
return (
<>
<WebsiteHeader websiteId={websiteId} name={name} domain={domain}>
{showDetailsButton && (
<Link href={`/websites/${websiteId}`}>
<Button variant="primary">
<Text>{formatMessage(labels.viewDetails)}</Text>
<Icon>
<Icon rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />
</Icon>
</Icon>
</Button>
</Link>
)}
</WebsiteHeader>
<FilterTags
websiteId={websiteId}
params={{ url, referrer, os, browser, device, country, region, city, title }}
/>
<Row
ref={ref}
className={classNames(styles.header, {
[styles.sticky]: stickyHeader,
[styles.isSticky]: isSticky,
})}
>
<Column defaultSize={12} xl={8}>
<MetricsBar websiteId={websiteId} />
</Column>
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
<WebsiteDateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
</div>
</Column>
</Row>
<Row>
<Column className={styles.chart}>
{error && <ErrorMessage />}
{showChart && (
<PageviewsChart
websiteId={websiteId}
data={chartData}
unit={unit}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
/>
)}
</Column>
</Row>
</>
);
}
export default WebsiteChart;

View File

@ -1,42 +0,0 @@
import { Flexbox, Row, Column, Text, Button, Icon } from 'react-basics';
import Favicon from 'components/common/Favicon';
import ActiveUsers from './ActiveUsers';
import styles from './WebsiteHeader.module.css';
import { useMessages } from 'hooks';
import Icons from 'components/icons';
export function WebsiteHeader({ websiteId, name, domain, children }) {
const { formatMessage, labels } = useMessages();
const links = [
{ label: formatMessage(labels.overview), icon: <Icons.Overview /> },
{ label: formatMessage(labels.realtime), icon: <Icons.Clock /> },
{ label: formatMessage(labels.reports), icon: <Icons.Reports /> },
{ label: formatMessage(labels.eventData), icon: <Icons.Nodes /> },
];
return (
<Row className={styles.header} justifyContent="center">
<Column className={styles.title} variant="two">
<Favicon domain={domain} />
<Text>{name}</Text>
</Column>
<Column className={styles.actions} variant="two">
<ActiveUsers websiteId={websiteId} />
<Flexbox alignItems="center">
{links.map(({ label, icon }) => {
return (
<Button key={label} variant="quiet">
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Button>
);
})}
</Flexbox>
{children}
</Column>
</Row>
);
}
export default WebsiteHeader;

View File

@ -2,7 +2,7 @@ import WebsiteSelect from 'components/input/WebsiteSelect';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EventsChart from 'components/metrics/EventsChart';
import WebsiteChart from 'components/metrics/WebsiteChart';
import WebsiteChart from 'components/pages/websites/WebsiteChart';
import useApi from 'hooks/useApi';
import Head from 'next/head';
import Link from 'next/link';
@ -143,12 +143,7 @@ export function TestConsole() {
</Row>
<Row>
<Column>
<WebsiteChart
websiteId={website.id}
name={website.name}
domain={website.domain}
showLink
/>
<WebsiteChart websiteId={website.id} />
<EventsChart websiteId={website.id} />
</Column>
</Row>

View File

@ -0,0 +1,47 @@
import { Column, Row } from 'react-basics';
import { useApi, useDateRange } from 'hooks';
import MetricCard from 'components/metrics/MetricCard';
import useMessages from 'hooks/useMessages';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricsBar from 'components/metrics/MetricsBar';
import styles from './EventDataMetricsBar.module.css';
export function EventDataMetricsBar({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data, error, isLoading, isFetched } = useQuery(
['event-data:fields', { websiteId, startDate, endDate, modified }],
() =>
get(`/event-data/fields`, {
websiteId,
startAt: +startDate,
endAt: +endDate,
}),
);
return (
<Row>
<Column defaultSize={12} xl={8}>
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
{!error && isFetched && (
<MetricCard
className={styles.card}
label={formatMessage(labels.fields)}
value={data?.length}
/>
)}
</MetricsBar>
</Column>
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<WebsiteDateFilter websiteId={websiteId} />
</div>
</Column>
</Row>
);
}
export default EventDataMetricsBar;

View File

@ -0,0 +1,42 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
min-height: 90px;
margin-bottom: 20px;
background: var(--base50);
z-index: var(--z-index-above);
}
.metrics {
display: flex;
flex-direction: row;
align-items: center;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex: 1;
}
.bar {
display: flex;
cursor: pointer;
min-height: 110px;
gap: 20px;
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,18 @@
import { GridTable, GridColumn } from 'react-basics';
import { useMessages } from 'hooks';
export function EventDataTable({ data = [], showValue }) {
const { formatMessage, labels } = useMessages();
return (
<GridTable data={data}>
<GridColumn name="field" label={formatMessage(labels.field)} />
<GridColumn name="value" label={formatMessage(labels.value)} hidden={!showValue} />
<GridColumn name="total" label={formatMessage(labels.total)}>
{({ total }) => total.toLocaleString()}
</GridColumn>
</GridTable>
);
}
export default EventDataTable;

View File

@ -1,22 +1,20 @@
import { useState, useEffect, useMemo } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import { useRouter } from 'next/router';
import firstBy from 'thenby';
import { GridRow, GridColumn } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import PageHeader from 'components/layout/PageHeader';
import WorldMap from 'components/common/WorldMap';
import RealtimeLog from 'components/pages/realtime/RealtimeLog';
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 WebsiteHeader from 'components/pages/websites/WebsiteHeader';
import useApi from 'hooks/useApi';
import useMessages from 'hooks/useMessages';
import { percentFilter } from 'lib/filters';
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import styles from './RealtimeDashboard.module.css';
import styles from './RealtimePage.module.css';
import { useWebsite } from 'hooks';
function mergeData(state = [], data = [], time) {
const ids = state.map(({ __id }) => __id);
@ -25,12 +23,10 @@ function mergeData(state = [], data = [], time) {
.filter(({ timestamp }) => timestamp >= time);
}
export function RealtimeDashboard({ websiteId }) {
const { formatMessage, labels } = useMessages();
const router = useRouter();
export function RealtimePage({ websiteId }) {
const [currentData, setCurrentData] = useState();
const { get, useQuery } = useApi();
const { data: website } = useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`));
const { data: website } = useWebsite(websiteId);
const { data, isLoading, error } = useQuery(
['realtime', websiteId],
() => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
@ -93,15 +89,9 @@ export function RealtimeDashboard({ websiteId }) {
return currentData;
}, [currentData]);
const handleSelect = id => {
router.push(`/realtime/${id}`);
};
return (
<Page loading={isLoading} error={error}>
<PageHeader title={formatMessage(labels.realtime)}>
<WebsiteSelect websiteId={websiteId} onSelect={handleSelect} />
</PageHeader>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader websiteId={websiteId} data={currentData} />
<div className={styles.chart}>
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
@ -126,4 +116,4 @@ export function RealtimeDashboard({ websiteId }) {
);
}
export default RealtimeDashboard;
export default RealtimePage;

View File

@ -5,13 +5,13 @@ import { Button, Icon, Icons, Text } from 'react-basics';
import { useMessages, useReports } from 'hooks';
import ReportsTable from './ReportsTable';
export function ReportsList() {
export function ReportsPage() {
const { formatMessage, labels } = useMessages();
const { reports, error, isLoading } = useReports();
return (
<Page loading={isLoading} error={error}>
<PageHeader title="Reports">
<PageHeader title={formatMessage(labels.reports)}>
<Link href="/reports/create">
<Button variant="primary">
<Icon>
@ -26,4 +26,4 @@ export function ReportsList() {
);
}
export default ReportsList;
export default ReportsPage;

View File

@ -0,0 +1,59 @@
import { useMemo } from 'react';
import PageviewsChart from 'components/metrics/PageviewsChart';
import { useApi, useDateRange, useTimezone, usePageQuery } from 'hooks';
import { getDateArray, getDateLength } from 'lib/date';
export function WebsiteChart({ websiteId }) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, modified } = dateRange;
const [timezone] = useTimezone();
const {
query: { url, referrer, os, browser, device, country, region, city, title },
} = usePageQuery();
const { get, useQuery } = useApi();
const { data, isLoading } = useQuery(
[
'websites:pageviews',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city, title },
],
() =>
get(`/websites/${websiteId}/pageviews`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
referrer,
os,
browser,
device,
country,
region,
city,
title,
}),
);
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit, modified]);
return (
<PageviewsChart
websiteId={websiteId}
data={chartData}
unit={unit}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
/>
);
}
export default WebsiteChart;

View File

@ -0,0 +1,17 @@
.container {
position: relative;
display: flex;
flex-direction: column;
align-self: stretch;
}
.chart {
position: relative;
overflow: hidden;
}
.title {
font-size: var(--font-size-lg);
line-height: 60px;
font-weight: 600;
}

View File

@ -1,11 +1,19 @@
import { Button, Text, Icon } from 'react-basics';
import { useMemo } from 'react';
import { firstBy } from 'thenby';
import WebsiteChart from 'components/metrics/WebsiteChart';
import Link from 'next/link';
import WebsiteChart from 'components/pages/websites/WebsiteChart';
import useDashboard from 'store/dashboard';
import styles from './WebsiteList.module.css';
import WebsiteHeader from './WebsiteHeader';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { useMessages, useLocale } from 'hooks';
import Icons from 'components/icons';
export default function WebsiteChartList({ websites, showCharts, limit }) {
const { formatMessage, labels } = useMessages();
const { websiteOrder } = useDashboard();
const { dir } = useLocale();
const ordered = useMemo(
() =>
@ -17,16 +25,23 @@ export default function WebsiteChartList({ websites, showCharts, limit }) {
return (
<div>
{ordered.map(({ id, name, domain }, index) => {
{ordered.map(({ id }, index) => {
return index < limit ? (
<div key={id} className={styles.website}>
<WebsiteChart
websiteId={id}
name={name}
domain={domain}
showChart={showCharts}
showDetailsButton={true}
/>
<WebsiteHeader websiteId={id} showLinks={false}>
<Link href={`/websites/${id}`}>
<Button variant="primary">
<Text>{formatMessage(labels.viewDetails)}</Text>
<Icon>
<Icon rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />
</Icon>
</Icon>
</Button>
</Link>
</WebsiteHeader>
<WebsiteMetricsBar websiteId={id} />
<WebsiteChart websiteId={id} showChart={showCharts} />
</div>
) : null;
})}

View File

@ -1,47 +0,0 @@
import { useState } from 'react';
import { Loading } from 'react-basics';
import Page from 'components/layout/Page';
import WebsiteChart from 'components/metrics/WebsiteChart';
import useApi from 'hooks/useApi';
import usePageQuery from 'hooks/usePageQuery';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import WebsiteTableView from './WebsiteTableView';
import WebsiteMenuView from './WebsiteMenuView';
export default function WebsiteDetails({ websiteId }) {
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery(['websites', websiteId], () =>
get(`/websites/${websiteId}`),
);
const [chartLoaded, setChartLoaded] = useState(false);
const {
query: { view },
} = usePageQuery();
function handleDataLoad() {
if (!chartLoaded) {
setTimeout(() => setChartLoaded(true), DEFAULT_ANIMATION_DURATION);
}
}
return (
<Page loading={isLoading} error={error}>
<WebsiteChart
websiteId={websiteId}
name={data?.name}
domain={data?.domain}
onDataLoad={handleDataLoad}
showLink={false}
stickyHeader={true}
/>
{!chartLoaded && <Loading icon="dots" style={{ minHeight: 300 }} />}
{chartLoaded && (
<>
{!view && <WebsiteTableView websiteId={websiteId} />}
{view && <WebsiteMenuView websiteId={websiteId} />}
</>
)}
</Page>
);
}

View File

@ -1,31 +0,0 @@
.chart {
margin-bottom: 30px;
}
.view {
border-top: 1px solid var(--base300);
}
.menu {
font-size: var(--font-size-sm);
}
.content {
min-height: 600px;
padding: 20px 0;
}
.backButton {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16px;
}
.backButton svg {
transform: rotate(180deg);
}
.hidden {
display: none;
}

View File

@ -0,0 +1,37 @@
import { Loading } from 'react-basics';
import Page from 'components/layout/Page';
import WebsiteChart from 'components/pages/websites/WebsiteChart';
import FilterTags from 'components/metrics/FilterTags';
import usePageQuery from 'hooks/usePageQuery';
import WebsiteTableView from './WebsiteTableView';
import WebsiteMenuView from './WebsiteMenuView';
import { useWebsite } from 'hooks';
import WebsiteHeader from './WebsiteHeader';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
export default function WebsiteDetailsPage({ websiteId }) {
const { data: website, isLoading, error } = useWebsite(websiteId);
const {
query: { view, url, referrer, os, browser, device, country, region, city, title },
} = usePageQuery();
return (
<Page loading={isLoading} error={error}>
<WebsiteHeader websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} sticky={true} />
<WebsiteChart websiteId={websiteId} />
<FilterTags
websiteId={websiteId}
params={{ url, referrer, os, browser, device, country, region, city, title }}
/>
{!website && <Loading icon="dots" style={{ minHeight: 300 }} />}
{website && (
<>
{!view && <WebsiteTableView websiteId={websiteId} />}
{view && <WebsiteMenuView websiteId={websiteId} />}
</>
)}
</Page>
);
}

View File

@ -0,0 +1,35 @@
import EventDataTable from 'components/pages/event-data/EventDataTable';
import { EventDataMetricsBar } from 'components/pages/event-data/EventDataMetricsBar';
import { useDateRange, useApi, usePageQuery } from 'hooks';
import styles from './WebsiteEventData.module.css';
function useFields(websiteId, field) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(
['event-data:fields', websiteId, startDate, endDate],
() =>
get('/event-data', {
websiteId,
startAt: +startDate,
endAt: +endDate,
field,
}),
{ enabled: !!(websiteId && startDate && endDate) },
);
return { data, error, isLoading };
}
export default function WebsiteEventData({ websiteId }) {
const { data } = useFields(websiteId);
const { query } = usePageQuery();
return (
<div className={styles.container}>
<EventDataMetricsBar websiteId={websiteId} />
<EventDataTable data={data} showValue={query?.field} />
</div>
);
}

View File

@ -0,0 +1,9 @@
.container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: flex-end;
}

View File

@ -0,0 +1,12 @@
import Page from 'components/layout/Page';
import WebsiteHeader from './WebsiteHeader';
import WebsiteEventData from './WebsiteEventData';
export default function WebsiteEventDataPage({ websiteId }) {
return (
<Page>
<WebsiteHeader websiteId={websiteId} />
<WebsiteEventData websiteId={websiteId} />
</Page>
);
}

View File

@ -0,0 +1,78 @@
import classNames from 'classnames';
import { Flexbox, Row, Column, Text, Button, Icon } from 'react-basics';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Favicon from 'components/common/Favicon';
import ActiveUsers from 'components/metrics/ActiveUsers';
import styles from './WebsiteHeader.module.css';
import Icons from 'components/icons';
import { useMessages, useWebsite } from 'hooks';
export function WebsiteHeader({ websiteId, showLinks = true, children }) {
const { formatMessage, labels } = useMessages();
const { asPath, pathname } = useRouter();
const { data: website } = useWebsite(websiteId);
const { name, domain } = website || {};
const links = [
{
label: formatMessage(labels.overview),
icon: <Icons.Overview />,
path: '',
},
{
label: formatMessage(labels.realtime),
icon: <Icons.Clock />,
path: '/realtime',
},
{
label: formatMessage(labels.reports),
icon: <Icons.Reports />,
path: '/reports',
},
{
label: formatMessage(labels.eventData),
icon: <Icons.Nodes />,
path: '/event-data',
},
];
return (
<Row className={styles.header} justifyContent="center">
<Column className={styles.title} variant="two">
<Favicon domain={domain} />
<Text>{name}</Text>
</Column>
<Column className={styles.actions} variant="two">
<ActiveUsers websiteId={websiteId} />
{showLinks && (
<Flexbox alignItems="center">
{links.map(({ label, icon, path }) => {
const query = path.indexOf('?');
const selected = path
? asPath.endsWith(query >= 0 ? path.substring(0, query) : path)
: pathname === '/websites/[id]';
return (
<Link key={label} href={`/websites/${websiteId}${path}`} shallow={true}>
<Button
variant="quiet"
className={classNames({
[styles.selected]: selected,
})}
>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Button>
</Link>
);
})}
</Flexbox>
)}
{children}
</Column>
</Row>
);
}
export default WebsiteHeader;

View File

@ -23,3 +23,7 @@
gap: 30px;
min-height: 0;
}
.selected {
font-weight: bold;
}

View File

@ -0,0 +1,138 @@
import { useState } from 'react';
import classNames from 'classnames';
import { Row, Column } from 'react-basics';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import MetricCard from 'components/metrics/MetricCard';
import RefreshButton from 'components/input/RefreshButton';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricsBar from 'components/metrics/MetricsBar';
import { useApi, useDateRange, usePageQuery, useMessages, useSticky } from 'hooks';
import styles from './WebsiteMetricsBar.module.css';
export function WebsiteMetricsBar({ websiteId, sticky }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
const { ref, isSticky } = useSticky({ enabled: sticky });
const {
query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
}),
);
const formatFunc = format
? n => (n >= 0 ? formatLongNumber(n) : `-${formatLongNumber(Math.abs(n))}`)
: formatNumber;
function handleSetFormat() {
setFormat(state => !state);
}
const { pageviews, uniques, bounces, totaltime } = data || {};
const num = Math.min(data && uniques.value, data && bounces.value);
const diffs = data && {
pageviews: pageviews.value - pageviews.change,
uniques: uniques.value - uniques.change,
bounces: bounces.value - bounces.change,
totaltime: totaltime.value - totaltime.change,
};
return (
<Row
ref={ref}
className={classNames(styles.container, {
[styles.sticky]: sticky,
[styles.isSticky]: isSticky,
})}
>
<Column defaultSize={12} xl={8}>
<MetricsBar
isLoading={isLoading}
isFetched={isFetched}
error={error}
onClick={handleSetFormat}
>
{!error && isFetched && (
<>
<MetricCard
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews.value}
change={pageviews.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={uniques.value}
change={uniques.change}
format={formatFunc}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.bounceRate)}
value={uniques.value ? (num / uniques.value) * 100 : 0}
change={
uniques.value && uniques.change
? (num / uniques.value) * 100 -
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) * 100 || 0
: 0
}
format={n => Number(n).toFixed(0) + '%'}
reverseColors
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.averageVisitTime)}
value={
totaltime.value && pageviews.value
? totaltime.value / (pageviews.value - bounces.value)
: 0
}
change={
totaltime.value && pageviews.value
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
totaltime.value / (pageviews.value - bounces.value)) *
-1 || 0
: 0
}
format={n =>
`${n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`
}
/>
</>
)}
</MetricsBar>
</Column>
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<RefreshButton websiteId={websiteId} />
<WebsiteDateFilter websiteId={websiteId} />
</div>
</Column>
</Row>
);
}
export default WebsiteMetricsBar;

View File

@ -1,22 +1,4 @@
.container {
position: relative;
display: flex;
flex-direction: column;
align-self: stretch;
}
.chart {
position: relative;
overflow: hidden;
}
.title {
font-size: var(--font-size-lg);
line-height: 60px;
font-weight: 600;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
@ -35,8 +17,10 @@
gap: 10px;
}
.dropdown {
min-width: 200px;
@media only screen and (max-width: 1200px) {
.actions {
margin-top: 40px;
}
}
@media only screen and (min-width: 992px) {
@ -49,9 +33,3 @@
border-bottom: 1px solid var(--base300);
}
}
@media only screen and (max-width: 1200px) {
.actions {
margin-top: 40px;
}
}

View File

@ -0,0 +1,30 @@
import Page from 'components/layout/Page';
import Link from 'next/link';
import { Button, Icon, Icons, Text, Flexbox } from 'react-basics';
import { useMessages, useReports } from 'hooks';
import ReportsTable from 'components/pages/reports/ReportsTable';
import WebsiteHeader from './WebsiteHeader';
export function WebsiteReportsPage({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { reports, error, isLoading } = useReports(websiteId);
return (
<Page loading={isLoading} error={error}>
<WebsiteHeader websiteId={websiteId} />
<Flexbox alignItems="center" justifyContent="end">
<Link href="/reports/create">
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.createReport)}</Text>
</Button>
</Link>
</Flexbox>
<ReportsTable websiteId={websiteId} data={reports} />
</Page>
);
}
export default WebsiteReportsPage;

View File

@ -18,3 +18,4 @@ export * from './useSticky';
export * from './useTheme';
export * from './useTimezone';
export * from './useUser';
export * from './useWebsite';

View File

@ -1,8 +1,8 @@
import useApi from './useApi';
export function useReports() {
export function useReports(websiteId) {
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(['reports'], () => get(`/reports`));
const { data, error, isLoading } = useQuery(['reports'], () => get(`/reports`, { websiteId }));
return { reports: data, error, isLoading };
}

10
hooks/useWebsite.js Normal file
View File

@ -0,0 +1,10 @@
import useApi from './useApi';
export function useWebsite(websiteId) {
const { get, useQuery } = useApi();
return useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`), {
enabled: !!websiteId,
});
}
export default useWebsite;

View File

@ -95,7 +95,7 @@
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"react": "^18.2.0",
"react-basics": "^0.89.0",
"react-basics": "^0.91.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.4",

View File

@ -0,0 +1,36 @@
import { canViewWebsite } from 'lib/auth';
import { useCors, useAuth } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
import { getEventDataFields } from 'queries';
export interface EventDataFieldsRequestBody {
websiteId: string;
dateRange: {
startDate: string;
endDate: string;
};
}
export default async (
req: NextApiRequestQueryBody<any, EventDataFieldsRequestBody>,
res: NextApiResponse<any>,
) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
const { websiteId, startAt, endAt } = req.query;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getEventDataFields(websiteId, new Date(+startAt), new Date(+endAt));
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -0,0 +1,37 @@
import { canViewWebsite } from 'lib/auth';
import { useCors, useAuth } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
import { getEventData } from 'queries';
export interface EventDataRequestBody {
websiteId: string;
dateRange: {
startDate: string;
endDate: string;
};
field?: string;
}
export default async (
req: NextApiRequestQueryBody<any, EventDataRequestBody>,
res: NextApiResponse<any>,
) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
const { websiteId, startAt, endAt, field } = req.query;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getEventData(websiteId, new Date(+startAt), new Date(+endAt), field);
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -1,78 +0,0 @@
import { canViewWebsite } from 'lib/auth';
import { useCors, useAuth } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
import { getEventDataFields } from 'queries/analytics/eventData/getEventDataFields';
import { getEventData } from 'queries';
export interface EventDataRequestBody {
websiteId: string;
dateRange: {
startDate: string;
endDate: string;
};
fields: [
{
name: string;
type: string;
value: string;
},
];
filters: [
{
name: string;
type: string;
value: string;
},
];
groups: [
{
name: string;
type: string;
},
];
}
export default async (
req: NextApiRequestQueryBody<any, EventDataRequestBody>,
res: NextApiResponse<any>,
) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
const { websiteId, startAt, endAt } = req.query;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getEventDataFields(websiteId, new Date(+startAt), new Date(+endAt));
return ok(res, data);
}
if (req.method === 'POST') {
const {
websiteId,
dateRange: { startDate, endDate },
...criteria
} = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getEventData(
websiteId,
new Date(startDate),
new Date(endDate),
criteria as any,
);
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -2,8 +2,9 @@ import { uuid } from 'lib/crypto';
import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { methodNotAllowed, ok } from 'next-basics';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { createReport, getReports } from 'queries';
import { canViewWebsite } from 'lib/auth';
export interface ReportRequestBody {
websiteId: string;
@ -23,12 +24,18 @@ export default async (
await useCors(req, res);
await useAuth(req, res);
const { websiteId } = req.query;
const {
user: { id: userId },
} = req.auth;
if (req.method === 'GET') {
const data = await getReports(userId);
if (!(websiteId && (await canViewWebsite(req.auth, websiteId)))) {
return unauthorized(res);
}
const data = await getReports({ websiteId });
return ok(res, data);
}

View File

@ -1,60 +0,0 @@
import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody, WebsiteEventDataMetric } from 'lib/types';
import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getEventData } from 'queries';
export interface WebsiteEventDataRequestQuery {
id: string;
}
export interface WebsiteEventDataRequestBody {
startAt: string;
endAt: string;
eventName?: string;
urlPath?: string;
timeSeries?: {
unit: string;
timezone: string;
};
filters: [
{
eventKey?: string;
eventValue?: string | number | boolean | Date;
},
];
}
export default async (
req: NextApiRequestQueryBody<WebsiteEventDataRequestQuery, WebsiteEventDataRequestBody>,
res: NextApiResponse<WebsiteEventDataMetric[]>,
) => {
await useCors(req, res);
await useAuth(req, res);
const { id: websiteId } = req.query;
if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const { startAt, endAt, eventName, urlPath, filters } = req.body;
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
const events = await getEventData(websiteId, {
startDate,
endDate,
eventName,
urlPath,
filters,
});
return ok(res, events);
}
return methodNotAllowed(res);
};

View File

@ -1,13 +0,0 @@
import AppLayout from 'components/layout/AppLayout';
import useMessages from 'hooks/useMessages';
import ReportsPage from 'components/pages/reports/ReportsPage';
export default function () {
const { formatMessage, labels } = useMessages();
return (
<AppLayout title={formatMessage(labels.reports)}>
<ReportsPage />
</AppLayout>
);
}

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router';
import AppLayout from 'components/layout/AppLayout';
import WebsiteEventData from 'components/pages/websites/WebsiteEventData';
import WebsiteEventDataPage from 'components/pages/websites/WebsiteEventDataPage';
import useMessages from 'hooks/useMessages';
export default function () {
@ -14,7 +14,7 @@ export default function () {
return (
<AppLayout title={formatMessage(labels.websites)}>
<WebsiteEventData websiteId={id} />
<WebsiteEventDataPage websiteId={id} />
</AppLayout>
);
}

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router';
import AppLayout from 'components/layout/AppLayout';
import RealtimeDashboard from 'components/pages/realtime/RealtimeDashboard';
import RealtimePage from 'components/pages/realtime/RealtimePage';
export default function () {
const router = useRouter();
@ -12,7 +12,7 @@ export default function () {
return (
<AppLayout>
<RealtimeDashboard websiteId={websiteId} />
<RealtimePage websiteId={websiteId} />
</AppLayout>
);
}

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router';
import AppLayout from 'components/layout/AppLayout';
import WebsiteReports from 'components/pages/websites/WebsiteReports';
import WebsiteReportsPage from 'components/pages/websites/WebsiteReportsPage';
export default function () {
const router = useRouter();
@ -12,7 +12,7 @@ export default function () {
return (
<AppLayout>
<WebsiteReports websiteId={websiteId} />
<WebsiteReportsPage websiteId={websiteId} />
</AppLayout>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -13,11 +13,9 @@ export async function getReportById(reportId: string): Promise<Report> {
});
}
export async function getReports(userId: string): Promise<Report[]> {
export async function getReports(where: Prisma.ReportWhereUniqueInput): Promise<Report[]> {
return prisma.client.report.findMany({
where: {
userId,
},
where,
});
}

View File

@ -3,26 +3,10 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import { WebsiteEventDataMetric } from 'lib/types';
import { loadWebsite } from 'lib/query';
import { DEFAULT_CREATED_AT } from 'lib/constants';
export interface EventDataCriteria {
fields: [{ name: string; type: string; value: string }];
filters: [
{
name: string;
type: string;
value: [string, string];
},
];
groups: [
{
name: string;
type: string;
},
];
}
import prisma from '../../../lib/prisma';
export async function getEventData(
...args: [websiteId: string, startDate: Date, endDate: Date, criteria: EventDataCriteria]
...args: [websiteId: string, startDate: Date, endDate: Date, field?: string]
): Promise<WebsiteEventDataMetric[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@ -30,76 +14,79 @@ export async function getEventData(
});
}
async function relationalQuery() {
return null;
async function relationalQuery(websiteId: string, startDate: Date, endDate: Date, field: string) {
const { toUuid, rawQuery } = prisma;
const website = await loadWebsite(websiteId);
const resetDate = new Date(website?.resetAt || DEFAULT_CREATED_AT);
if (field) {
return rawQuery(
`select event_key as field,
count(*) as total
from event_data
where website_id = $1${toUuid()}
and event_key = $2
and created_at >= $3
and created_at between $4 and $5
group by event_key
order by 2 desc, 1 asc
limit 1000
`,
[websiteId, field, resetDate, startDate, endDate] as any,
);
}
return rawQuery(
`select
event_key as field,
count(*) as total
from event_data
where website_id = $1${toUuid()}
and created_at >= $2
and created_at between $3 and $4
group by event_key
order by 2 desc, 1 asc
limit 1000
`,
[websiteId, resetDate, startDate, endDate] as any,
);
}
async function clickhouseQuery(
websiteId: string,
startDate: Date,
endDate: Date,
criteria: EventDataCriteria,
) {
const { fields, filters } = criteria;
async function clickhouseQuery(websiteId: string, startDate: Date, endDate: Date, field: string) {
const { rawQuery, getDateFormat, getBetweenDates } = clickhouse;
const website = await loadWebsite(websiteId);
const resetDate = new Date(website?.resetAt || DEFAULT_CREATED_AT);
const uniqueFields = fields.reduce((obj, { name, type, value }) => {
const prefix = type === 'array' ? 'string' : type;
if (!obj[name]) {
obj[name] = {
columns: [
'event_key as field',
`count(*) as total`,
value === 'unique' ? `${prefix}_value as value` : null,
].filter(n => n),
groups: ['event_key', value === 'unique' ? `${prefix}_value` : null].filter(n => n),
};
}
return obj;
}, {});
const queries = Object.keys(uniqueFields).reduce((arr, key) => {
const field = uniqueFields[key];
const params = { websiteId, name: key };
return arr.concat(
rawQuery(
`select
${field.columns.join(',')}
if (field) {
return rawQuery(
`select
event_key as field,
count(*) as total
from event_data
where website_id = {websiteId:UUID}
and event_key = {name:String}
and event_key = {field:String}
and created_at >= ${getDateFormat(resetDate)}
and ${getBetweenDates('created_at', startDate, endDate)}
group by ${field.groups.join(',')}
limit 100
group by event_key
order by 2 desc, 1 asc
limit 1000
`,
params,
),
{ websiteId, field },
);
}, []);
}
const results = (await Promise.all(queries)).flatMap(n => n);
const columns = results.reduce((arr, row) => {
const keys = Object.keys(row);
for (const key of keys) {
if (!arr.includes(key)) {
arr.push(key);
}
}
return arr;
}, []);
return results.reduce((arr, row) => {
return arr.concat(
columns.reduce((obj, key) => {
obj[key] = row[key];
return obj;
}, {}),
);
}, []);
return rawQuery(
`select
event_key as field,
count(*) as total
from event_data
where website_id = {websiteId:UUID}
and created_at >= ${getDateFormat(resetDate)}
and ${getBetweenDates('created_at', startDate, endDate)}
group by event_key
order by 2 desc, 1 asc
limit 1000
`,
{ websiteId },
);
}

View File

@ -7344,10 +7344,10 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-basics@^0.89.0:
version "0.89.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.89.0.tgz#672a14448818fc7f20a3f7d73d0340d2165f94f2"
integrity sha512-nsYZCCfAjEy/fVt+5te3kQEyqA+4dEFutI9n7ol36eWmWbBJjZXCF1NgSHsosMYN2wlrpsrI7HoMTgL68FQnUg==
react-basics@^0.91.0:
version "0.91.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.91.0.tgz#2970529a22a455ec73a1be884eb93a109c9dafc0"
integrity sha512-vP8LYWiFwA+eguMEuHvHct4Jl5R/2GUjWc1tMujDG0CsAAUGhx68tAJr0K3gBrWjmpJrTPVfX8SdBNKSDAjQsw==
dependencies:
classnames "^2.3.1"
date-fns "^2.29.3"