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 }) => { export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => {
const classes = []; const classes = [];
classes.push(xs ? `col-${xs}` : 'col'); classes.push(xs ? `col-${xs}` : 'col-12');
if (sm) { if (sm) {
classes.push(`col-sm-${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 }, query: { url },
} = usePageQuery(); } = usePageQuery();
console.log({ modified });
const { data, error, loading } = useFetch( const { data, error, loading } = useFetch(
`/api/website/${websiteId}/stats`, `/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 { FormattedMessage } from 'react-intl';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames'; import classNames from 'classnames';
import Link from 'components/common/Link'; import Link from 'components/common/Link';
import Loading from 'components/common/Loading'; import Loading from 'components/common/Loading';
import NoData from 'components/common/NoData';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg'; import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters'; import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format';
import useDateRange from 'hooks/useDateRange'; import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery'; 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 styles from './MetricsTable.module.css';
import ErrorMessage from '../common/ErrorMessage';
export default function MetricsTable({ export default function MetricsTable({
websiteId, websiteId,
@ -49,15 +47,12 @@ export default function MetricsTable({
token, token,
}, },
onDataLoad, onDataLoad,
delay: 300, delay: DEFAULT_ANIMATION_DURATION,
}, },
[modified], [modified],
); );
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0;
const rankings = useMemo(() => { const filteredData = useMemo(() => {
if (data) { if (data) {
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data); const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
if (limit) { if (limit) {
@ -68,91 +63,34 @@ export default function MetricsTable({
return []; return [];
}, [data, error, dataFilter, filterOptions]); }, [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 ( return (
<div className={classNames(styles.container, className)}> <div className={classNames(styles.container, className)}>
{!data && loading && <Loading />} {!data && loading && <Loading />}
{error && <ErrorMessage />} {error && <ErrorMessage />}
{data && !error && ( {data && !error && (
<> <DataTable
<div className={styles.header}> title={title}
<div className={styles.title}>{title}</div> data={filteredData}
<div className={styles.metric} onClick={handleSetFormat}> metric={metric}
{metric} className={className}
</div> renderLabel={renderLabel}
</div> limit={limit}
<div className={styles.body}> animate={limit > 0}
{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>
</>
)} )}
<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> </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; 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 { .footer {
display: flex; display: flex;
justify-content: center; 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 }, { label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
].concat(websites.map(({ name, website_id }) => ({ label: name, value: website_id }))); ].concat(websites.map(({ name, website_id }) => ({ label: name, value: website_id })));
const { pageviews, sessions, events } = data; const { pageviews, sessions, events, countries } = data;
const countries = sessions.reduce((obj, { country }) => {
if (country) {
if (!obj[country]) {
obj[country] = 1;
} else {
obj[country] += 1;
}
}
return obj;
}, {});
return ( return (
<> <>
@ -45,7 +35,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
/> />
<MetricCard <MetricCard
label={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />} label={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
value={Object.keys(countries).length} value={countries.length}
/> />
</div> </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 { subMinutes, startOfMinute } from 'date-fns';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import Page from 'components/layout/Page'; import Page from 'components/layout/Page';
import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout'; import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout';
import RealtimeChart from '../metrics/RealtimeChart'; import RealtimeChart from '../metrics/RealtimeChart';
@ -7,6 +9,11 @@ import RealtimeLog from '../metrics/RealtimeLog';
import styles from './RealtimeDashboard.module.css'; import styles from './RealtimeDashboard.module.css';
import RealtimeHeader from '../metrics/RealtimeHeader'; import RealtimeHeader from '../metrics/RealtimeHeader';
import useFetch from 'hooks/useFetch'; 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_RANGE = 30;
const REALTIME_INTERVAL = 5000; const REALTIME_INTERVAL = 5000;
@ -23,6 +30,8 @@ function filterWebsite(data, id) {
} }
export default function RealtimeDashboard() { export default function RealtimeDashboard() {
const [locale] = useLocale();
const countryNames = useCountryNames(locale);
const [data, setData] = useState(); const [data, setData] = useState();
const [websiteId, setWebsiteId] = useState(0); const [websiteId, setWebsiteId] = useState(0);
const { data: init, loading } = useFetch('/api/realtime', { params: { type: 'init' } }); const { data: init, loading } = useFetch('/api/realtime', { params: { type: 'init' } });
@ -33,41 +42,84 @@ export default function RealtimeDashboard() {
headers: { 'x-umami-token': init?.token }, headers: { 'x-umami-token': init?.token },
}); });
const realtimeData = useMemo(() => { const renderCountryName = useCallback(({ x }) => countryNames[x], []);
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;
}, {});
return { const realtimeData = useMemo(() => {
pageviews: filterWebsite(pageviews, websiteId), if (data) {
sessions: filterWebsite(sessions, websiteId), const { pageviews, sessions, events } = data;
events: filterWebsite(events, websiteId),
countries, if (websiteId) {
...props, return {
}; pageviews: filterWebsite(pageviews, websiteId),
sessions: filterWebsite(sessions, websiteId),
events: filterWebsite(events, websiteId),
};
}
} }
return data; return data;
}, [data, websiteId]); }, [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(() => { useEffect(() => {
if (init && !data) { if (init && !data) {
setData(init.data); const { websites, data } = init;
const domains = init.websites.map(({ domain }) => domain);
setData({ websites, domains, ...data });
} else if (updates) { } else if (updates) {
const { pageviews, sessions, events, timestamp } = 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 => ({ setData(state => ({
pageviews: mergeData(state.pageviews, pageviews, minTime), ...state,
sessions: mergeData(state.sessions, sessions, minTime), pageviews: mergeData(state.pageviews, pageviews, time),
events: mergeData(state.events, events, minTime), sessions: mergeData(state.sessions, sessions, time),
events: mergeData(state.events, events, time),
timestamp, timestamp,
})); }));
} }
@ -77,14 +129,16 @@ export default function RealtimeDashboard() {
return null; return null;
} }
const { websites } = init; const { websites } = data;
//console.log({realtimeData, countries});
return ( return (
<Page> <Page>
<RealtimeHeader <RealtimeHeader
websites={websites} websites={websites}
websiteId={websiteId} websiteId={websiteId}
data={realtimeData} data={{ ...realtimeData, countries }}
onSelect={setWebsiteId} onSelect={setWebsiteId}
/> />
<div className={styles.chart}> <div className={styles.chart}>
@ -101,15 +155,26 @@ export default function RealtimeDashboard() {
<RealtimeLog data={realtimeData} websites={websites} /> <RealtimeLog data={realtimeData} websites={websites} />
</GridColumn> </GridColumn>
<GridColumn xs={12} lg={4}> <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> </GridColumn>
</GridRow> </GridRow>
<GridRow> <GridRow>
<GridColumn xs={12} lg={4}> <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>
<GridColumn xs={12} lg={8}> <GridColumn xs={12} lg={8}>
x <WorldMap data={countries} />
</GridColumn> </GridColumn>
</GridRow> </GridRow>
</GridLayout> </GridLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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