mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-22 01:46:58 +01:00
Display page views and unique visitors.
This commit is contained in:
parent
bdcdcd9d13
commit
ce92c7897d
@ -15,7 +15,9 @@ export default function DateFilter({ onChange }) {
|
|||||||
return (
|
return (
|
||||||
<select value={selected} onChange={handleChange}>
|
<select value={selected} onChange={handleChange}>
|
||||||
{filterOptions.map(option => (
|
{filterOptions.map(option => (
|
||||||
<option name={option}>{option}</option>
|
<option key={option} name={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
|
11
components/MetricCard.js
Normal file
11
components/MetricCard.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './MetricCard.module.css';
|
||||||
|
|
||||||
|
const MetricCard = ({ value, label }) => (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.value}>{value}</div>
|
||||||
|
<div className={styles.label}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricCard;
|
16
components/MetricCard.module.css
Normal file
16
components/MetricCard.module.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
@ -1,16 +1,9 @@
|
|||||||
import React, { useRef, useEffect, useMemo } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import ChartJS from 'chart.js';
|
import ChartJS from 'chart.js';
|
||||||
import { getLocalTime } from 'lib/date';
|
|
||||||
|
|
||||||
export default function PageviewsChart({ data }) {
|
export default function PageviewsChart({ data }) {
|
||||||
const canvas = useRef();
|
const canvas = useRef();
|
||||||
const chart = useRef();
|
const chart = useRef();
|
||||||
const pageviews = useMemo(() => {
|
|
||||||
if (data) {
|
|
||||||
return data.pageviews.map(({ t, y }) => ({ t: getLocalTime(t), y }));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
if (!canvas.current) return;
|
if (!canvas.current) return;
|
||||||
@ -21,11 +14,19 @@ export default function PageviewsChart({ data }) {
|
|||||||
data: {
|
data: {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'page views',
|
label: 'unique visitors',
|
||||||
data: pageviews,
|
data: data.uniques,
|
||||||
lineTension: 0,
|
lineTension: 0,
|
||||||
backgroundColor: 'rgb(38, 128, 235, 0.1)',
|
backgroundColor: 'rgb(146, 86, 217, 0.2)',
|
||||||
borderColor: 'rgb(13, 102, 208, 0.2)',
|
borderColor: 'rgb(122, 66, 191, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'page views',
|
||||||
|
data: data.pageviews,
|
||||||
|
lineTension: 0,
|
||||||
|
backgroundColor: 'rgb(38, 128, 235, 0.2)',
|
||||||
|
borderColor: 'rgb(13, 102, 208, 0.3)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -52,6 +53,10 @@ export default function PageviewsChart({ data }) {
|
|||||||
},
|
},
|
||||||
tooltipFormat: 'ddd M/DD hA',
|
tooltipFormat: 'ddd M/DD hA',
|
||||||
},
|
},
|
||||||
|
gridLines: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
stacked: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
yAxes: [
|
yAxes: [
|
||||||
@ -59,13 +64,15 @@ export default function PageviewsChart({ data }) {
|
|||||||
ticks: {
|
ticks: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
},
|
},
|
||||||
|
stacked: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
chart.current.data.datasets[0].data = pageviews;
|
chart.current.data.datasets[0].data = data.uniques;
|
||||||
|
chart.current.data.datasets[1].data = data.pageviews;
|
||||||
chart.current.update();
|
chart.current.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ export default function WebsiteList() {
|
|||||||
<DateFilter onChange={handleDateChange} />
|
<DateFilter onChange={handleDateChange} />
|
||||||
{data &&
|
{data &&
|
||||||
data.websites.map(({ website_id, label }) => (
|
data.websites.map(({ website_id, label }) => (
|
||||||
<div>
|
<div key={website_id}>
|
||||||
<h2>{label}</h2>
|
<h2>{label}</h2>
|
||||||
<WebsiteStats
|
<WebsiteStats
|
||||||
websiteId={website_id}
|
websiteId={website_id}
|
||||||
|
@ -1,10 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import PageviewsChart from './PageviewsChart';
|
import PageviewsChart from './PageviewsChart';
|
||||||
import { get } from 'lib/web';
|
import { get } from 'lib/web';
|
||||||
import { getTimezone } from 'lib/date';
|
import { getDateArray, getTimezone } from 'lib/date';
|
||||||
|
import WebsiteSummary from './WebsiteSummary';
|
||||||
|
|
||||||
export default function WebsiteStats({ websiteId, startDate, endDate, unit }) {
|
export default function WebsiteStats({ websiteId, startDate, endDate, unit }) {
|
||||||
const [data, setData] = useState();
|
const [data, setData] = useState();
|
||||||
|
const [pageviews, uniques] = useMemo(() => {
|
||||||
|
if (data) {
|
||||||
|
return [
|
||||||
|
getDateArray(data.pageviews, startDate, endDate, unit),
|
||||||
|
getDateArray(data.uniques, startDate, endDate, unit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [[], []];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setData(
|
setData(
|
||||||
@ -19,7 +29,12 @@ export default function WebsiteStats({ websiteId, startDate, endDate, unit }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [websiteId, startDate, endDate]);
|
}, [websiteId, startDate, endDate, unit]);
|
||||||
|
|
||||||
return <PageviewsChart data={data} />;
|
return (
|
||||||
|
<div>
|
||||||
|
<WebsiteSummary data={{ pageviews, uniques }} />
|
||||||
|
<PageviewsChart data={{ pageviews, uniques }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
16
components/WebsiteSummary.js
Normal file
16
components/WebsiteSummary.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import MetricCard from './MetricCard';
|
||||||
|
import styles from './WebsiteSummary.module.css';
|
||||||
|
|
||||||
|
function getTotal(data) {
|
||||||
|
return data.reduce((n, v) => n + v.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebsiteSummary({ data }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<MetricCard label="Views" value={getTotal(data.pageviews)} />
|
||||||
|
<MetricCard label="Visitors" value={getTotal(data.uniques)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
3
components/WebsiteSummary.module.css
Normal file
3
components/WebsiteSummary.module.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
60
lib/date.js
60
lib/date.js
@ -1,9 +1,24 @@
|
|||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { addMinutes, endOfDay, subDays, subHours } from 'date-fns';
|
import {
|
||||||
|
addMinutes,
|
||||||
|
addHours,
|
||||||
|
startOfDay,
|
||||||
|
endOfHour,
|
||||||
|
endOfDay,
|
||||||
|
startOfHour,
|
||||||
|
addDays,
|
||||||
|
subDays,
|
||||||
|
subHours,
|
||||||
|
differenceInHours,
|
||||||
|
differenceInDays,
|
||||||
|
} from 'date-fns';
|
||||||
|
|
||||||
export function getTimezone() {
|
export function getTimezone() {
|
||||||
const tz = moment.tz.guess();
|
return moment.tz.guess();
|
||||||
return moment.tz.zone(tz).abbr(new Date().getTimezoneOffset());
|
}
|
||||||
|
|
||||||
|
export function getTimezonAbbr() {
|
||||||
|
return moment.tz.zone(getTimezone()).abbr(new Date().getTimezoneOffset());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalTime(t) {
|
export function getLocalTime(t) {
|
||||||
@ -12,26 +27,51 @@ export function getLocalTime(t) {
|
|||||||
|
|
||||||
export function getDateRange(value) {
|
export function getDateRange(value) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const endToday = endOfDay(now);
|
const hour = endOfHour(now);
|
||||||
|
const day = endOfDay(now);
|
||||||
|
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case '7d':
|
case '7d':
|
||||||
return {
|
return {
|
||||||
startDate: subDays(endToday, 7),
|
startDate: subDays(day, 7),
|
||||||
endDate: endToday,
|
endDate: day,
|
||||||
unit: 'day',
|
unit: 'day',
|
||||||
};
|
};
|
||||||
case '30d':
|
case '30d':
|
||||||
return {
|
return {
|
||||||
startDate: subDays(endToday, 30),
|
startDate: subDays(day, 30),
|
||||||
endDate: endToday,
|
endDate: day,
|
||||||
unit: 'day',
|
unit: 'day',
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
startDate: subHours(now, 24),
|
startDate: subHours(hour, 24),
|
||||||
endDate: now,
|
endDate: hour,
|
||||||
unit: 'hour',
|
unit: 'hour',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dateFuncs = {
|
||||||
|
hour: [differenceInHours, addHours, startOfHour],
|
||||||
|
day: [differenceInDays, addDays, startOfDay],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDateArray(data, startDate, endDate, unit) {
|
||||||
|
const arr = [];
|
||||||
|
const [diff, add, normalize] = dateFuncs[unit];
|
||||||
|
const n = diff(endDate, startDate);
|
||||||
|
|
||||||
|
function findData(t) {
|
||||||
|
return data.find(e => getLocalTime(e.t).getTime() === normalize(t).getTime())?.y || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = add(startDate, i + 1);
|
||||||
|
const y = findData(t);
|
||||||
|
|
||||||
|
arr.push({ t, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
15
lib/db.js
15
lib/db.js
@ -1,4 +1,5 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
log: [
|
log: [
|
||||||
@ -9,13 +10,21 @@ const options = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function logQuery(e) {
|
||||||
|
if (process.env.LOG_QUERY) {
|
||||||
|
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let prisma;
|
let prisma;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
prisma = new PrismaClient(options);
|
prisma = new PrismaClient(options);
|
||||||
|
prisma.on('query', logQuery);
|
||||||
} else {
|
} else {
|
||||||
if (!global.prisma) {
|
if (!global.prisma) {
|
||||||
global.prisma = new PrismaClient(options);
|
global.prisma = new PrismaClient(options);
|
||||||
|
global.prisma.on('query', logQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
prisma = global.prisma;
|
prisma = global.prisma;
|
||||||
@ -23,12 +32,6 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
|
||||||
prisma.on('query', e => {
|
|
||||||
if (process.env.LOG_QUERY) {
|
|
||||||
console.log(`${e.params} -> ${e.query} (${e.duration}ms)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function runQuery(query) {
|
export async function runQuery(query) {
|
||||||
return query.catch(e => {
|
return query.catch(e => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -18,8 +18,8 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
res.setHeader('Set-Cookie', [cookie]);
|
res.setHeader('Set-Cookie', [cookie]);
|
||||||
|
|
||||||
res.status(200).json({ token });
|
return res.status(200).json({ token });
|
||||||
} else {
|
|
||||||
res.status(401).end();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(401).end();
|
||||||
};
|
};
|
||||||
|
@ -26,5 +26,5 @@ export default async (req, res) => {
|
|||||||
ok = true;
|
ok = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ ok, session: token });
|
return res.status(200).json({ ok, session: token });
|
||||||
};
|
};
|
||||||
|
@ -5,8 +5,8 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await verifySecureToken(token);
|
const payload = await verifySecureToken(token);
|
||||||
res.status(200).json(payload);
|
return res.status(200).json(payload);
|
||||||
} catch {
|
} catch {
|
||||||
res.status(400).end();
|
return res.status(400).end();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -8,5 +8,5 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
const websites = await getWebsites(user_id);
|
const websites = await getWebsites(user_id);
|
||||||
|
|
||||||
res.status(200).json({ websites });
|
return res.status(200).json({ websites });
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@ -6,10 +7,14 @@ export default async (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) || !['hour', 'day'].includes(unit)) {
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
|
||||||
const [pageviews, uniques] = await Promise.all([
|
const [pageviews, uniques] = await Promise.all([
|
||||||
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, '*'),
|
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, '*'),
|
||||||
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, 'distinct session_id'),
|
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, 'distinct session_id'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.status(200).json({ pageviews, uniques });
|
return res.status(200).json({ pageviews, uniques });
|
||||||
};
|
};
|
||||||
|
@ -9,5 +9,5 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
const pageviews = await getPageviews(+id, new Date(+start_at), new Date(+end_at));
|
const pageviews = await getPageviews(+id, new Date(+start_at), new Date(+end_at));
|
||||||
|
|
||||||
res.status(200).json({ pageviews });
|
return res.status(200).json({ pageviews });
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user