mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Defined how to parse visit stats and how to render them
This commit is contained in:
parent
a75c7309f7
commit
d97cbdde5d
7 changed files with 196 additions and 13 deletions
|
@ -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",
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
101
src/visits/services/VisitsParser.js
Normal file
101
src/visits/services/VisitsParser.js
Normal 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();
|
33
yarn.lock
33
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue