Realtime dashboard (#212)

* Auto-updating dashboard with realtime info

* Remove extra route

* Draw list of countries next to the map

* Nice animations

* Do not show bounce rates in realtime modals

* Update countries and devices in realtime

* Remove unused component

* Show total pageviews in the last 30 minutes

* Show proper labels

* Remove unnecessary z-index

* Fix label for main graph

* Fix compiler warnings

* Add tests

* Fix copy pluralizations

* Fix copy in countries modal

* Real-time -> Realtime

* Looser test assertion

* Show last 30 minutes conversions on realtime report

* Remove EventTarget API because it doesn't work on Safari

* Get referrer drilldown from sessions table

* Fix failing tests
This commit is contained in:
Uku Taht 2020-07-14 16:52:26 +03:00 committed by GitHub
parent 1c501db394
commit 232298d327
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 492 additions and 143 deletions

View File

@ -95,9 +95,8 @@ blockquote {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 20px;
height: 20px;
width: 10px;
height: 10px;
}
.pulsating-circle:before {
@ -110,8 +109,8 @@ blockquote {
margin-left: -100%;
margin-top: -100%;
border-radius: 45px;
background-color: #01a4e9;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
background-color: #9ae6b4;
animation: pulse-ring 3s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
@apply bg-green-500;
}
.pulsating-circle:after {
@ -124,7 +123,7 @@ blockquote {
height: 100%;
background-color: white;
border-radius: 15px;
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite;
animation: pulse-dot 3s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite;
@apply bg-green-500;
}
@ -133,7 +132,10 @@ blockquote {
0% {
transform: scale(.33);
}
80%, 100% {
50% {
transform: scale(1);
}
40%, 100% {
opacity: 0;
}
}
@ -142,10 +144,10 @@ blockquote {
0% {
transform: scale(.8);
}
50% {
25% {
transform: scale(1);
}
100% {
50%, 100% {
transform: scale(.8);
}
}

View File

@ -87,6 +87,8 @@ class DatePicker extends React.Component {
return 'Last 6 months'
} else if (query.period === '12mo') {
return 'Last 12 months'
} else if (query.period === 'realtime') {
return 'Realtime'
} else if (query.period === 'custom') {
return `${formatDayShort(query.from)} - ${formatDayShort(query.to)}`
}
@ -171,6 +173,7 @@ class DatePicker extends React.Component {
<div className="rounded bg-white shadow-xs font-medium text-gray-800">
<div className="py-1">
{ this.renderLink('day', 'Today') }
{ this.renderLink('realtime', 'Realtime') }
</div>
<div className="border-t border-gray-200"></div>
<div className="py-1">

View File

@ -0,0 +1,48 @@
import React from 'react';
import Datepicker from './datepicker'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Referrers from './stats/referrers'
import Pages from './stats/pages'
import Countries from './stats/countries'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
export default class Historical extends React.Component {
renderConversions() {
if (this.props.site.hasGoals) {
return (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.props.query} />
</div>
)
}
}
render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
<CurrentVisitors timer={this.props.timer} site={this.props.site} />
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
<Filters query={this.props.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.props.query} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.props.query} />
<Pages site={this.props.site} query={this.props.query} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.props.query} />
<Devices site={this.props.site} query={this.props.query} />
</div>
{ this.renderConversions() }
</div>
)
}
}

View File

@ -1,22 +1,38 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
import Datepicker from './datepicker'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Referrers from './stats/referrers'
import Pages from './stats/pages'
import Countries from './stats/countries'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import Historical from './historical'
import Realtime from './realtime'
import {parseQuery} from './query'
import * as api from './api'
class Stats extends React.Component {
const THIRTY_SECONDS = 30000
class Timer {
constructor() {
this.listeners = []
this.intervalId = setInterval(this.dispatchTick.bind(this), THIRTY_SECONDS)
}
onTick(listener) {
this.listeners.push(listener)
}
dispatchTick() {
for (const listener of this.listeners) {
listener()
}
}
}
class Dashboard extends React.Component {
constructor(props) {
super(props)
this.state = {query: parseQuery(props.location.search, this.props.site)}
this.state = {
query: parseQuery(props.location.search, this.props.site),
timer: new Timer()
}
}
componentDidUpdate(prevProps) {
@ -26,40 +42,13 @@ class Stats extends React.Component {
}
}
renderConversions() {
if (this.props.site.hasGoals) {
return (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.state.query} />
</div>
)
}
}
render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
<CurrentVisitors site={this.props.site} />
</div>
<Datepicker site={this.props.site} query={this.state.query} />
</div>
<Filters query={this.state.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.state.query} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.state.query} />
<Pages site={this.props.site} query={this.state.query} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.state.query} />
<Devices site={this.props.site} query={this.state.query} />
</div>
{ this.renderConversions() }
</div>
)
if (this.state.query.period === 'realtime') {
return <Realtime timer={this.state.timer} site={this.props.site} query={this.state.query} />
} else {
return <Historical timer={this.state.timer} site={this.props.site} query={this.state.query} />
}
}
}
export default withRouter(Stats)
export default withRouter(Dashboard)

