Refactored data tables. Added realtime tables.

This commit is contained in:
Mike Cao 2020-10-10 00:07:08 -07:00
parent 51955c69ec
commit 8912daa2fa
29 changed files with 335 additions and 226 deletions

View File

@ -13,7 +13,7 @@ export const GridRow = ({ className, children }) => {
export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => {
const classes = [];
classes.push(xs ? `col-${xs}` : 'col');
classes.push(xs ? `col-${xs}` : 'col-12');
if (sm) {
classes.push(`col-sm-${sm}`);

View File

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import { formatNumber, formatLongNumber } from 'lib/format';
import styles from './DataTable.module.css';
export default function DataTable({
data,
title,
metric,
className,
limit,
renderLabel,
height = 400,
animate = true,
}) {
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const handleSetFormat = () => setFormat(state => !state);
const getRow = row => {
const { x: label, y: value, z: percent } = row;
return (
<AnimatedRow
key={label}
label={renderLabel ? renderLabel(row) : label}
value={value}
percent={percent}
animate={animate}
format={formatFunc}
onClick={handleSetFormat}
/>
);
};
const Row = ({ index, style }) => {
return <div style={style}>{getRow(data[index])}</div>;
};
return (
<div className={classNames(styles.table, className)}>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.metric} onClick={handleSetFormat}>
{metric}
</div>
</div>
<div className={styles.body}>
{data?.length === 0 && <NoData />}
{limit
? data.map(row => getRow(row))
: data.length > 0 && (
<FixedSizeList height={height} itemCount={data.length} itemSize={30}>
{Row}
</FixedSizeList>
)}
</div>
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<div className={styles.value} onClick={onClick}>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
</div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View File

@ -0,0 +1,96 @@
.table {
position: relative;
font-size: var(--font-size-small);
display: flex;
flex-direction: column;
flex: 1;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
}
.title {
display: flex;
font-weight: 600;
font-size: var(--font-size-normal);
}
.metric {
font-size: var(--font-size-small);
font-weight: 600;
text-align: center;
width: 100px;
cursor: pointer;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
overflow: hidden;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 2;
}
.label a {
color: inherit;
text-decoration: none;
}
.label a:hover {
color: var(--primary400);
}
.label:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
cursor: pointer;
}
.percent {
position: relative;
width: 50px;
color: var(--gray600);
border-left: 1px solid var(--gray600);
padding-left: 10px;
z-index: 1;
}
.bar {
position: absolute;
top: 0;
left: 0;
height: 30px;
opacity: 0.1;
background: var(--primary400);
z-index: -1;
}
.body {
position: relative;
flex: 1;
overflow: hidden;
}

View File

@ -18,8 +18,6 @@ export default function MetricsBar({ websiteId, token, className }) {
query: { url },
} = usePageQuery();
console.log({ modified });
const { data, error, loading } = useFetch(
`/api/website/${websiteId}/stats`,
{

View File

@ -1,19 +1,17 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import Link from 'components/common/Link';
import Loading from 'components/common/Loading';
import NoData from 'components/common/NoData';
import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import ErrorMessage from 'components/common/ErrorMessage';
import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './MetricsTable.module.css';
import ErrorMessage from '../common/ErrorMessage';
export default function MetricsTable({
websiteId,
@ -49,15 +47,12 @@ export default function MetricsTable({
token,
},
onDataLoad,
delay: 300,
delay: DEFAULT_ANIMATION_DURATION,
},
[modified],
);
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0;
const rankings = useMemo(() => {
const filteredData = useMemo(() => {
if (data) {
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
if (limit) {
@ -68,91 +63,34 @@ export default function MetricsTable({
return [];
}, [data, error, dataFilter, filterOptions]);
const handleSetFormat = () => setFormat(state => !state);
const getRow = row => {
const { x: label, y: value, z: percent } = row;
return (
<AnimatedRow
key={label}
label={renderLabel ? renderLabel(row) : label}
value={value}
percent={percent}
animate={shouldAnimate}
format={formatFunc}
onClick={handleSetFormat}
/>
);
};
const Row = ({ index, style }) => {
return <div style={style}>{getRow(rankings[index])}</div>;
};
return (
<div className={classNames(styles.container, className)}>
{!data && loading && <Loading />}
{error && <ErrorMessage />}
{data && !error && (
<>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.metric} onClick={handleSetFormat}>
{metric}
</div>
</div>
<div className={styles.body}>
{rankings?.length === 0 && <NoData />}
{limit
? rankings.map(row => getRow(row))
: rankings.length > 0 && (
<FixedSizeList height={500} itemCount={rankings.length} itemSize={30}>
{Row}
</FixedSizeList>
)}
</div>
<div className={styles.footer}>
{limit && (
<Link
icon={<Arrow />}
href={router.pathname}
as={resolve({ view: type })}
size="small"
iconRight
>
<FormattedMessage id="button.more" defaultMessage="More" />
</Link>
)}
</div>
</>
<DataTable
title={title}
data={filteredData}
metric={metric}
className={className}
renderLabel={renderLabel}
limit={limit}
animate={limit > 0}
/>
)}
<div className={styles.footer}>
{limit && (
<Link
icon={<Arrow />}
href={router.pathname}
as={resolve({ view: type })}
size="small"
iconRight
>
<FormattedMessage id="button.more" defaultMessage="More" />
</Link>
)}
</div>
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<div className={styles.value} onClick={onClick}>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
</div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View File

@ -6,95 +6,6 @@
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
}
.title {
display: flex;
font-weight: 600;
font-size: var(--font-size-normal);
}
.metric {
font-size: var(--font-size-small);
font-weight: 600;
text-align: center;
width: 100px;
cursor: pointer;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
overflow: hidden;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 2;
}
.label a {
color: inherit;
text-decoration: none;
}
.label a:hover {
color: var(--primary400);
}
.label:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
cursor: pointer;
}
.percent {
position: relative;
width: 50px;
color: var(--gray600);
border-left: 1px solid var(--gray600);
padding-left: 10px;
z-index: 1;
}
.bar {
position: absolute;
top: 0;
left: 0;
height: 30px;
opacity: 0.1;
background: var(--primary400);
z-index: -1;
}
.body {
position: relative;
flex: 1;
overflow: hidden;
}
.footer {
display: flex;
justify-content: center;

View File

@ -10,17 +10,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
{ label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
].concat(websites.map(({ name, website_id }) => ({ label: name, value: website_id })));
const { pageviews, sessions, events } = data;
const countries = sessions.reduce((obj, { country }) => {
if (country) {
if (!obj[country]) {
obj[country] = 1;
} else {
obj[country] += 1;
}
}
return obj;
}, {});
const { pageviews, sessions, events, countries } = data;
return (
<>
@ -45,7 +35,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
/>
<MetricCard
label={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
value={Object.keys(countries).length}
value={countries.length}
/>
</div>
</>

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import Page from 'components/layout/Page';
import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout';
import RealtimeChart from '../metrics/RealtimeChart';
@ -7,6 +9,11 @@ import RealtimeLog from '../metrics/RealtimeLog';
import styles from './RealtimeDashboard.module.css';
import RealtimeHeader from '../metrics/RealtimeHeader';
import useFetch from 'hooks/useFetch';
import WorldMap from '../common/WorldMap';
import DataTable from '../metrics/DataTable';
import useLocale from 'hooks/useLocale';
import useCountryNames from 'hooks/useCountryNames';
import { FormattedMessage } from 'react-intl';
const REALTIME_RANGE = 30;
const REALTIME_INTERVAL = 5000;
@ -23,6 +30,8 @@ function filterWebsite(data, id) {
}
export default function RealtimeDashboard() {
const [locale] = useLocale();
const countryNames = useCountryNames(locale);
const [data, setData] = useState();
const [websiteId, setWebsiteId] = useState(0);
const { data: init, loading } = useFetch('/api/realtime', { params: { type: 'init' } });
@ -33,41 +42,84 @@ export default function RealtimeDashboard() {
headers: { 'x-umami-token': init?.token },
});
const realtimeData = useMemo(() => {
if (websiteId) {
const { pageviews, sessions, events, ...props } = data;
const countries = sessions.reduce((obj, { country }) => {
if (country) {
if (!obj[country]) {
obj[country] = 1;
} else {
obj[country] += 1;
}
}
return obj;
}, {});
const renderCountryName = useCallback(({ x }) => countryNames[x], []);
return {
pageviews: filterWebsite(pageviews, websiteId),
sessions: filterWebsite(sessions, websiteId),
events: filterWebsite(events, websiteId),
countries,
...props,
};
const realtimeData = useMemo(() => {
if (data) {
const { pageviews, sessions, events } = data;
if (websiteId) {
return {
pageviews: filterWebsite(pageviews, websiteId),
sessions: filterWebsite(sessions, websiteId),
events: filterWebsite(events, websiteId),
};
}
}
return data;
}, [data, websiteId]);
const countries = useMemo(() => {
if (realtimeData?.sessions) {
return percentFilter(
realtimeData.sessions
.reduce((arr, { country }) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(firstBy('y', -1)),
);
}
return [];
}, [realtimeData]);
const referrers = useMemo(() => {
if (realtimeData?.pageviews) {
return percentFilter(
realtimeData.pageviews
.reduce((arr, { referrer }) => {
if (referrer?.startsWith('http')) {
const { hostname } = new URL(referrer);
const row = arr.find(({ x }) => x === hostname);
if (!row) {
arr.push({ x: hostname, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(firstBy('y', -1)),
);
}
return [];
}, [realtimeData]);
useEffect(() => {
if (init && !data) {
setData(init.data);
const { websites, data } = init;
const domains = init.websites.map(({ domain }) => domain);
setData({ websites, domains, ...data });
} else if (updates) {
const { pageviews, sessions, events, timestamp } = updates;
const minTime = subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime();
const time = subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime();
setData(state => ({
pageviews: mergeData(state.pageviews, pageviews, minTime),
sessions: mergeData(state.sessions, sessions, minTime),
events: mergeData(state.events, events, minTime),
...state,
pageviews: mergeData(state.pageviews, pageviews, time),
sessions: mergeData(state.sessions, sessions, time),
events: mergeData(state.events, events, time),
timestamp,
}));
}
@ -77,14 +129,16 @@ export default function RealtimeDashboard() {
return null;
}
const { websites } = init;
const { websites } = data;
//console.log({realtimeData, countries});
return (
<Page>
<RealtimeHeader
websites={websites}
websiteId={websiteId}
data={realtimeData}
data={{ ...realtimeData, countries }}
onSelect={setWebsiteId}
/>
<div className={styles.chart}>
@ -101,15 +155,26 @@ export default function RealtimeDashboard() {
<RealtimeLog data={realtimeData} websites={websites} />
</GridColumn>
<GridColumn xs={12} lg={4}>
x
<DataTable
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
data={referrers}
animate={false}
/>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn xs={12} lg={4}>
x
<DataTable
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
data={countries}
renderLabel={renderCountryName}
animate={false}
/>
</GridColumn>
<GridColumn xs={12} lg={8}>
x
<WorldMap data={countries} />
</GridColumn>
</GridRow>
</GridLayout>

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Adgangskoder matcher ikke",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Påkrævet",
"label.settings": "Indstillinger",
"label.this-month": "Denne måned",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Passwörter stimmen nicht überein",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Erforderlich",
"label.settings": "Einstellungen",
"label.this-month": "Diesen Monat",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Οι κωδικοί πρόσβασης δεν ταιριάζουν",
"label.profile": "Προφίλ",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Απαιτείται",
"label.settings": "Ρυθμίσεις",
"label.this-month": "Αυτο το μήνα",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Passwords don't match",
"label.profile": "Profile",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Required",
"label.settings": "Settings",
"label.this-month": "This month",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Las contraseñas no coinciden",
"label.profile": "Perfil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Requerido",
"label.settings": "Configuraciones",
"label.this-month": "Este mes",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Salasanat eivät täsmää",
"label.profile": "Profiili",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Vaaditaan",
"label.settings": "Asetukset",
"label.this-month": "Tämä kuukausi",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Loyniorðini eru ikki eins",
"label.profile": "Brúkari",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Krav",
"label.settings": "Stillingar",
"label.this-month": "Hendan mánan",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Les mots de passe ne correspondent pas",
"label.profile": "Profile",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Requis",
"label.settings": "Paramètres",
"label.this-month": "Ce mois ci",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Kata sandi tidak cocok",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Wajib",
"label.settings": "Pengaturan",
"label.this-month": "Bulan ini",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "パスワードが一致しません",
"label.profile": "プロファイル",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "必須",
"label.settings": "設定",
"label.this-month": "今月",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Нууц үг тохирохгүй байна",
"label.profile": "Бүртгэл",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Шаардлагатай",
"label.settings": "Тохиргоо",
"label.this-month": "Энэ сар",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Passordene er ikke like",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Påkrevd",
"label.settings": "Innstillinger",
"label.this-month": "Denne måneden",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Wachtwoorden komen niet overeen",
"label.profile": "Profiel",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Verplicht",
"label.settings": "Instellingen",
"label.this-month": "Deze maand",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Palavra-passes não correspondem",
"label.profile": "Perfil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Obrigatório",
"label.settings": "Definições",
"label.this-month": "Este mês",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Parolele nu se potrivesc",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Obligatoriu",
"label.settings": "Setări",
"label.this-month": "Această lună",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Пароли не совпадают",
"label.profile": "Профиль",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Обязательное",
"label.settings": "Настройки",
"label.this-month": "Этот месяц",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Lösenorden är inte samma",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Krävs",
"label.settings": "Inställningar",
"label.this-month": "Denna månad",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Parolalar uyuşmuyor",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Zorunlu alan",
"label.settings": "Ayarlar",
"label.this-month": "Bu ay",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "Паролі не співпадають",
"label.profile": "Профіль",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "Обов'язкове",
"label.settings": "Налаштування",
"label.this-month": "Поточний місяць",

View File

@ -38,6 +38,7 @@
"label.passwords-dont-match": "密码不一致",
"label.profile": "个人资料",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.required": "必填",
"label.settings": "设置",
"label.this-month": "本月",

View File

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "0.90.0",
"version": "0.91.0",
"description": "A simple, fast, website analytics alternative to Google Analytics. ",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",