mirror of
https://github.com/kremalicious/umami.git
synced 2024-12-24 02:06:19 +01:00
Metrics components refactoring. New event data page.
This commit is contained in:
parent
4e6d24e932
commit
c865f43b11
@ -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 |
@ -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',
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
3
components/input/WebsiteDateFilter.module.css
Normal file
3
components/input/WebsiteDateFilter.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.dropdown {
|
||||
min-width: 200px;
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
min-height: 110px;
|
||||
gap: 20px;
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
|
47
components/pages/event-data/EventDataMetricsBar.js
Normal file
47
components/pages/event-data/EventDataMetricsBar.js
Normal 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;
|
42
components/pages/event-data/EventDataMetricsBar.module.css
Normal file
42
components/pages/event-data/EventDataMetricsBar.module.css
Normal 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);
|
||||
}
|
||||
}
|
18
components/pages/event-data/EventDataTable.js
Normal file
18
components/pages/event-data/EventDataTable.js
Normal 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;
|
@ -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;
|
@ -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;
|
59
components/pages/websites/WebsiteChart.js
Normal file
59
components/pages/websites/WebsiteChart.js
Normal 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;
|
17
components/pages/websites/WebsiteChart.module.css
Normal file
17
components/pages/websites/WebsiteChart.module.css
Normal 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;
|
||||
}
|
@ -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;
|
||||
})}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
37
components/pages/websites/WebsiteDetailsPage.js
Normal file
37
components/pages/websites/WebsiteDetailsPage.js
Normal 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>
|
||||
);
|
||||
}
|
35
components/pages/websites/WebsiteEventData.js
Normal file
35
components/pages/websites/WebsiteEventData.js
Normal 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>
|
||||
);
|
||||
}
|
9
components/pages/websites/WebsiteEventData.module.css
Normal file
9
components/pages/websites/WebsiteEventData.module.css
Normal file
@ -0,0 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
12
components/pages/websites/WebsiteEventDataPage.js
Normal file
12
components/pages/websites/WebsiteEventDataPage.js
Normal 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>
|
||||
);
|
||||
}
|
78
components/pages/websites/WebsiteHeader.js
Normal file
78
components/pages/websites/WebsiteHeader.js
Normal 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;
|
@ -23,3 +23,7 @@
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
138
components/pages/websites/WebsiteMetricsBar.js
Normal file
138
components/pages/websites/WebsiteMetricsBar.js
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
30
components/pages/websites/WebsiteReportsPage.js
Normal file
30
components/pages/websites/WebsiteReportsPage.js
Normal 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;
|
@ -18,3 +18,4 @@ export * from './useSticky';
|
||||
export * from './useTheme';
|
||||
export * from './useTimezone';
|
||||
export * from './useUser';
|
||||
export * from './useWebsite';
|
||||
|
@ -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
10
hooks/useWebsite.js
Normal 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;
|
@ -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",
|
||||
|
36
pages/api/event-data/fields.ts
Normal file
36
pages/api/event-data/fields.ts
Normal 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);
|
||||
};
|
37
pages/api/event-data/index.ts
Normal file
37
pages/api/event-data/index.ts
Normal 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);
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 |
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user