View File

@ -1,6 +1,6 @@
import {formatDay, formatMonthYYYY, nowInOffset, parseUTCDate} from './date'
const PERIODS = ['day', 'month', '7d', '30d', '60d', '6mo', '12mo', 'custom']
const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '60d', '6mo', '12mo', 'custom']
export function parseQuery(querystring, site) {
const q = new URLSearchParams(querystring)
@ -8,7 +8,7 @@ export function parseQuery(querystring, site) {
const periodKey = 'period__' + site.domain
if (PERIODS.includes(period)) {
if (period !== 'custom') window.localStorage[periodKey] = period
if (period !== 'custom' && period !== 'realtime') window.localStorage[periodKey] = period
} else {
if (window.localStorage[periodKey]) {
period = window.localStorage[periodKey]

View File

@ -0,0 +1,48 @@
import React from 'react';
import Datepicker from './datepicker'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Referrers from './stats/referrers'
import Pages from './stats/pages'
import Countries from './stats/countries'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
export default class Stats extends React.Component {
renderConversions() {
if (this.props.site.hasGoals) {
return (
<div className="w-full block md:flex items-start justify-between mt-6">
<Conversions site={this.props.site} query={this.props.query} title="Goal Conversions (last 30 min)" />
</div>
)
}
}
render() {
return (
<div className="mb-12">
<div className="w-full sm:flex justify-between items-center">
<div className="w-full flex items-center">
<h2 className="text-left mr-8 font-semibold text-xl">Analytics for <a href={`http://${this.props.site.domain}`} target="_blank">{this.props.site.domain}</a></h2>
</div>
<Datepicker site={this.props.site} query={this.props.query} />
</div>
<Filters query={this.props.query} history={this.props.history} />
<VisitorGraph site={this.props.site} query={this.props.query} timer={this.props.timer} />
<div className="w-full block md:flex items-start justify-between">
<Referrers site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Pages site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>
<div className="w-full block md:flex items-start justify-between">
<Countries site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Devices site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>
{ this.renderConversions() }
</div>
)
}
}

View File

@ -28,15 +28,27 @@ export default class Conversions extends React.Component {
.then((res) => this.setState({loading: false, goals: res}))
}
renderGoal(goal) {
renderGoalText(goalName) {
if (this.props.query.period === 'realtime') {
return <span className="block px-2" style={{marginTop: '-26px'}}>{goalName}</span>
} else {
const query = new URLSearchParams(window.location.search)
query.set('goal', goal.name)
query.set('goal', goalName)
return (
<Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
{ goalName }
</Link>
)
}
}
renderGoal(goal) {
return (
<div className="flex items-center justify-between my-2 text-sm" key={goal.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
<Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">{ goal.name }</Link>
{this.renderGoalText(goal.name)}
</div>
<span className="font-medium">{numberFormatter(goal.count)}</span>
</div>
@ -53,7 +65,7 @@ export default class Conversions extends React.Component {
} else if (this.state.goals) {
return (
<div className="w-full bg-white shadow-xl rounded p-4">
<h3 className="font-bold">Goal Conversions</h3>
<h3 className="font-bold">{this.props.title || "Goal Conversions"}</h3>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Goal</span>
<span>Conversions</span>

View File

@ -10,14 +10,13 @@ export default class Countries extends React.Component {
constructor(props) {
super(props)
this.resizeMap = this.resizeMap.bind(this)
this.state = {
loading: true
}
this.state = {loading: true}
}
componentDidMount() {
this.fetchCountries()
this.fetchCountries().then(this.drawMap.bind(this))
window.addEventListener('resize', this.resizeMap);
if (this.props.timer) this.props.timer.onTick(this.updateCountries.bind(this))
}
componentWillUnmount() {
@ -31,17 +30,7 @@ export default class Countries extends React.Component {
}
}
fetchCountries() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query)
.then((res) => this.setState({loading: false, countries: res}))
.then(() => this.drawMap())
}
resizeMap() {
this.map && this.map.resize()
}
drawMap() {
getDataset() {
var dataset = {};
var onlyValues = this.state.countries.map(function(obj){ return obj.count });
@ -56,6 +45,28 @@ export default class Countries extends React.Component {
dataset[item.name] = {numberOfThings: item.count, fillColor: paletteScale(item.count)};
});
return dataset
}
updateCountries() {
this.fetchCountries().then(() => {
this.map.updateChoropleth(this.getDataset(), {reset: true})
})
}
fetchCountries() {
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query)
.then((res) => this.setState({loading: false, countries: res}))
}
resizeMap() {
this.map && this.map.resize()
}
drawMap() {
var dataset = this.getDataset();
const label = this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
this.map = new Datamap({
element: document.getElementById('map-container'),
responsive: true,
@ -73,7 +84,7 @@ export default class Countries extends React.Component {
if (!data) { return ; }
return ['<div class="hoverinfo">',
'<strong>', geo.properties.name, '</strong>',
'<br><strong>', data.numberOfThings, '</strong> Visitors',
'<br><strong>', data.numberOfThings, '</strong> ' + label,
'</div>'].join('');
}
}

View File

@ -1,6 +1,5 @@
import React from 'react';
const THIRTY_SECONDS = 30000
import { Link } from 'react-router-dom'
export default class CurrentVisitors extends React.Component {
constructor(props) {
@ -9,13 +8,8 @@ export default class CurrentVisitors extends React.Component {
}
componentDidMount() {
this.updateCount().then(() => {
this.intervalId = setInterval(this.updateCount.bind(this), THIRTY_SECONDS)
})
}
componentWillUnMount() {
clearInverval(this.intervalId)
this.updateCount()
this.props.timer.onTick(this.updateCount.bind(this))
}
updateCount() {
@ -30,12 +24,12 @@ export default class CurrentVisitors extends React.Component {
render() {
if (this.state.currentVisitors !== null) {
return (
<div className="text-sm font-bold text-gray-500 mt-1">
<Link to={`/${encodeURIComponent(this.props.site.domain)}?period=realtime`} className="block text-sm font-bold text-gray-500 mt-1">
<svg className="w-2 mr-2 fill-current text-green-500 inline" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8"/>
</svg>
{this.state.currentVisitors} current visitors
</div>
</Link>
)
} else {
return null

View File

@ -46,6 +46,7 @@ class ScreenSizes extends React.Component {
componentDidMount() {
this.fetchScreenSizes()
if (this.props.timer) this.props.timer.onTick(this.fetchScreenSizes.bind(this))
}
componentDidUpdate(prevProps) {
@ -72,13 +73,17 @@ class ScreenSizes extends React.Component {
)
}
label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderList() {
if (this.state.sizes && this.state.sizes.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Screen size</span>
<span>Visitors</span>
<span>{ this.label() }</span>
</div>
{ this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) }
</React.Fragment>
@ -108,6 +113,7 @@ class Browsers extends React.Component {
componentDidMount() {
this.fetchBrowsers()
if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this))
}
componentDidUpdate(prevProps) {
@ -134,13 +140,17 @@ class Browsers extends React.Component {
)
}
label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderList() {
if (this.state.browsers && this.state.browsers.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Browser</span>
<span>Visitors</span>
<span>{ this.label() }</span>
</div>
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
</React.Fragment>
@ -170,6 +180,7 @@ class OperatingSystems extends React.Component {
componentDidMount() {
this.fetchOperatingSystems()
if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this))
}
componentDidUpdate(prevProps) {
@ -196,13 +207,17 @@ class OperatingSystems extends React.Component {
)
}
label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderList() {
if (this.state.operatingSystems && this.state.operatingSystems.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Operating system</span>
<span>Visitors</span>
<span>{ this.label() }</span>
</div>
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
</React.Fragment>
@ -232,11 +247,11 @@ export default class Devices extends React.Component {
renderContent() {
if (this.state.mode === 'size') {
return <ScreenSizes site={this.props.site} query={this.props.query} />
return <ScreenSizes site={this.props.site} query={this.props.query} timer={this.props.timer} />
} else if (this.state.mode === 'browser') {
return <Browsers site={this.props.site} query={this.props.query} />
return <Browsers site={this.props.site} query={this.props.query} timer={this.props.timer} />
} else if (this.state.mode === 'os') {
return <OperatingSystems site={this.props.site} query={this.props.query} />
return <OperatingSystems site={this.props.site} query={this.props.query} timer={this.props.timer} />
}
}

View File

@ -10,13 +10,14 @@ import {parseQuery} from '../../query'
class CountriesModal extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site)
}
}
componentDidMount() {
const query = parseQuery(this.props.location.search, this.props.site)
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, query, {limit: 100})
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 100})
.then((res) => this.setState({loading: false, countries: res}))
}
@ -29,6 +30,10 @@ class CountriesModal extends React.Component {
)
}
label() {
return this.state.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderBody() {
if (this.state.loading) {
return (
@ -45,7 +50,7 @@ class CountriesModal extends React.Component {
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Country</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Visitors</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">{this.label()}</th>
</tr>
</thead>
<tbody>

View File

@ -23,7 +23,7 @@ class PagesModal extends React.Component {
}
showBounceRate() {
return !this.state.query.filters.goal
return this.state.query.period !== 'realtime' && !this.state.query.filters.goal
}
formatBounceRate(page) {
@ -44,6 +44,10 @@ class PagesModal extends React.Component {
)
}
label() {
return this.state.query.period === 'realtime' ? 'Active visitors' : 'Pageviews'
}
renderBody() {
if (this.state.loading) {
return (
@ -60,7 +64,7 @@ class PagesModal extends React.Component {
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Page url</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Pageviews</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">{ this.label() }</th>
{this.showBounceRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Bounce rate</th>}
</tr>
</thead>

View File

@ -29,7 +29,7 @@ class ReferrerDrilldownModal extends React.Component {
}
showBounceRate() {
return !this.state.query.filters.goal
return this.state.query.period !== 'realtime' && !this.state.query.filters.goal
}
formatBounceRate(ref) {

View File

@ -29,7 +29,7 @@ class ReferrersModal extends React.Component {
}
showBounceRate() {
return !this.state.query.filters.goal
return this.state.query.period !== 'realtime' && !this.state.query.filters.goal
}
formatBounceRate(page) {
@ -53,6 +53,10 @@ class ReferrersModal extends React.Component {
)
}
label() {
return this.state.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderBody() {
if (this.state.loading) {
return (
@ -69,7 +73,7 @@ class ReferrersModal extends React.Component {
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Referrer</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Visitors</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">{this.label()}</th>
{this.showBounceRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Bounce rate</th>}
</tr>
</thead>

View File

@ -1,4 +1,5 @@
import React from 'react';
import FlipMove from 'react-flip-move';
import FadeIn from '../fade-in'
import Bar from './bar'
@ -10,13 +11,12 @@ import * as api from '../api'
export default class Pages extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true
}
this.state = {loading: true}
}
componentDidMount() {
this.fetchPages()
if (this.props.timer) this.props.timer.onTick(this.fetchPages.bind(this))
}
componentDidUpdate(prevProps) {
@ -43,16 +43,22 @@ export default class Pages extends React.Component {
)
}
label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Pageviews'
}
renderList() {
if (this.state.pages.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Page url</span>
<span>Pageviews</span>
<span>{ this.label() }</span>
</div>
<FlipMove>
{ this.state.pages.map(this.renderPage.bind(this)) }
</FlipMove>
</React.Fragment>
)
} else {

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move';
import FadeIn from '../fade-in'
import Bar from './bar'
@ -15,6 +16,7 @@ export default class Referrers extends React.Component {
componentDidMount() {
this.fetchReferrers()
if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this))
}
componentDidUpdate(prevProps) {
@ -49,16 +51,22 @@ export default class Referrers extends React.Component {
)
}
label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}
renderList() {
if (this.state.referrers.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Referrer</span>
<span>Visitors</span>
<span>{ this.label() }</span>
</div>
<FlipMove>
{this.state.referrers.map(this.renderReferrer.bind(this))}
</FlipMove>
</React.Fragment>
)
} else {

View File

@ -6,7 +6,7 @@ import { eventName } from '../query'
import numberFormatter from '../number-formatter'
import * as api from '../api'
function mainSet(plot, present_index, ctx) {
function mainSet(plot, present_index, ctx, label) {
var gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)');
gradient.addColorStop(1, 'rgba(101,116,205, 0)');
@ -19,7 +19,7 @@ function mainSet(plot, present_index, ctx) {
}
return [{
label: 'Visitors',
label: label,
data: plot,
borderWidth: 3,
borderColor: 'rgba(101,116,205)',
@ -27,7 +27,7 @@ function mainSet(plot, present_index, ctx) {
backgroundColor: gradient,
},
{
label: 'Visitors',
label: label,
data: dashedPlot,
borderWidth: 3,
borderDash: [5, 10],
@ -37,7 +37,7 @@ function mainSet(plot, present_index, ctx) {
}]
} else {
return [{
label: 'Visitors',
label: label,
data: plot,
borderWidth: 3,
borderColor: 'rgba(101,116,205)',
@ -89,7 +89,7 @@ function compareSet(plot, present_index, ctx) {
}
function dataSets(graphData, ctx) {
const dataSets = mainSet(graphData.plot, graphData.present_index, ctx)
const dataSets = mainSet(graphData.plot, graphData.present_index, ctx, graphData.interval === 'minute' ? 'Pageviews' : 'Visitors')
if (graphData.compare_plot) {
return dataSets.concat(compareSet(graphData.compare_plot, graphData.present_index, ctx))
@ -105,13 +105,13 @@ const MONTHS = [
"November", "December"
]
function dateFormatter(graphData) {
function dateFormatter(interval, longForm) {
return function(isoDate) {
let date = new Date(isoDate)
if (graphData.interval === 'month') {
if (interval === 'month') {
return MONTHS[date.getUTCMonth()];
} else if (graphData.interval === 'date') {
} else if (interval === 'date') {
return date.getUTCDate() + ' ' + MONTHS[date.getUTCMonth()];
} else if (graphData.interval === 'hour') {
const parts = isoDate.split(/[^0-9]/);
@ -121,6 +121,13 @@ function dateFormatter(graphData) {
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
return hours + ampm;
} else if (interval === 'minute') {
if (longForm) {
const minutesAgo = Math.abs(isoDate)
return minutesAgo === 1 ? '1 minute ago' : minutesAgo + ' minutes ago'
} else {
return isoDate + 'm'
}
}
}
}
@ -128,13 +135,13 @@ function dateFormatter(graphData) {
class LineGraph extends React.Component {
componentDidMount() {
const {graphData} = this.props
const ctx = document.getElementById("main-graph-canvas").getContext('2d');
this.ctx = document.getElementById("main-graph-canvas").getContext('2d');
this.chart = new Chart(ctx, {
this.chart = new Chart(this.ctx, {
type: 'line',
data: {
labels: graphData.labels,
datasets: dataSets(graphData, ctx)
datasets: dataSets(graphData, this.ctx)
},
options: {
animation: false,
@ -160,7 +167,7 @@ class LineGraph extends React.Component {
callbacks: {
title: function(dataPoints) {
const data = dataPoints[0]
return dateFormatter(graphData)(data.xLabel)
return dateFormatter(graphData.interval, true)(data.xLabel)
},
beforeBody: function() {
this.drawnLabels = {}
@ -201,7 +208,7 @@ class LineGraph extends React.Component {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
callback: dateFormatter(graphData),
callback: dateFormatter(graphData.interval),
}
}]
}
@ -209,6 +216,18 @@ class LineGraph extends React.Component {
});
}
componentDidUpdate(prevProps) {
if (this.props.graphData !== prevProps.graphData) {
const newDataset = dataSets(this.props.graphData, this.ctx)
for (let i = 0; i < newDataset[0].data.length; i++) {
this.chart.data.datasets[0].data[i] = newDataset[0].data[i]
}
this.chart.update()
}
}
onClick(e) {
const query = new URLSearchParams(window.location.search)
const element = this.chart.getElementsAtEventForMode(e, 'index', {intersect: false})[0]
@ -240,7 +259,7 @@ class LineGraph extends React.Component {
renderTopStats() {
const {graphData} = this.props
return this.props.graphData.top_stats.map((stat, index) => {
const stats = this.props.graphData.top_stats.map((stat, index) => {
let border = index > 0 ? 'lg:border-l border-gray-300' : ''
border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border
@ -254,6 +273,12 @@ class LineGraph extends React.Component {
</div>
)
})
if (graphData.interval === 'minute') {
stats.push(<div key="dot" className="block pulsating-circle" style={{left: '125px', top: '52px'}}></div>)
}
return stats
}
downloadLink() {
@ -295,6 +320,7 @@ export default class VisitorGraph extends React.Component {
componentDidMount() {
this.fetchGraphData()
if (this.props.timer) this.props.timer.onTick(this.fetchGraphData.bind(this))
}
componentDidUpdate(prevProps) {
@ -322,7 +348,7 @@ export default class VisitorGraph extends React.Component {
render() {
return (
<div className="w-full bg-white shadow-xl rounded mt-6 main-graph">
<div className="w-full relative bg-white shadow-xl rounded mt-6 main-graph">
{ this.state.loading && <div className="loading pt-24 sm:pt-32 md:pt-48 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderInner() }

View File

@ -6964,6 +6964,11 @@
"prop-types": "^15.5.10"
}
},
"react-flip-move": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-flip-move/-/react-flip-move-3.0.4.tgz",
"integrity": "sha512-HyUVv9g3t/BS7Yz9HgrtYSWyRNdR2F81nkj+C5iRY675AwlqCLB5JU9mnZWg0cdVz7IM4iquoyZx70vzZv3Z8Q=="
},
"react-is": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz",

View File

@ -26,6 +26,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-flatpickr": "^3.10.0",
"react-flip-move": "^3.0.4",
"react-router-dom": "^5.1.2",
"tailwindcss": "^1.3.1",
"uglifyjs-webpack-plugin": "^2.2.0",

View File

@ -155,8 +155,30 @@ defmodule Plausible.Stats.Clickhouse do
{plot, compare_plot, labels, present_index}
end
def calculate_plot(site, %Query{period: "realtime"}) do
groups =
Clickhouse.all(
from e in "events",
where: e.domain == ^site.domain,
where: e.timestamp >= fragment("now() - INTERVAL 31 MINUTE"),
select:
{
fragment("dateDiff('minute', now(), ?) as relativeMinute", e.timestamp),
fragment("count(*) as pageviews")
},
group_by: fragment("relativeMinute"),
order_by: fragment("relativeMinute")
)
|> Enum.map(fn row -> {row["relativeMinute"], row["pageviews"]} end)
|> Enum.into(%{})
labels = Enum.into(-30..-1, [])
plot = Enum.map(labels, fn label -> groups[label] || 0 end)
{plot, nil, labels, nil}
end
def bounce_rate(site, query) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
[res] =
Clickhouse.all(
@ -169,6 +191,18 @@ defmodule Plausible.Stats.Clickhouse do
res["bounce_rate"] || 0
end
def total_pageviews(site, %Query{period: "realtime"}) do
[res] =
Clickhouse.all(
from e in "events",
select: fragment("count(*) as pageviews"),
where: e.timestamp >= fragment("now() - INTERVAL 30 MINUTE"),
where: e.domain == ^site.domain
)
res["pageviews"]
end
def pageviews_and_visitors(site, query) do
[res] =
Clickhouse.all(
@ -213,7 +247,7 @@ defmodule Plausible.Stats.Clickhouse do
end)
end
def top_referrers(site, query, limit \\ 5, include \\ []) do
def top_referrers(site, query, limit, include) do
referrers =
Clickhouse.all(
from e in base_session_query(site, query),
@ -241,7 +275,7 @@ defmodule Plausible.Stats.Clickhouse do
end
defp bounce_rates_by_referrer_source(site, query) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
Clickhouse.all(
from s in "sessions",
@ -262,7 +296,7 @@ defmodule Plausible.Stats.Clickhouse do
def visitors_from_referrer(site, query, referrer) do
[res] =
Clickhouse.all(
from e in base_query(site, query),
from e in base_session_query(site, query),
select: fragment("uniq(user_id) as visitors"),
where: e.referrer_source == ^referrer
)
@ -292,7 +326,7 @@ defmodule Plausible.Stats.Clickhouse do
def referrer_drilldown(site, query, referrer, include \\ []) do
referring_urls =
Clickhouse.all(
from e in base_query(site, query),
from e in base_session_query(site, query),
select: {fragment("? as name", e.referrer), fragment("uniq(user_id) as count")},
group_by: e.referrer,
where: e.referrer_source == ^referrer,
@ -349,7 +383,7 @@ defmodule Plausible.Stats.Clickhouse do
end
defp bounce_rates_by_referring_url(site, query) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
Clickhouse.all(
from s in "sessions",
@ -367,7 +401,17 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.into(%{})
end
def top_pages(site, query, limit \\ 5, include \\ []) do
def top_pages(site, %Query{period: "realtime"} = query, limit, _include) do
Clickhouse.all(
from s in base_session_query(site, query),
select: {fragment("? as name", s.exit_page), fragment("uniq(?) as count", s.user_id)},
group_by: s.exit_page,
order_by: [desc: fragment("count")],
limit: ^limit
)
end
def top_pages(site, query, limit, include) do
pages =
Clickhouse.all(
from e in base_query(site, query),
@ -386,7 +430,7 @@ defmodule Plausible.Stats.Clickhouse do
end
defp bounce_rates_by_page_url(site, query) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
Clickhouse.all(
from s in "sessions",
@ -520,6 +564,7 @@ defmodule Plausible.Stats.Clickhouse do
def goal_conversions(site, query) do
goals = Repo.all(from g in Plausible.Goal, where: g.domain == ^site.domain)
query = if query.period == "realtime", do: %Query{query | period: "30m"}, else: query
(fetch_pageview_goals(goals, site, query) ++
fetch_event_goals(goals, site, query))
@ -565,8 +610,17 @@ defmodule Plausible.Stats.Clickhouse do
Enum.sort_by(conversions, fn conversion -> -conversion["count"] end)
end
defp base_session_query(site, %Query{period: "realtime"}) do
first_datetime = Timex.now(site.timezone) |> Timex.shift(minutes: -5) |> Timex.Timezone.convert("UTC")
from(s in "sessions",
where: s.domain == ^site.domain,
where: s.timestamp >= ^first_datetime
)
end
defp base_session_query(site, query) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
from(s in "sessions",
where: s.domain == ^site.domain,
@ -575,7 +629,7 @@ defmodule Plausible.Stats.Clickhouse do
end
defp base_query(site, query, events \\ ["pageview"]) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
{goal_event, path} = event_name_for_goal(query)
q =
@ -598,7 +652,19 @@ defmodule Plausible.Stats.Clickhouse do
end
end
defp date_range_utc_boundaries(date_range, timezone) do
defp utc_boundaries(%Query{period: "30m"}, timezone) do
last_datetime = NaiveDateTime.utc_now() |> Timex.to_datetime(timezone) |> Timex.Timezone.convert("UTC")
first_datetime = last_datetime |> Timex.shift(minutes: -30)
{first_datetime, last_datetime}
end
defp utc_boundaries(%Query{period: "realtime"}, timezone) do
last_datetime = NaiveDateTime.utc_now() |> Timex.to_datetime(timezone) |> Timex.Timezone.convert("UTC")
first_datetime = last_datetime |> Timex.shift(minutes: -5)
{first_datetime, last_datetime}
end
defp utc_boundaries(%Query{date_range: date_range}, timezone) do
{:ok, first} = NaiveDateTime.new(date_range.first, ~T[00:00:00])
first_datetime =

View File

@ -13,6 +13,16 @@ defmodule Plausible.Stats.Query do
Map.put(query, :date_range, Date.range(new_first, new_last))
end
def from(tz, %{"period" => "realtime"}) do
date = today(tz)
%__MODULE__{
period: "realtime",
step_type: "minute",
date_range: Date.range(date, date)
}
end
def from(_tz, %{"period" => "day", "date" => date} = params) do
date = Date.from_iso8601!(date)

View File

@ -23,6 +23,19 @@ defmodule PlausibleWeb.Api.StatsController do
})
end
defp fetch_top_stats(site, %Query{period: "realtime"} = query) do
[
%{
name: "Active visitors",
count: Stats.current_visitors(site),
},
%{
name: "Pageviews (last 30 min)",
count: Stats.total_pageviews(site, query),
}
]
end
defp fetch_top_stats(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do
prev_query = Query.shift_back(query)
total_visitors = Stats.unique_visitors(site, %{query | filters: %{}})

View File

@ -52,8 +52,8 @@ defmodule Plausible.Workers.SendEmailReport do
bounce_rate = Stats.bounce_rate(site, query)
prev_bounce_rate = Stats.bounce_rate(site, Query.shift_back(query))
change_bounce_rate = if prev_bounce_rate > 0, do: bounce_rate - prev_bounce_rate
referrers = Stats.top_referrers(site, query)
pages = Stats.top_pages(site, query)
referrers = Stats.top_referrers(site, query, 5, [])
pages = Stats.top_pages(site, query, 5, [])
user = Plausible.Auth.find_user_by(email: email)
login_link = user && Plausible.Sites.is_owner?(user.id, site)

View File

@ -20,6 +20,14 @@ defmodule Plausible.Stats.QueryTest do
assert q.step_type == "hour"
end
test "parses realtime format" do
q = Query.from(@tz, %{"period" => "realtime"})
assert q.date_range.first == Timex.today()
assert q.date_range.last == Timex.today()
assert q.period == "realtime"
end
test "parses month format" do
q = Query.from(@tz, %{"period" => "month", "date" => "2019-01-01"})

View File

@ -5,6 +5,15 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
describe "GET /api/stats/main-graph - plot" do
setup [:create_user, :log_in, :create_site]
test "displays pageviews for the last 30 minutes in realtime graph", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime")
assert %{"plot" => plot} = json_response(conn, 200)
assert Enum.count(plot) == 30
assert Enum.any?(plot, fn pageviews -> pageviews > 0 end)
end
test "displays visitors for a day", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01")

View File

@ -30,5 +30,13 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
%{"bounce_rate" => nil, "count" => 1, "name" => "/irrelevant"}
]
end
test "returns top pages in realtime report", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime")
assert json_response(conn, 200) == [
%{"count" => 3, "name" => "/"}
]
end
end
end

View File

@ -31,6 +31,15 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
%{"name" => "Bing", "count" => 1, "bounce_rate" => 0, "url" => ""}
]
end
test "returns top referrer sources in realtime report", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=realtime")
assert json_response(conn, 200) == [
%{"name" => "10words", "count" => 2, "url" => "10words.com"},
%{"name" => "Bing", "count" => 1, "url" => ""}
]
end
end
describe "GET /api/stats/:domain/goal/referrers" do
@ -74,8 +83,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
assert json_response(conn, 200) == %{
"total_visitors" => 2,
"referrers" => [
%{"name" => "10words.com/page2", "count" => 1},
%{"name" => "10words.com/page1", "count" => 1}
%{"name" => "10words.com/page1", "count" => 2}
]
}
end
@ -90,8 +98,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
assert json_response(conn, 200) == %{
"total_visitors" => 2,
"referrers" => [
%{"name" => "10words.com/page2", "count" => 1, "bounce_rate" => nil},
%{"name" => "10words.com/page1", "count" => 1, "bounce_rate" => 50.0}
%{"name" => "10words.com/page1", "count" => 2, "bounce_rate" => 50.0}
]
}
end

View File

@ -237,7 +237,54 @@ defmodule Plausible.Test.ClickhouseSetup do
referrer: "",
is_bounce: false,
start: ~N[2019-01-01 03:00:00]
}
},
%{
domain: "test-site.com",
entry_page: "/",
exit_page: "/",
referrer_source: "Google",
referrer: "",
is_bounce: false,
start: ~N[2019-02-01 01:00:00],
timestamp: ~N[2019-02-01 01:00:00]
},
%{
domain: "test-site.com",
entry_page: "/",
exit_page: "/",
referrer_source: "Google",
referrer: "",
is_bounce: false,
start: ~N[2019-02-01 02:00:00],
timestamp: ~N[2019-02-01 02:00:00]
},
%{
domain: "test-site.com",
entry_page: "/",
exit_page: "/",
referrer: "t.co/some-link",
referrer_source: "Twitter",
start: ~N[2019-03-01 01:00:00],
timestamp: ~N[2019-03-01 01:00:00]
},
%{
domain: "test-site.com",
entry_page: "/",
exit_page: "/",
referrer: "t.co/some-link",
referrer_source: "Twitter",
start: ~N[2019-03-01 01:00:00],
timestamp: ~N[2019-03-01 01:00:00]
},
%{
domain: "test-site.com",
entry_page: "/",
exit_page: "/",
referrer: "t.co/nonexistent-link",
referrer_source: "Twitter",
start: ~N[2019-03-01 02:00:00],
timestamp: ~N[2019-03-01 02:00:00]
},
])
end
end