mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-15 01:35:17 +01:00
Details page.
This commit is contained in:
parent
5b45301178
commit
ac2612924e
63
components/RankingsChart.js
Normal file
63
components/RankingsChart.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useSpring, animated } from 'react-spring';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { get } from 'lib/web';
|
||||||
|
import styles from './RankingsChart.module.css';
|
||||||
|
|
||||||
|
export default function RankingsChart({
|
||||||
|
title,
|
||||||
|
websiteId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
filterData,
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState();
|
||||||
|
|
||||||
|
const rankings = useMemo(() => (data && filterData ? filterData(data) : data), [data]);
|
||||||
|
|
||||||
|
const total = useMemo(() => rankings?.reduce((n, { y }) => n + y, 0) || 0, [rankings]);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setData(
|
||||||
|
await get(`/api/website/${websiteId}/rankings`, {
|
||||||
|
start_at: +startDate,
|
||||||
|
end_at: +endDate,
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (websiteId) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [websiteId, startDate, endDate, type]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <h1>loading...</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.container, className)}>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
{rankings.map(({ x, y }, i) => (i <= 10 ? <Row label={x} value={y} total={total} /> : null))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ label, value, total }) => {
|
||||||
|
const props = useSpring({ width: `${(value / total) * 100}%`, from: { width: '0%' } });
|
||||||
|
const valueProps = useSpring({ y: value, from: { y: 0 } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.label}>{label}</div>
|
||||||
|
<animated.div className={styles.value}>
|
||||||
|
{valueProps.y.interpolate(y => y.toFixed(0))}
|
||||||
|
</animated.div>
|
||||||
|
<animated.div className={styles.bar} style={{ ...props }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
38
components/RankingsChart.module.css
Normal file
38
components/RankingsChart.module.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
height: 30px;
|
||||||
|
opacity: 0.1;
|
||||||
|
background: #2680eb;
|
||||||
|
}
|
@ -7,9 +7,13 @@ import QuickButtons from './QuickButtons';
|
|||||||
import styles from './WebsiteChart.module.css';
|
import styles from './WebsiteChart.module.css';
|
||||||
import DateFilter from './DateFilter';
|
import DateFilter from './DateFilter';
|
||||||
|
|
||||||
export default function WebsiteChart({ title, websiteId }) {
|
export default function WebsiteChart({
|
||||||
|
websiteId,
|
||||||
|
defaultDateRange = '7day',
|
||||||
|
onDateChange = () => {},
|
||||||
|
}) {
|
||||||
const [data, setData] = useState();
|
const [data, setData] = useState();
|
||||||
const [dateRange, setDateRange] = useState(getDateRange('7day'));
|
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
|
||||||
const { startDate, endDate, unit, value } = dateRange;
|
const { startDate, endDate, unit, value } = dateRange;
|
||||||
|
|
||||||
const [pageviews, uniques] = useMemo(() => {
|
const [pageviews, uniques] = useMemo(() => {
|
||||||
@ -24,6 +28,7 @@ export default function WebsiteChart({ title, websiteId }) {
|
|||||||
|
|
||||||
function handleDateChange(values) {
|
function handleDateChange(values) {
|
||||||
setDateRange(values);
|
setDateRange(values);
|
||||||
|
onDateChange(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@ -43,7 +48,6 @@ export default function WebsiteChart({ title, websiteId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.title}>{title}</div>
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<MetricsBar websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
<MetricsBar websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
<DateFilter value={value} onChange={handleDateChange} />
|
<DateFilter value={value} onChange={handleDateChange} />
|
||||||
|
118
components/WebsiteDetails.js
Normal file
118
components/WebsiteDetails.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { get } from 'lib/web';
|
||||||
|
import WebsiteChart from './WebsiteChart';
|
||||||
|
import RankingsChart from './RankingsChart';
|
||||||
|
import { getDateRange } from '../lib/date';
|
||||||
|
import styles from './WebsiteDetails.module.css';
|
||||||
|
|
||||||
|
const osFilter = data => data.map(({ x, y }) => ({ x: !x ? 'Unknown' : x, y }));
|
||||||
|
|
||||||
|
const urlFilter = data => data.filter(({ x }) => x !== '' && !x.startsWith('#'));
|
||||||
|
|
||||||
|
const refFilter = data =>
|
||||||
|
data.filter(({ x }) => x !== '' && !x.startsWith('/') && !x.startsWith('#'));
|
||||||
|
|
||||||
|
const deviceFilter = data => {
|
||||||
|
const devices = data.reduce(
|
||||||
|
(obj, { x, y }) => {
|
||||||
|
const [width] = x.split('x');
|
||||||
|
if (width >= 1920) {
|
||||||
|
obj.desktop += +y;
|
||||||
|
} else if (width >= 1024) {
|
||||||
|
obj.laptop += +y;
|
||||||
|
} else if (width >= 767) {
|
||||||
|
obj.tablet += +y;
|
||||||
|
} else {
|
||||||
|
obj.mobile += +y;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
{ desktop: 0, laptop: 0, tablet: 0, mobile: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(devices).map(key => ({ x: key, y: devices[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
|
||||||
|
const [data, setData] = useState();
|
||||||
|
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setData(await get(`/api/website/${websiteId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(values) {
|
||||||
|
setDateRange(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (websiteId) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [websiteId]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <h1>loading...</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col">
|
||||||
|
<h1>{data.label}</h1>
|
||||||
|
<WebsiteChart websiteId={data.website_id} onDateChange={handleDateChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classNames(styles.row, 'row justify-content-between')}>
|
||||||
|
<RankingsChart
|
||||||
|
title="Top URLs"
|
||||||
|
type="url"
|
||||||
|
className="col-12 col-md-8 col-lg-6"
|
||||||
|
websiteId={data.website_id}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
filterData={urlFilter}
|
||||||
|
/>
|
||||||
|
<RankingsChart
|
||||||
|
title="Top referrers"
|
||||||
|
type="referrer"
|
||||||
|
className="col-12 col-md-8 col-lg-6"
|
||||||
|
websiteId={data.website_id}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
filterData={refFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classNames(styles.row, 'row justify-content-between')}>
|
||||||
|
<RankingsChart
|
||||||
|
title="Browsers"
|
||||||
|
type="browser"
|
||||||
|
className="col-12 col-md-8 col-lg-4"
|
||||||
|
websiteId={data.website_id}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
/>
|
||||||
|
<RankingsChart
|
||||||
|
title="Operating system"
|
||||||
|
type="os"
|
||||||
|
className="col-12 col-md-8 col-lg-4"
|
||||||
|
websiteId={data.website_id}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
filterData={osFilter}
|
||||||
|
/>
|
||||||
|
<RankingsChart
|
||||||
|
title="Devices"
|
||||||
|
type="screen"
|
||||||
|
className="col-12 col-md-8 col-lg-4"
|
||||||
|
websiteId={data.website_id}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
filterData={deviceFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
3
components/WebsiteDetails.module.css
Normal file
3
components/WebsiteDetails.module.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.row {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { get } from 'lib/web';
|
import { get } from 'lib/web';
|
||||||
import WebsiteChart from './WebsiteChart';
|
import WebsiteChart from './WebsiteChart';
|
||||||
import styles from './WebsiteList.module.css';
|
import styles from './WebsiteList.module.css';
|
||||||
@ -17,8 +18,15 @@ export default function WebsiteList() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{data &&
|
{data &&
|
||||||
data.websites.map(({ website_id, label }) => (
|
data.websites.map(({ website_id, website_uuid, label }) => (
|
||||||
|
<>
|
||||||
|
<h2>
|
||||||
|
<Link href={`/${website_uuid}`}>
|
||||||
|
<a>{label}</a>
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
|
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
18
lib/db.js
18
lib/db.js
@ -154,6 +154,24 @@ export async function getPageviews(website_id, start_at, end_at) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRankings(website_id, start_at, end_at, type, table) {
|
||||||
|
return runQuery(
|
||||||
|
prisma.queryRaw(
|
||||||
|
`
|
||||||
|
select distinct "${type}" x, count(*) y
|
||||||
|
from "${table}"
|
||||||
|
where website_id=$1
|
||||||
|
and created_at between $2 and $3
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
`,
|
||||||
|
website_id,
|
||||||
|
start_at,
|
||||||
|
end_at,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPageviewData(
|
export async function getPageviewData(
|
||||||
website_id,
|
website_id,
|
||||||
start_at,
|
start_at,
|
||||||
|
@ -24,10 +24,6 @@ export function formatTime(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatShortTime(val, formats = ['m', 's'], space = '') {
|
export function formatShortTime(val, formats = ['m', 's'], space = '') {
|
||||||
if (val <= 0) {
|
|
||||||
return `0${formats[formats.length - 1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { days, hours, minutes, seconds, ms } = parseTime(val);
|
const { days, hours, minutes, seconds, ms } = parseTime(val);
|
||||||
let t = '';
|
let t = '';
|
||||||
|
|
||||||
@ -37,5 +33,9 @@ export function formatShortTime(val, formats = ['m', 's'], space = '') {
|
|||||||
if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`;
|
if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`;
|
||||||
if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`;
|
if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`;
|
||||||
|
|
||||||
|
if (!t) {
|
||||||
|
return `0${formats[formats.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
15
pages/[id].js
Normal file
15
pages/[id].js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Layout from 'components/Layout';
|
||||||
|
import WebsiteDetails from '../components/WebsiteDetails';
|
||||||
|
|
||||||
|
export default function DetailsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<WebsiteDetails websiteId={id} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
12
pages/api/website/[id]/index.js
Normal file
12
pages/api/website/[id]/index.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { getWebsite } from 'lib/db';
|
||||||
|
import { useAuth } from 'lib/middleware';
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
const website = await getWebsite(id);
|
||||||
|
|
||||||
|
return res.status(200).json(website);
|
||||||
|
};
|
@ -2,12 +2,14 @@ import moment from 'moment-timezone';
|
|||||||
import { getPageviewData } from 'lib/db';
|
import { getPageviewData } from 'lib/db';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
|
|
||||||
|
const unitTypes = ['month', 'hour', 'day'];
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const { id, start_at, end_at, unit, tz } = req.query;
|
const { id, start_at, end_at, unit, tz } = req.query;
|
||||||
|
|
||||||
if (!moment.tz.zone(tz) || !['month', 'hour', 'day'].includes(unit)) {
|
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
|
||||||
return res.status(400).end();
|
return res.status(400).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
21
pages/api/website/[id]/rankings.js
Normal file
21
pages/api/website/[id]/rankings.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { getRankings } from 'lib/db';
|
||||||
|
import { useAuth } from 'lib/middleware';
|
||||||
|
|
||||||
|
const sessionColumns = ['browser', 'os', 'screen'];
|
||||||
|
const pageviewColumns = ['url', 'referrer'];
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
const { id, type, start_at, end_at } = req.query;
|
||||||
|
|
||||||
|
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = sessionColumns.includes(type) ? 'session' : 'pageview';
|
||||||
|
|
||||||
|
const rankings = await getRankings(+id, new Date(+start_at), new Date(+end_at), type, table);
|
||||||
|
|
||||||
|
return res.status(200).json(rankings);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user