Defined how to parse visit stats and how to render them

This commit is contained in:
Alejandro Celaya 2018-07-30 20:31:48 +02:00
parent a75c7309f7
commit d97cbdde5d
7 changed files with 196 additions and 13 deletions

View file

@ -19,6 +19,7 @@
"bootstrap": "^4.1.1", "bootstrap": "^4.1.1",
"case-sensitive-paths-webpack-plugin": "2.1.1", "case-sensitive-paths-webpack-plugin": "2.1.1",
"chalk": "1.1.3", "chalk": "1.1.3",
"chart.js": "^2.7.2",
"css-loader": "0.28.7", "css-loader": "0.28.7",
"dotenv": "4.0.0", "dotenv": "4.0.0",
"dotenv-expand": "4.2.0", "dotenv-expand": "4.2.0",
@ -44,6 +45,7 @@
"raf": "3.4.0", "raf": "3.4.0",
"ramda": "^0.25.0", "ramda": "^0.25.0",
"react": "^16.3.2", "react": "^16.3.2",
"react-chartjs-2": "^2.7.4",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0", "react-datepicker": "^1.5.0",
"react-dev-utils": "^5.0.1", "react-dev-utils": "^5.0.1",

View file

@ -31,13 +31,13 @@ export class ShlinkApiClient {
const filteredOptions = reject(value => isEmpty(value) || isNil(value), options); const filteredOptions = reject(value => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions) return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then(resp => resp.data) .then(resp => resp.data)
.catch(e => this._handleAuthError(e, this.listShortUrls, [filteredOptions])); .catch(e => this._handleAuthError(e, this.createShortUrl, [filteredOptions]));
}; };
getShortUrlVisits = shortCode => getShortUrlVisits = (shortCode, dates) =>
this._performRequest(`/short-codes/${shortCode}/visits`, 'GET') this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then(resp => resp.data.visits.data) .then(resp => resp.data.visits.data)
.catch(e => this._handleAuthError(e, this.listShortUrls, [shortCode])); .catch(e => this._handleAuthError(e, this.getShortUrlVisits, [shortCode, dates]));
_performRequest = async (url, method = 'GET', params = {}, data = {}) => { _performRequest = async (url, method = 'GET', params = {}, data = {}) => {
if (isEmpty(this._token)) { if (isEmpty(this._token)) {

View file

@ -5,6 +5,7 @@ import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
export default combineReducers({ export default combineReducers({
servers: serversReducer, servers: serversReducer,
@ -12,4 +13,5 @@ export default combineReducers({
shortUrlsList: shortUrlsListReducer, shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer, shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationResultReducer, shortUrlCreationResult: shortUrlCreationResultReducer,
shortUrlVisits: shortUrlVisitsReducer
}); });

View file

@ -1,25 +1,68 @@
import React from 'react'; import React from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { pick } from 'ramda'; import { pick } from 'ramda';
import { Card, CardBody, CardHeader } from 'reactstrap';
import { getShortUrlVisits } from './reducers/shortUrlVisits'; import { getShortUrlVisits } from './reducers/shortUrlVisits';
import VisitsParser from '../visits/services/VisitsParser';
export class ShortUrlsVisits extends React.Component { export class ShortUrlsVisits extends React.Component {
state = { startDate: '', endDate: '' };
componentDidMount() { componentDidMount() {
const { match: { params } } = this.props; const { match: { params } } = this.props;
this.props.getShortUrlVisits(params.shortCode); this.props.getShortUrlVisits(params.shortCode, this.state);
} }
render() { render() {
const { match: { params }, selectedServer } = this.props; const { match: { params }, selectedServer, visitsParser, shortUrlVisits } = this.props;
const serverUrl = selectedServer ? selectedServer.url : ''; const serverUrl = selectedServer ? selectedServer.url : '';
const shortUrl = `${serverUrl}/${params.shortCode}`; const shortUrl = `${serverUrl}/${params.shortCode}`;
const generateGraphData = stats => ({
labels: Object.keys(stats),
datasets: Object.values(stats)
});
return ( return (
<div className="short-urls-container"> <div className="short-urls-container">
<div className="card bg-light"> <Card className="bg-light">
<div className="card-body"> <CardBody>
<h2>Visit stats for <a target="_blank" href={shortUrl}>{shortUrl}</a></h2> <h2>Visit stats for <a target="_blank" href={shortUrl}>{shortUrl}</a></h2>
{/* TODO Once Shlink's API allows it, add total visits counter, long URL and creation time */} </CardBody>
</Card>
<div className="row">
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>Operating systems</CardHeader>
<CardBody>
<Doughnut data={generateGraphData(visitsParser.processOsStats(shortUrlVisits))} />
</CardBody>
</Card>
</div>
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>Browsers</CardHeader>
<CardBody>
<Doughnut data={generateGraphData(visitsParser.processBrowserStats(shortUrlVisits))} />
</CardBody>
</Card>
</div>
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>Countries</CardHeader>
<CardBody>
<HorizontalBar data={generateGraphData(visitsParser.processCountriesStats(shortUrlVisits))} />
</CardBody>
</Card>
</div>
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>Referrers</CardHeader>
<CardBody>
<HorizontalBar data={generateGraphData(visitsParser.processReferrersStats(shortUrlVisits))} />
</CardBody>
</Card>
</div> </div>
</div> </div>
</div> </div>
@ -27,6 +70,10 @@ export class ShortUrlsVisits extends React.Component {
} }
} }
export default connect(pick(['selectedServer']), { ShortUrlsVisits.defaultProps = {
visitsParser: VisitsParser
};
export default connect(pick(['selectedServer', 'shortUrlVisits']), {
getShortUrlVisits getShortUrlVisits
})(ShortUrlsVisits); })(ShortUrlsVisits);

View file

@ -34,11 +34,11 @@ export default function dispatch (state = initialState, action) {
} }
} }
export const getShortUrlVisits = shortCode => async dispatch => { export const getShortUrlVisits = (shortCode, dates) => async dispatch => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
try { try {
const visits = await ShlinkApiClient.getShortUrlVisits(shortCode); const visits = await ShlinkApiClient.getShortUrlVisits(shortCode, dates);
dispatch({ visits, type: GET_SHORT_URL_VISITS }); dispatch({ visits, type: GET_SHORT_URL_VISITS });
} catch (e) { } catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); dispatch({ type: GET_SHORT_URL_VISITS_ERROR });

View file

@ -0,0 +1,101 @@
import { forEach, isNil, isEmpty } from 'ramda';
const osFromUserAgent = userAgent => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case (lowerUserAgent.indexOf('linux') >= 0):
return 'Linux';
case (lowerUserAgent.indexOf('windows') >= 0):
return 'Windows';
case (lowerUserAgent.indexOf('mac') >= 0):
return 'MacOS';
case (lowerUserAgent.indexOf('mobi') >= 0):
return 'Mobile';
default:
return 'Others';
}
};
const browserFromUserAgent = userAgent => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case (lowerUserAgent.indexOf('firefox') >= 0):
return 'Firefox';
case (lowerUserAgent.indexOf('chrome') >= 0):
return 'Chrome';
case (lowerUserAgent.indexOf('safari') >= 0):
return 'Safari';
case (lowerUserAgent.indexOf('opera') >= 0):
return 'Opera';
case (lowerUserAgent.indexOf('msie') >= 0):
return 'Internet Explorer';
default:
return 'Others';
}
};
const extractDomain = url => {
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};
// FIXME Refactor these foreach statements which mutate a stats object
export class VisitsParser {
processOsStats = visits => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
stats[os] = typeof stats[os] === 'undefined' ? 1 : stats[os] + 1;
}, visits);
return stats;
};
processBrowserStats = visits => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
stats[browser] = typeof stats[browser] === 'undefined' ? 1 : stats[browser] + 1;
}, visits);
return stats;
};
processReferrersStats = visits => {
const stats = {};
forEach(visit => {
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
stats[domain] = typeof stats[domain] === 'undefined' ? 1 : stats[domain] + 1;
}, visits);
return stats;
};
processCountriesStats = visits => {
const stats = {};
forEach(({ visitLocation }) => {
const notHasCountry = isNil(visitLocation)
|| isNil(visitLocation.countryName)
|| isEmpty(visitLocation.countryName);
const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
stats[country] = typeof stats[country] === 'undefined' ? 1 : stats[country] + 1;
}, visits);
return stats;
};
}
export default new VisitsParser();

View file

@ -1437,6 +1437,26 @@ chardet@^0.4.0:
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
chart.js@^2.7.2:
version "2.7.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.2.tgz#3c9fde4dc5b95608211bdefeda7e5d33dffa5714"
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chartjs-color-string@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1"
dependencies:
color-name "^1.0.0"
chartjs-color@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae"
dependencies:
chartjs-color-string "^0.5.0"
color-convert "^0.5.3"
cheerio@^1.0.0-rc.2: cheerio@^1.0.0-rc.2:
version "1.0.0-rc.2" version "1.0.0-rc.2"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
@ -1589,6 +1609,10 @@ collection-visit@^1.0.0:
map-visit "^1.0.0" map-visit "^1.0.0"
object-visit "^1.0.0" object-visit "^1.0.0"
color-convert@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
color-convert@^1.3.0, color-convert@^1.9.0: color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.2" version "1.9.2"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.2.tgz#49881b8fba67df12a96bdf3f56c0aab9e7913147" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.2.tgz#49881b8fba67df12a96bdf3f56c0aab9e7913147"
@ -4905,7 +4929,7 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi
dependencies: dependencies:
minimist "0.0.8" minimist "0.0.8"
moment@^2.22.2: moment@^2.10.2, moment@^2.22.2:
version "2.22.2" version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
@ -6070,6 +6094,13 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-chartjs-2@^2.7.4:
version "2.7.4"
resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.4.tgz#e41ea4e81491dc78347111126a48e96ee57db1a6"
dependencies:
lodash "^4.17.4"
prop-types "^15.5.8"
react-copy-to-clipboard@^5.0.1: react-copy-to-clipboard@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e"