mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Realtime dashboard improvements (#2445)
* add a new realtime-update-timer module * hook to the new 'tick' event in ListReport for auto-updates This commit fixes the bug where all reports using the `ListReport` component did not auto-update in realtime mode. Those reports are: - Pages (Top / Entry / Exit) - Locations (Countries / Regions / Cities) - Devices (Screen Sizes / Browsers + versions / OS-s + versions) * fetch data for ListReports only when scrolled into view * refactor fetching data in ListReport * refer to one source of truth for utm tags * make the 'All' tab in Sources auto-update * make all UTM tabs in Sources auto-update * fetch UTM data only when scrolled into view * auto-update Referrers with the new timer * auto-update google search terms * auto-update Conversions * make countries map auto-update * auto-update visitor-graph and top stats with new timer * use new tick event for current visitors (in Historical) * remove the old timer class * update changelog * Visual improvements to automatic realtime updates (#2532) * minor consistency fix for text color in dark mode * use FlipMove in goal conversions report * use FlipMove in ListReports * set main graph and top stats loading state correctly * refactor isIntervalValid function * enforce intervals are valid when set and stored * remove duplicate data fetching on interval change Fetching new data is handled by the `fetchGraphData` callback in `updateInterval` * refactor updateMetric function * make it clearer why 'metric' can be a faulty value * extract 'query' and 'site' variables from 'this.props' * reset interval state only when period is changed The 'maybeRollbackInterval' function was also used to fetch data. This commit replaces all those function calls with 'fetchGraphData' which better describes the actual behavior. We should only worry about rolling back the interval if 'query.period' has changed. This commit also stops the graph from flickering when it is updated in realtime. * update names of two variables * remove unnecessary negation * make collapsed graph state more explicit * consider stored invalid intervals when graph mounts * fix not showing loading spinner regression * remove interval state from VisitorGraph (#2540) * Realtime prop breakdown (#2535) * disable load more in realtime mode * extract doFetch function * separate fetchPropBreakdown and fetchNextPage functions * subscribe for auto-updates in realtime * improve readability with function name changes
This commit is contained in:
parent
22e2ae1844
commit
47e21121db
@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- Automatically update all visible dashboard reports in the realtime view
|
||||
|
||||
### Changed
|
||||
- Reject events with long URIs and data URIs plausible/analytics#2536
|
||||
- Always show direct traffic in sources reports plausible/analytics#2531
|
||||
|
@ -32,7 +32,7 @@ function Historical(props) {
|
||||
<div className="items-center w-full flex">
|
||||
<div className="flex items-center w-full">
|
||||
<SiteSwitcher site={props.site} loggedIn={props.loggedIn} currentUserRole={props.currentUserRole} />
|
||||
<CurrentVisitors timer={props.timer} site={props.site} query={props.query} />
|
||||
<CurrentVisitors site={props.site} query={props.query} />
|
||||
<Filters className="flex" site={props.site} query={props.query} history={props.history} />
|
||||
</div>
|
||||
<Datepicker site={props.site} query={props.query} />
|
||||
|
@ -7,31 +7,11 @@ import {parseQuery} from './query'
|
||||
import * as api from './api'
|
||||
import { withComparisonProvider } from './comparison-provider-hoc';
|
||||
|
||||
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),
|
||||
timer: new Timer()
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,9 +24,9 @@ class Dashboard extends React.Component {
|
||||
|
||||
render() {
|
||||
if (this.state.query.period === 'realtime') {
|
||||
return <Realtime timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
||||
return <Realtime site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
||||
} else {
|
||||
return <Historical timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
||||
return <Historical site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,9 @@ import 'url-search-params-polyfill';
|
||||
import Router from './router'
|
||||
import ErrorBoundary from './error-boundary'
|
||||
import * as api from './api'
|
||||
import * as timer from './util/realtime-update-timer'
|
||||
|
||||
timer.start()
|
||||
|
||||
const container = document.getElementById('stats-react-container')
|
||||
|
||||
|
@ -39,14 +39,14 @@ class Realtime extends React.Component {
|
||||
<Datepicker site={this.props.site} query={this.props.query} />
|
||||
</div>
|
||||
</div>
|
||||
<VisitorGraph site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
<VisitorGraph site={this.props.site} query={this.props.query} />
|
||||
<div className="items-start justify-between block w-full md:flex">
|
||||
<Sources 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} />
|
||||
<Sources site={this.props.site} query={this.props.query} />
|
||||
<Pages site={this.props.site} query={this.props.query} />
|
||||
</div>
|
||||
<div className="items-start justify-between block w-full md:flex">
|
||||
<Locations 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} />
|
||||
<Locations site={this.props.site} query={this.props.query} />
|
||||
<Devices site={this.props.site} query={this.props.query} />
|
||||
</div>
|
||||
|
||||
{ this.renderConversions() }
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import FlipMove from 'react-flip-move'
|
||||
|
||||
|
||||
import Bar from '../bar'
|
||||
import PropBreakdown from './prop-breakdown'
|
||||
@ -20,7 +22,7 @@ export default class Conversions extends React.Component {
|
||||
viewport: DEFAULT_WIDTH,
|
||||
}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
|
||||
this.fetchConversions = this.fetchConversions.bind(this)
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
}
|
||||
|
||||
@ -31,6 +33,7 @@ export default class Conversions extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize, false);
|
||||
document.removeEventListener('tick', this.fetchConversions)
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
@ -39,6 +42,9 @@ export default class Conversions extends React.Component {
|
||||
|
||||
onVisible() {
|
||||
this.fetchConversions()
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchConversions)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -102,8 +108,9 @@ export default class Conversions extends React.Component {
|
||||
<span className="inline-block w-20">CR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ this.state.goals.map(this.renderGoal.bind(this)) }
|
||||
<FlipMove>
|
||||
{ this.state.goals.map(this.renderGoal.bind(this)) }
|
||||
</FlipMove>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import * as api from '../../api'
|
||||
|
||||
const MOBILE_UPPER_WIDTH = 767
|
||||
const DEFAULT_WIDTH = 1080
|
||||
const BREAKDOWN_LIMIT = 100
|
||||
|
||||
// https://stackoverflow.com/a/43467144
|
||||
function isValidHttpUrl(string) {
|
||||
@ -45,17 +46,25 @@ export default class PropertyBreakdown extends React.Component {
|
||||
}
|
||||
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.fetch = this.fetch.bind(this)
|
||||
this.fetchAndReplace = this.fetchAndReplace.bind(this)
|
||||
this.fetchAndConcat = this.fetchAndConcat.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.handleResize, false);
|
||||
|
||||
this.handleResize();
|
||||
this.fetchPropBreakdown()
|
||||
this.fetchAndReplace()
|
||||
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchAndReplace)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize, false);
|
||||
document.removeEventListener('tick', this.fetchAndReplace)
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
@ -67,19 +76,31 @@ export default class PropertyBreakdown extends React.Component {
|
||||
return viewport > MOBILE_UPPER_WIDTH ? "16rem" : "10rem";
|
||||
}
|
||||
|
||||
fetchPropBreakdown() {
|
||||
if (this.props.query.filters['goal']) {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, {limit: 100, page: this.state.page})
|
||||
.then((res) => this.setState((state) => ({
|
||||
loading: false,
|
||||
breakdown: state.breakdown.concat(res),
|
||||
moreResultsAvailable: res.length === 100
|
||||
})))
|
||||
}
|
||||
fetch({concat}) {
|
||||
if (!this.props.query.filters['goal']) return
|
||||
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, {limit: BREAKDOWN_LIMIT, page: this.state.page})
|
||||
.then((res) => {
|
||||
let breakdown = concat ? this.state.breakdown.concat(res) : res
|
||||
|
||||
this.setState(() => ({
|
||||
loading: false,
|
||||
breakdown: breakdown,
|
||||
moreResultsAvailable: res.length >= BREAKDOWN_LIMIT
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fetchAndReplace() {
|
||||
this.fetch({concat: false})
|
||||
}
|
||||
|
||||
fetchAndConcat() {
|
||||
this.fetch({concat: true})
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
this.setState({loading: true, page: this.state.page + 1}, this.fetchPropBreakdown.bind(this))
|
||||
this.setState({loading: true, page: this.state.page + 1}, this.fetchAndConcat.bind(this))
|
||||
}
|
||||
|
||||
renderUrl(value) {
|
||||
@ -143,13 +164,13 @@ export default class PropertyBreakdown extends React.Component {
|
||||
|
||||
changePropKey(newKey) {
|
||||
storage.setItem(this.storageKey, newKey)
|
||||
this.setState({propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false}, this.fetchPropBreakdown)
|
||||
this.setState({propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false}, this.fetchAndReplace)
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
if (this.state.loading) {
|
||||
return <div className="px-4 py-2"><div className="loading sm mx-auto"><div></div></div></div>
|
||||
} else if (this.state.moreResultsAvailable) {
|
||||
} else if (this.state.moreResultsAvailable && this.props.query.period !== 'realtime') {
|
||||
return (
|
||||
<div className="w-full text-center my-4">
|
||||
<button onClick={this.loadMore.bind(this)} type="button" className="button">
|
||||
|
@ -8,11 +8,16 @@ export default class CurrentVisitors extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {currentVisitors: null}
|
||||
this.updateCount = this.updateCount.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateCount()
|
||||
this.props.timer.onTick(this.updateCount.bind(this))
|
||||
document.addEventListener('tick', this.updateCount)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.updateCount)
|
||||
}
|
||||
|
||||
updateCount() {
|
||||
|
@ -139,18 +139,18 @@ export default class Devices extends React.Component {
|
||||
switch (this.state.mode) {
|
||||
case 'browser':
|
||||
if (this.props.query.filters.browser) {
|
||||
return <BrowserVersions site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <BrowserVersions site={this.props.site} query={this.props.query} />
|
||||
}
|
||||
return <Browsers site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <Browsers site={this.props.site} query={this.props.query} />
|
||||
case 'os':
|
||||
if (this.props.query.filters.os) {
|
||||
return <OperatingSystemVersions site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <OperatingSystemVersions site={this.props.site} query={this.props.query} />
|
||||
}
|
||||
return <OperatingSystems site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <OperatingSystems site={this.props.site} query={this.props.query} />
|
||||
case 'size':
|
||||
default:
|
||||
return (
|
||||
<ScreenSizes site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
<ScreenSizes site={this.props.site} query={this.props.query} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||
import dateFormatter from './date-formatter.js'
|
||||
|
||||
export const INTERVALS = ["month", "week", "date", "hour", "minute"]
|
||||
|
||||
export const METRIC_MAPPING = {
|
||||
'Unique visitors (last 30 min)': 'visitors',
|
||||
'Pageviews (last 30 min)': 'pageviews',
|
||||
|
@ -5,7 +5,7 @@ import { navigateToQuery } from '../../query'
|
||||
import * as api from '../../api'
|
||||
import * as storage from '../../util/storage'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
import {GraphTooltip, buildDataSet, INTERVALS, METRIC_MAPPING, METRIC_LABELS, METRIC_FORMATTER} from './graph-util';
|
||||
import {GraphTooltip, buildDataSet, METRIC_MAPPING, METRIC_LABELS, METRIC_FORMATTER} from './graph-util';
|
||||
import dateFormatter from './date-formatter';
|
||||
import TopStats from './top-stats';
|
||||
import { IntervalPicker, getStoredInterval, storeInterval } from './interval-picker';
|
||||
@ -233,7 +233,7 @@ class LineGraph extends React.Component {
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const interval = this.props.graphData?.interval || this.state.interval
|
||||
const interval = this.props.graphData?.interval
|
||||
const queryParams = api.serializeQuery(this.props.query, [{ interval }])
|
||||
const endpoint = `/${encodeURIComponent(this.props.site.domain)}/export${queryParams}`
|
||||
|
||||
@ -314,81 +314,71 @@ export default class VisitorGraph extends React.Component {
|
||||
this.state = {
|
||||
topStatsLoadingState: LOADING_STATE.loading,
|
||||
mainGraphLoadingState: LOADING_STATE.loading,
|
||||
metric: storage.getItem(`metric__${this.props.site.domain}`) || 'visitors',
|
||||
interval: getStoredInterval(this.props.query.period, this.props.site.domain)
|
||||
metric: storage.getItem(`metric__${this.props.site.domain}`) || 'visitors'
|
||||
}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.updateMetric = this.updateMetric.bind(this)
|
||||
this.fetchTopStatData = this.fetchTopStatData.bind(this)
|
||||
this.fetchGraphData = this.fetchGraphData.bind(this)
|
||||
this.maybeRollbackInterval = this.maybeRollbackInterval.bind(this)
|
||||
this.updateInterval = this.updateInterval.bind(this)
|
||||
}
|
||||
|
||||
isIntervalValid({ query, site }) {
|
||||
const period = query?.period
|
||||
const validIntervalsForPeriod = site.validIntervalsByPeriod[period] || []
|
||||
const storedInterval = getStoredInterval(period, site.domain)
|
||||
isIntervalValid(interval) {
|
||||
const { query, site } = this.props
|
||||
const validIntervals = site.validIntervalsByPeriod[query.period] || []
|
||||
|
||||
return validIntervalsForPeriod.includes(storedInterval)
|
||||
return validIntervals.includes(interval)
|
||||
}
|
||||
|
||||
maybeRollbackInterval() {
|
||||
if (this.isIntervalValid(this.props)) {
|
||||
const interval = getStoredInterval(this.props.query.period, this.props.site.domain)
|
||||
getIntervalFromStorage() {
|
||||
const { query, site } = this.props
|
||||
const storedInterval = getStoredInterval(query.period, site.domain)
|
||||
|
||||
this.setState({interval}, () => {
|
||||
this.fetchGraphData()
|
||||
})
|
||||
if (this.isIntervalValid(storedInterval)) {
|
||||
return storedInterval
|
||||
} else {
|
||||
this.setState({interval: undefined}, () => {
|
||||
this.setState({graphData: null})
|
||||
this.fetchGraphData()
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
updateInterval(interval) {
|
||||
if (INTERVALS.includes(interval)) {
|
||||
this.setState({interval, mainGraphLoadingState: LOADING_STATE.refreshing}, this.maybeRollbackInterval)
|
||||
if (this.isIntervalValid(interval)) {
|
||||
storeInterval(this.props.query.period, this.props.site.domain, interval)
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.refreshing }, this.fetchGraphData)
|
||||
}
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.setState({mainGraphLoadingState: LOADING_STATE.loading}, this.maybeRollbackInterval)
|
||||
this.setState({mainGraphLoadingState: LOADING_STATE.loading}, this.fetchGraphData)
|
||||
this.fetchTopStatData()
|
||||
if (this.props.timer) {
|
||||
this.props.timer.onTick(this.maybeRollbackInterval)
|
||||
this.props.timer.onTick(this.fetchTopStatData)
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchGraphData)
|
||||
document.addEventListener('tick', this.fetchTopStatData)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { metric, topStatData, interval } = this.state;
|
||||
const { metric, topStatData } = this.state;
|
||||
const { query, site } = this.props
|
||||
|
||||
if (this.props.query !== prevProps.query) {
|
||||
if (metric) {
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.loading, topStatsLoadingState: LOADING_STATE.loading, graphData: null, topStatData: null }, this.maybeRollbackInterval)
|
||||
} else {
|
||||
if (query !== prevProps.query) {
|
||||
if (this.isGraphCollapsed()) {
|
||||
this.setState({ topStatsLoadingState: LOADING_STATE.loading, topStatData: null })
|
||||
} else {
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.loading, topStatsLoadingState: LOADING_STATE.loading, graphData: null, topStatData: null }, this.fetchGraphData)
|
||||
}
|
||||
this.fetchTopStatData()
|
||||
}
|
||||
|
||||
if (metric !== prevState.metric) {
|
||||
this.setState({mainGraphLoadingState: LOADING_STATE.refreshing}, this.maybeRollbackInterval)
|
||||
this.setState({mainGraphLoadingState: LOADING_STATE.refreshing}, this.fetchGraphData)
|
||||
}
|
||||
|
||||
if (interval !== prevState.interval && interval) {
|
||||
this.setState({mainGraphLoadingState: LOADING_STATE.refreshing}, this.maybeRollbackInterval)
|
||||
}
|
||||
|
||||
const savedMetric = storage.getItem(`metric__${this.props.site.domain}`)
|
||||
const savedMetric = storage.getItem(`metric__${site.domain}`)
|
||||
const topStatLabels = topStatData && topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name)
|
||||
const prevTopStatLabels = prevState.topStatData && prevState.topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name)
|
||||
if (topStatLabels && `${topStatLabels}` !== `${prevTopStatLabels}`) {
|
||||
if (this.props.query.filters.goal && metric !== 'conversions') {
|
||||
if (query.filters.goal && metric !== 'conversions') {
|
||||
this.setState({ metric: 'conversions' })
|
||||
} else if (topStatLabels.includes(savedMetric) && savedMetric !== "") {
|
||||
this.setState({ metric: savedMetric })
|
||||
@ -398,41 +388,48 @@ export default class VisitorGraph extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
updateMetric(newMetric) {
|
||||
if (newMetric === this.state.metric) {
|
||||
storage.setItem(`metric__${this.props.site.domain}`, "")
|
||||
this.setState({ metric: "" })
|
||||
} else {
|
||||
storage.setItem(`metric__${this.props.site.domain}`, newMetric)
|
||||
this.setState({ metric: newMetric })
|
||||
}
|
||||
isGraphCollapsed() {
|
||||
return this.state.metric === ""
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchGraphData)
|
||||
document.removeEventListener('tick', this.fetchTopStatData)
|
||||
}
|
||||
|
||||
updateMetric(clickedMetric) {
|
||||
const newMetric = clickedMetric === this.state.metric ? "" : clickedMetric
|
||||
|
||||
storage.setItem(`metric__${this.props.site.domain}`, newMetric)
|
||||
this.setState({ metric: newMetric })
|
||||
}
|
||||
|
||||
fetchGraphData() {
|
||||
if (!this.state.metric) {
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.ready, graphData: null })
|
||||
if (this.isGraphCollapsed()) {
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.loaded, graphData: null })
|
||||
return
|
||||
}
|
||||
|
||||
const url = `/api/stats/${encodeURIComponent(this.props.site.domain)}/main-graph`
|
||||
let params = {metric: this.state.metric || 'none'}
|
||||
if (this.state.interval) { params.interval = this.state.interval }
|
||||
let params = { metric: this.state.metric }
|
||||
const interval = this.getIntervalFromStorage()
|
||||
if (interval) { params.interval = interval }
|
||||
|
||||
api.get(url, this.props.query, params)
|
||||
.then((res) => {
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.ready, graphData: res })
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.loaded, graphData: res })
|
||||
return res
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.ready, graphData: false })
|
||||
this.setState({ mainGraphLoadingState: LOADING_STATE.loaded, graphData: false })
|
||||
})
|
||||
}
|
||||
|
||||
fetchTopStatData() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/top-stats`, this.props.query)
|
||||
.then((res) => {
|
||||
this.setState({ topStatsLoadingState: LOADING_STATE.ready, topStatData: res })
|
||||
this.setState({ topStatsLoadingState: LOADING_STATE.loaded, topStatData: res })
|
||||
return res
|
||||
})
|
||||
}
|
||||
@ -443,14 +440,14 @@ export default class VisitorGraph extends React.Component {
|
||||
|
||||
const theme = document.querySelector('html').classList.contains('dark') || false
|
||||
|
||||
const topStatsReadyOrRefreshing = (topStatsLoadingState === LOADING_STATE.ready || topStatsLoadingState === LOADING_STATE.refreshing)
|
||||
const mainGraphReadyOrRefreshing = (mainGraphLoadingState === LOADING_STATE.ready || mainGraphLoadingState === LOADING_STATE.refreshing)
|
||||
const topStatsLoadedOrRefreshing = (topStatsLoadingState === LOADING_STATE.loaded || topStatsLoadingState === LOADING_STATE.refreshing)
|
||||
const mainGraphLoadedOrRefreshing = (mainGraphLoadingState === LOADING_STATE.loaded || mainGraphLoadingState === LOADING_STATE.refreshing)
|
||||
const noMetricOrRefreshing = (!metric || mainGraphLoadingState === LOADING_STATE.refreshing)
|
||||
const topStatAndGraphLoaded = !!(topStatData && graphData)
|
||||
|
||||
const showGraph =
|
||||
topStatsReadyOrRefreshing &&
|
||||
mainGraphReadyOrRefreshing &&
|
||||
topStatsLoadedOrRefreshing &&
|
||||
mainGraphLoadedOrRefreshing &&
|
||||
(topStatData && noMetricOrRefreshing || topStatAndGraphLoaded)
|
||||
|
||||
return (
|
||||
|
@ -131,14 +131,14 @@ export default class Locations extends React.Component {
|
||||
renderContent() {
|
||||
switch(this.state.mode) {
|
||||
case "cities":
|
||||
return <Cities site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
return <Cities site={this.props.site} query={this.props.query} />
|
||||
case "regions":
|
||||
return <Regions onClick={this.onRegionFilter} site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
return <Regions onClick={this.onRegionFilter} site={this.props.site} query={this.props.query} />
|
||||
case "countries":
|
||||
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} />
|
||||
case "map":
|
||||
default:
|
||||
return <CountriesMap onClick={this.onCountryFilter('map')} site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
return <CountriesMap onClick={this.onCountryFilter('map')} site={this.props.site} query={this.props.query}/>
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ class Countries extends React.Component {
|
||||
darkTheme: document.querySelector('html').classList.contains('dark') || false
|
||||
}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.updateCountries = this.updateCountries.bind(this)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -33,12 +34,15 @@ class Countries extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.resizeMap);
|
||||
document.removeEventListener('tick', this.updateCountries);
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchCountries().then(this.drawMap.bind(this))
|
||||
window.addEventListener('resize', this.resizeMap);
|
||||
if (this.props.timer) this.props.timer.onTick(this.updateCountries.bind(this))
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.updateCountries)
|
||||
}
|
||||
}
|
||||
|
||||
getDataset() {
|
||||
|
@ -101,12 +101,12 @@ export default class Pages extends React.Component {
|
||||
renderContent() {
|
||||
switch(this.state.mode) {
|
||||
case "entry-pages":
|
||||
return <EntryPages site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <EntryPages site={this.props.site} query={this.props.query} />
|
||||
case "exit-pages":
|
||||
return <ExitPages site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <ExitPages site={this.props.site} query={this.props.query} />
|
||||
case "pages":
|
||||
default:
|
||||
return <TopPages site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
return <TopPages site={this.props.site} query={this.props.query} />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import FlipMove from 'react-flip-move';
|
||||
|
||||
|
||||
import FadeIn from '../../fade-in'
|
||||
import MoreLink from '../more-link'
|
||||
@ -28,25 +30,32 @@ function ExternalLink({item, externalLinkDest}) {
|
||||
|
||||
export default function ListReport(props) {
|
||||
const [state, setState] = useState({loading: true, list: null})
|
||||
const [visible, setVisible] = useState(false)
|
||||
const valueKey = props.valueKey || 'visitors'
|
||||
const showConversionRate = !!props.query.filters.goal
|
||||
const prevQuery = useRef();
|
||||
|
||||
function fetchData() {
|
||||
if (typeof(prevQuery.current) === 'undefined' || prevQuery.current !== props.query) {
|
||||
prevQuery.current = props.query;
|
||||
setState({loading: true, list: null})
|
||||
const fetchData = useCallback(() => {
|
||||
if (props.query.period !== 'realtime') {
|
||||
setState({loading: true, list: null})
|
||||
}
|
||||
props.fetchData()
|
||||
.then((res) => setState({loading: false, list: res}))
|
||||
}, [props.query])
|
||||
|
||||
function onVisible() {
|
||||
setVisible(true)
|
||||
if (props.query.period == 'realtime') {
|
||||
document.addEventListener('tick', fetchData)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) { fetchData() }
|
||||
}, [props.query, visible]);
|
||||
|
||||
function onVisible() {
|
||||
fetchData()
|
||||
if (props.timer) props.timer.onTick(fetchData)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => { document.removeEventListener('tick', fetchData) }
|
||||
}, []);
|
||||
|
||||
function label() {
|
||||
if (props.query.period === 'realtime') {
|
||||
@ -60,8 +69,6 @@ export default function ListReport(props) {
|
||||
return props.valueLabel || 'Visitors'
|
||||
}
|
||||
|
||||
useEffect(fetchData, [props.query]);
|
||||
|
||||
function renderListItem(listItem) {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
|
||||
@ -115,7 +122,9 @@ export default function ListReport(props) {
|
||||
{showConversionRate && <span className="inline-block w-20">CR</span>}
|
||||
</span>
|
||||
</div>
|
||||
{ state.list && state.list.map(renderListItem) }
|
||||
<FlipMove>
|
||||
{ state.list.map(renderListItem) }
|
||||
</FlipMove>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -23,11 +23,14 @@ export default class Referrers extends React.Component {
|
||||
super(props)
|
||||
this.state = { loading: true }
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchReferrers = this.fetchReferrers.bind(this)
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchReferrers()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this))
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -37,6 +40,10 @@ export default class Referrers extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
@ -5,15 +5,21 @@ import MoreLink from '../more-link'
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import RocketIcon from '../modals/rocket-icon'
|
||||
import * as api from '../../api'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
|
||||
export default class SearchTerms extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {loading: true}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchSearchTerms = this.fetchSearchTerms.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
onVisible() {
|
||||
this.fetchSearchTerms()
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchSearchTerms)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -23,6 +29,10 @@ export default class SearchTerms extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchSearchTerms)
|
||||
}
|
||||
|
||||
fetchSearchTerms() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.props.query)
|
||||
.then((res) => this.setState({
|
||||
@ -30,7 +40,7 @@ export default class SearchTerms extends React.Component {
|
||||
searchTerms: res.search_terms || [],
|
||||
notConfigured: res.not_configured,
|
||||
isAdmin: res.is_admin
|
||||
})).catch((error) =>
|
||||
})).catch((error) =>
|
||||
{
|
||||
this.setState({ loading: false, searchTerms: [], notConfigured: true, error: true, isAdmin: error.payload.is_admin })
|
||||
}
|
||||
@ -122,7 +132,9 @@ export default class SearchTerms extends React.Component {
|
||||
>
|
||||
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading} className="flex-grow">
|
||||
{ this.renderContent() }
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{ this.renderContent() }
|
||||
</LazyLoader>
|
||||
</FadeIn>
|
||||
</div>
|
||||
)
|
||||
|
@ -15,12 +15,15 @@ class AllSources extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchReferrers = this.fetchReferrers.bind(this)
|
||||
this.state = { loading: true }
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchReferrers()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this))
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -30,6 +33,10 @@ class AllSources extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
@ -103,7 +110,7 @@ class AllSources extends React.Component {
|
||||
</React.Fragment>
|
||||
)
|
||||
} else {
|
||||
return <div className="font-medium text-center text-gray-500 mt-44">No data yet</div>
|
||||
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,12 +151,16 @@ const UTM_TAGS = {
|
||||
class UTMSources extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchReferrers = this.fetchReferrers.bind(this)
|
||||
this.state = { loading: true }
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
onVisible() {
|
||||
this.fetchReferrers()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this))
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -159,6 +170,10 @@ class UTMSources extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchReferrers)
|
||||
}
|
||||
|
||||
showNoRef() {
|
||||
return this.props.query.period === 'realtime'
|
||||
}
|
||||
@ -240,7 +255,7 @@ class UTMSources extends React.Component {
|
||||
|
||||
renderContent() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
<div className="flex justify-between w-full">
|
||||
<h3 className="font-bold dark:text-gray-100">Top Sources</h3>
|
||||
{this.props.renderTabs()}
|
||||
@ -249,7 +264,7 @@ class UTMSources extends React.Component {
|
||||
<FadeIn show={!this.state.loading} className="flex flex-col flex-grow">
|
||||
{this.renderList()}
|
||||
</FadeIn>
|
||||
</React.Fragment>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
|
||||
@ -289,7 +304,7 @@ export default class SourceList extends React.Component {
|
||||
renderTabs() {
|
||||
const activeClass = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
|
||||
const defaultClass = 'hover:text-indigo-600 cursor-pointer truncate text-left'
|
||||
const dropdownOptions = ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content']
|
||||
const dropdownOptions = Object.keys(UTM_TAGS)
|
||||
let buttonText = UTM_TAGS[this.state.tab] ? UTM_TAGS[this.state.tab].label : 'Campaigns'
|
||||
|
||||
return (
|
||||
@ -344,15 +359,7 @@ export default class SourceList extends React.Component {
|
||||
render() {
|
||||
if (this.state.tab === 'all') {
|
||||
return <AllSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
} else if (this.state.tab === 'utm_medium') {
|
||||
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
} else if (this.state.tab === 'utm_source') {
|
||||
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
} else if (this.state.tab === 'utm_campaign') {
|
||||
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
} else if (this.state.tab === 'utm_content') {
|
||||
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
} else if (this.state.tab === 'utm_term') {
|
||||
} else if (Object.keys(UTM_TAGS).includes(this.state.tab)) {
|
||||
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
|
||||
}
|
||||
}
|
||||
|
8
assets/js/dashboard/util/realtime-update-timer.js
Normal file
8
assets/js/dashboard/util/realtime-update-timer.js
Normal file
@ -0,0 +1,8 @@
|
||||
const THIRTY_SECONDS = 30000
|
||||
const tickEvent = new Event('tick')
|
||||
|
||||
export function start() {
|
||||
setInterval(() => {
|
||||
document.dispatchEvent(tickEvent)
|
||||
}, THIRTY_SECONDS)
|
||||
}
|
Loading…
Reference in New Issue
Block a user