Merge pull request #99 from mikecao/dev

v0.21.0
This commit is contained in:
Mike Cao 2020-09-03 14:54:18 -07:00 committed by GitHub
commit 681852b225
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 471 additions and 602 deletions

View File

@ -82,6 +82,16 @@ To build the umami container and start up a Postgres database, run:
docker-compose up docker-compose up
``` ```
### Getting updates
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
```
git pull
npm install
npm run build
```
## License ## License
MIT MIT

View File

@ -1,19 +1,28 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { setDateRange } from 'redux/actions/websites'; import { setDateRange } from 'redux/actions/websites';
import Button from './Button'; import Button from './Button';
import Refresh from 'assets/redo.svg'; import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg';
import { useDateRange } from 'hooks/useDateRange'; import { useDateRange } from 'hooks/useDateRange';
import { getDateRange } from '../../lib/date';
export default function RefreshButton({ websiteId }) { export default function RefreshButton({ websiteId }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const dateRange = useDateRange(websiteId); const dateRange = useDateRange(websiteId);
const [loading, setLoading] = useState(false);
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/metrics`]);
function handleClick() { function handleClick() {
if (dateRange) { if (dateRange) {
dispatch(setDateRange(websiteId, dateRange)); setLoading(true);
dispatch(setDateRange(websiteId, getDateRange(dateRange.value)));
} }
} }
return <Button icon={<Refresh />} size="small" onClick={handleClick} />; useEffect(() => {
setLoading(false);
}, [completed]);
return <Button icon={loading ? <Dots /> : <Refresh />} size="small" onClick={handleClick} />;
} }

View File

@ -4,7 +4,7 @@ import classNames from 'classnames';
import ChartJS from 'chart.js'; import ChartJS from 'chart.js';
import styles from './BarChart.module.css'; import styles from './BarChart.module.css';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { formatLongNumber } from '../../lib/format'; import { formatLongNumber } from 'lib/format';
export default function BarChart({ export default function BarChart({
chartId, chartId,
@ -22,30 +22,39 @@ export default function BarChart({
const chart = useRef(); const chart = useRef();
const [tooltip, setTooltip] = useState({}); const [tooltip, setTooltip] = useState({});
const renderXLabel = (label, index, values) => { function renderXLabel(label, index, values) {
const d = new Date(values[index].value); const d = new Date(values[index].value);
const n = records; const w = canvas.current.width;
switch (unit) { switch (unit) {
case 'hour': case 'hour':
return format(d, 'ha'); return format(d, 'ha');
case 'day': case 'day':
if (n >= 15) { if (records > 31) {
return index % ~~(n / 15) === 0 ? format(d, 'MMM d') : ''; if (w <= 500) {
return index % 10 === 0 ? format(d, 'M/d') : '';
}
return index % 5 === 0 ? format(d, 'M/d') : '';
}
if (w <= 500) {
return index % 2 === 0 ? format(d, 'MMM d') : '';
} }
return format(d, 'EEE M/d'); return format(d, 'EEE M/d');
case 'month': case 'month':
if (w <= 660) {
return format(d, 'MMM');
}
return format(d, 'MMMM'); return format(d, 'MMMM');
default: default:
return label; return label;
} }
}; }
const renderYLabel = label => { function renderYLabel(label) {
return +label > 1 ? formatLongNumber(label) : label; return +label > 1 ? formatLongNumber(label) : label;
}; }
const renderTooltip = model => { function renderTooltip(model) {
const { opacity, title, body, labelColors } = model; const { opacity, title, body, labelColors } = model;
if (!opacity) { if (!opacity) {
@ -60,9 +69,9 @@ export default function BarChart({
labelColor: labelColors[0].backgroundColor, labelColor: labelColors[0].backgroundColor,
}); });
} }
}; }
const createChart = () => { function createChart() {
const options = { const options = {
animation: { animation: {
duration: animationDuration, duration: animationDuration,
@ -119,9 +128,9 @@ export default function BarChart({
}, },
options, options,
}); });
}; }
const updateChart = () => { function updateChart() {
const { options } = chart.current; const { options } = chart.current;
options.scales.xAxes[0].time.unit = unit; options.scales.xAxes[0].time.unit = unit;
@ -129,7 +138,7 @@ export default function BarChart({
options.animation.duration = animationDuration; options.animation.duration = animationDuration;
onUpdate(chart.current); onUpdate(chart.current);
}; }
useEffect(() => { useEffect(() => {
if (datasets) { if (datasets) {

View File

@ -21,7 +21,7 @@ export default function WebsiteHeader({ websiteId, title, showLink = false }) {
<Button <Button
icon={<Arrow />} icon={<Arrow />}
onClick={() => onClick={() =>
router.push('/website/[...id]', `/website/${websiteId}/${name}`, { router.push('/website/[...id]', `/website/${websiteId}/${title}`, {
shallow: true, shallow: true,
}) })
} }

View File

@ -1,7 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { get } from 'lib/web'; import { get } from 'lib/web';
import { updateQuery } from 'redux/actions/queries';
export default function useFetch(url, params = {}, options = {}) { export default function useFetch(url, params = {}, options = {}) {
const dispatch = useDispatch();
const [data, setData] = useState(); const [data, setData] = useState();
const [error, setError] = useState(); const [error, setError] = useState();
const keys = Object.keys(params) const keys = Object.keys(params)
@ -12,7 +15,11 @@ export default function useFetch(url, params = {}, options = {}) {
async function loadData() { async function loadData() {
try { try {
setError(null); setError(null);
const time = performance.now();
const data = await get(url, params); const data = await get(url, params);
dispatch(updateQuery({ url, time: performance.now() - time, completed: Date.now() }));
setData(data); setData(data);
onDataLoad(data); onDataLoad(data);
} catch (e) { } catch (e) {

View File

@ -34,7 +34,6 @@ export default prisma;
export async function runQuery(query) { export async function runQuery(query) {
return query.catch(e => { return query.catch(e => {
console.error(e);
throw e; throw e;
}); });
} }

View File

@ -13,7 +13,11 @@ export const urlFilter = (data, { raw }) => {
const cleanUrl = url => { const cleanUrl = url => {
try { try {
const { pathname, searchParams } = new URL(url); const { pathname, search, searchParams } = new URL(url);
if (search.startsWith('?/')) {
return `${pathname}${search}`;
}
const path = removeTrailingSlash(pathname); const path = removeTrailingSlash(pathname);
const ref = searchParams.get('ref'); const ref = searchParams.get('ref');

View File

@ -252,8 +252,9 @@ export function getMetrics(website_id, start_at, end_at) {
const db = getDatabase(); const db = getDatabase();
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select sum(t.c) as "pageviews", select sum(t.c) as "pageviews",
count(distinct t.session_id) as "uniques", count(distinct t.session_id) as "uniques",
sum(case when t.c = 1 then 1 else 0 end) as "bounces", sum(case when t.c = 1 then 1 else 0 end) as "bounces",
@ -269,15 +270,17 @@ export function getMetrics(website_id, start_at, end_at) {
group by 1, 2 group by 1, 2
) t ) t
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
if (db === MYSQL) { if (db === MYSQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select sum(t.c) as "pageviews", select sum(t.c) as "pageviews",
count(distinct t.session_id) as "uniques", count(distinct t.session_id) as "uniques",
sum(case when t.c = 1 then 1 else 0 end) as "bounces", sum(case when t.c = 1 then 1 else 0 end) as "bounces",
@ -293,9 +296,10 @@ export function getMetrics(website_id, start_at, end_at) {
group by 1, 2 group by 1, 2
) t ) t
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
@ -313,8 +317,9 @@ export function getPageviews(
const db = getDatabase(); const db = getDatabase();
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select ${getDateQuery(db, 'created_at', unit, timezone)} t, select ${getDateQuery(db, 'created_at', unit, timezone)} t,
count(${count}) y count(${count}) y
from pageview from pageview
@ -323,15 +328,17 @@ export function getPageviews(
group by 1 group by 1
order by 1 order by 1
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
if (db === MYSQL) { if (db === MYSQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select ${getDateQuery(db, 'created_at', unit, timezone)} t, select ${getDateQuery(db, 'created_at', unit, timezone)} t,
count(${count}) y count(${count}) y
from pageview from pageview
@ -340,9 +347,10 @@ export function getPageviews(
group by 1 group by 1
order by 1 order by 1
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
@ -355,8 +363,9 @@ export function getRankings(website_id, start_at, end_at, type, table, domain) {
const filter = domain ? `and ${type} not like '%${domain}%'` : ''; const filter = domain ? `and ${type} not like '%${domain}%'` : '';
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select distinct ${type} x, count(*) y select distinct ${type} x, count(*) y
from ${table} from ${table}
where website_id=$1 where website_id=$1
@ -365,15 +374,17 @@ export function getRankings(website_id, start_at, end_at, type, table, domain) {
group by 1 group by 1
order by 2 desc order by 2 desc
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
if (db === MYSQL) { if (db === MYSQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select distinct ${type} x, count(*) y select distinct ${type} x, count(*) y
from ${table} from ${table}
where website_id=? where website_id=?
@ -382,9 +393,10 @@ export function getRankings(website_id, start_at, end_at, type, table, domain) {
group by 1 group by 1
order by 2 desc order by 2 desc
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
@ -396,28 +408,32 @@ export function getActiveVisitors(website_id) {
const date = subMinutes(new Date(), 5); const date = subMinutes(new Date(), 5);
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select count(distinct session_id) x select count(distinct session_id) x
from pageview from pageview
where website_id=$1 where website_id=$1
and created_at >= $2 and created_at >= $2
`, `,
website_id, website_id,
date, date,
),
); );
} }
if (db === MYSQL) { if (db === MYSQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select count(distinct session_id) x select count(distinct session_id) x
from pageview from pageview
where website_id=? where website_id=?
and created_at >= ? and created_at >= ?
`, `,
website_id, website_id,
date, date,
),
); );
} }
@ -428,8 +444,9 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit =
const db = getDatabase(); const db = getDatabase();
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select select
event_value x, event_value x,
${getDateQuery(db, 'created_at', unit, timezone)} t, ${getDateQuery(db, 'created_at', unit, timezone)} t,
@ -440,15 +457,17 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit =
group by 1, 2 group by 1, 2
order by 2 order by 2
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }
if (db === MYSQL) { if (db === MYSQL) {
return prisma.$queryRaw( return runQuery(
` prisma.$queryRaw(
`
select select
event_value x, event_value x,
${getDateQuery(db, 'created_at', unit, timezone)} t, ${getDateQuery(db, 'created_at', unit, timezone)} t,
@ -459,9 +478,10 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit =
group by 1, 2 group by 1, 2
order by 2 order by 2
`, `,
website_id, website_id,
start_at, start_at,
end_at, end_at,
),
); );
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "0.20.0", "version": "0.21.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",
@ -39,14 +39,14 @@
} }
}, },
"dependencies": { "dependencies": {
"@prisma/client": "2.5.1", "@prisma/client": "2.6.1",
"@reduxjs/toolkit": "^1.4.0", "@reduxjs/toolkit": "^1.4.0",
"bcrypt": "^5.0.0", "bcrypt": "^5.0.0",
"chart.js": "^2.9.3", "chart.js": "^2.9.3",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"cookie": "^0.4.1", "cookie": "^0.4.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^2.16.0", "date-fns": "^2.16.1",
"date-fns-tz": "^1.0.10", "date-fns-tz": "^1.0.10",
"detect-browser": "^5.1.1", "detect-browser": "^5.1.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@ -57,7 +57,7 @@
"jose": "^1.28.0", "jose": "^1.28.0",
"maxmind": "^4.1.4", "maxmind": "^4.1.4",
"moment-timezone": "^0.5.31", "moment-timezone": "^0.5.31",
"next": "^9.5.2", "next": "^9.5.3",
"promise-polyfill": "^8.1.3", "promise-polyfill": "^8.1.3",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
@ -75,30 +75,30 @@
"uuid": "^8.3.0" "uuid": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@prisma/cli": "2.5.1", "@prisma/cli": "2.6.1",
"@rollup/plugin-buble": "^0.21.3", "@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-node-resolve": "^9.0.0",
"@rollup/plugin-replace": "^2.3.3", "@rollup/plugin-replace": "^2.3.3",
"@svgr/webpack": "^5.4.0", "@svgr/webpack": "^5.4.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"dotenv-cli": "^3.2.0", "dotenv-cli": "^3.2.0",
"eslint": "^7.7.0", "eslint": "^7.8.1",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.6", "eslint-plugin-react": "^7.20.6",
"eslint-plugin-react-hooks": "^4.1.0", "eslint-plugin-react-hooks": "^4.1.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"lint-staged": "^10.2.13", "lint-staged": "^10.3.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss-flexbugs-fixes": "^4.2.1", "postcss-flexbugs-fixes": "^4.2.1",
"postcss-import": "^12.0.1", "postcss-import": "^12.0.1",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"prettier": "^2.1.1", "prettier": "^2.1.1",
"prettier-eslint": "^11.0.0", "prettier-eslint": "^11.0.0",
"rollup": "^2.26.6", "rollup": "^2.26.9",
"rollup-plugin-hashbang": "^2.2.2", "rollup-plugin-hashbang": "^2.2.2",
"rollup-plugin-terser": "^7.0.0", "rollup-plugin-terser": "^7.0.1",
"stylelint": "^13.6.0", "stylelint": "^13.7.0",
"stylelint-config-css-modules": "^2.2.0", "stylelint-config-css-modules": "^2.2.0",
"stylelint-config-prettier": "^8.0.1", "stylelint-config-prettier": "^8.0.1",
"stylelint-config-recommended": "^3.0.0" "stylelint-config-recommended": "^3.0.0"

17
redux/actions/queries.js Normal file
View File

@ -0,0 +1,17 @@
import { createSlice } from '@reduxjs/toolkit';
const queries = createSlice({
name: 'queries',
initialState: {},
reducers: {
updateQuery(state, action) {
const { url, ...data } = action.payload;
state[url] = data;
return state;
},
},
});
export const { updateQuery } = queries.actions;
export default queries.reducer;

View File

@ -1,33 +1,29 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import produce from 'immer';
const websites = createSlice({ const websites = createSlice({
name: 'user', name: 'websites',
initialState: {}, initialState: {},
reducers: { reducers: {
updateWebsites(state, action) { updateWebsites(state, action) {
state = action.payload; state = action.payload;
return state; return state;
}, },
updateWebsite(state, action) {
const { websiteId, ...data } = action.payload;
state[websiteId] = data;
return state;
},
}, },
}); });
export const { updateWebsites } = websites.actions; export const { updateWebsites, updateWebsite } = websites.actions;
export default websites.reducer; export default websites.reducer;
export function setDateRange(websiteId, dateRange) { export function setDateRange(websiteId, dateRange) {
return (dispatch, getState) => { return dispatch => {
const state = getState(); return dispatch(
let { websites = {} } = state; updateWebsite({ websiteId, dateRange: { ...dateRange, modified: Date.now() } }),
);
websites = produce(websites, draft => {
if (!draft[websiteId]) {
draft[websiteId] = {};
}
draft[websiteId].dateRange = { ...dateRange, modified: Date.now() };
});
return dispatch(updateWebsites(websites));
}; };
} }

View File

@ -1,5 +1,6 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import user from './actions/user'; import user from './actions/user';
import websites from './actions/websites'; import websites from './actions/websites';
import queries from './actions/queries';
export default combineReducers({ user, websites }); export default combineReducers({ user, websites, queries });

View File

@ -61,12 +61,13 @@ import { removeTrailingSlash } from '../lib/url';
const handlePush = (state, title, navigatedURL) => { const handlePush = (state, title, navigatedURL) => {
removeEvents(); removeEvents();
currentRef = currentUrl; currentRef = currentUrl;
const newUrl = navigatedURL.toString();
if (navigatedURL.toString().startsWith('http')) { if (newUrl.startsWith('http')) {
const url = new URL(navigatedURL.toString()); const url = new URL(newUrl);
currentUrl = `${url.pathname}${url.search}`; currentUrl = `${url.pathname}${url.search}`;
} else { } else {
currentUrl = navigatedURL.toString(); currentUrl = newUrl;
} }
pageView(); pageView();
@ -101,4 +102,8 @@ import { removeTrailingSlash } from '../lib/url';
/* Start */ /* Start */
pageView(); pageView();
if (!window.umami) {
window.umami = event_value => collect('event', { event_type: 'custom', event_value });
}
})(window); })(window);

800
yarn.lock

File diff suppressed because it is too large Load Diff