City level location data (#1449)

* Merge branch 'plausible_master'

* Add City level details

* Add City level details

* Use ISO codes instead of geoname_id for subdivisions

* Add easier way to configure geolocation database

* Add workflow for dev branch

* Correct clickhouse migration

* Translate subdivision names

* Translate city names

* WIP

* Region and country filters

* Fix region filter

* Remove region_name when removing region filter

* Add modals for regions and cities

* Remove dead code

* WIP

* Revert "WIP"

This reverts commit 3202bf2fe9.

* Feature flag to hide cities when deployed

* Add changelog entry

* Remove unused code

* Remove unused variables

* Fix test

Co-authored-by: AymanTerra <aymanterra@yahoo.com>
This commit is contained in:
Uku Taht 2021-11-23 11:39:09 +02:00 committed by GitHub
parent 06d0d0eafa
commit 05bf43c1be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 224871 additions and 114 deletions

View File

@ -2,7 +2,7 @@ name: Build
on:
push:
branches: [ master, stable ]
branches: [ master, stable, dev ]
workflow_dispatch:

View File

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- Data exported via the download button will contain CSV data for all visible graps in a zip file.
- Region and city-level geolocation plausible/analytics#1449
- The `u` option can now be used in the `manual` extension to specify a URL when triggering events.
## v1.4.1

View File

@ -20,6 +20,8 @@
"react/self-closing-comp": [0],
"no-unused-expressions": [1, { "allowShortCircuit": true }],
"no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
"no-prototype-builtins": [0],
"react/jsx-props-no-spreading": [0],
"jsx-a11y/click-events-have-key-events": [0],
"jsx-a11y/no-static-element-interactions": [0],
"react/no-did-update-set-state": [0]

View File

@ -25,7 +25,7 @@ export default function SearchSelect(props) {
function fetchOptions({inputValue, isOpen}) {
setLoading(isOpen)
return props.fetchOptions(inputValue).then((loadedItems) => {
return props.fetchOptions(inputValue || '').then((loadedItems) => {
setLoading(false)
setItems(loadedItems)
})
@ -45,9 +45,16 @@ export default function SearchSelect(props) {
closeMenu,
} = useCombobox({
items,
itemToString: (item) => item.hasOwnProperty('name') ? item.name : item,
onInputValueChange: (changes) => {
debouncedFetchOptions(changes)
props.onInput(changes.inputValue)
if (changes.inputValue === '') {
props.onSelect({name: '', code: ''})
}
},
onSelectedItemChange: (changes) => {
props.onSelect(changes.selectedItem)
},
initialSelectedItem: props.initialSelectedItem,
onIsOpenChange: (state) => {
@ -87,10 +94,10 @@ export default function SearchSelect(props) {
items.map((item, index) => (
<li
className={classNames("cursor-pointer select-none relative py-2 pl-3 pr-9", {'text-white bg-indigo-600': highlightedIndex === index, 'text-gray-900 dark:text-gray-100': highlightedIndex !== index})}
key={`${item}`}
key={`${item.name ? item.name : item}`}
{...getItemProps({ item, index })}
>
{item}
{item.name ? item.name : item}
</li>
))
}

View File

@ -13,6 +13,8 @@ function removeFilter(key, history, query) {
[key]: false
}
if (key === 'goal') { newOpts.props = false }
if (key === 'region') { newOpts.region_name = false }
if (key === 'city') { newOpts.city_name = false }
navigateToQuery(
history,
query,
@ -62,6 +64,18 @@ function filterText(key, rawValue, query) {
return <>Country {type} <b>{selectedCountry.properties.name}</b></>
}
if (key === "region") {
const q = new URLSearchParams(window.location.search)
const regionName = q.get('region_name')
return <>Region {type} <b>{regionName}</b></>
}
if (key === "city") {
const q = new URLSearchParams(window.location.search)
const cityName = q.get('city_name')
return <>City {type} <b>{cityName}</b></>
}
const formattedFilter = formattedFilters[key]
if (formattedFilter) {

View File

@ -16,7 +16,8 @@ if (container) {
insertedAt: container.dataset.insertedAt,
embedded: container.dataset.embedded,
background: container.dataset.background,
selfhosted: container.dataset.selfhosted === 'true'
selfhosted: container.dataset.selfhosted === 'true',
cities: container.dataset.cities === 'true'
}
const loggedIn = container.dataset.loggedIn === 'true'

View File

@ -37,6 +37,8 @@ export function parseQuery(querystring, site) {
'os': q.get('os'),
'os_version': q.get('os_version'),
'country': q.get('country'),
'region': q.get('region'),
'city': q.get('city'),
'page': q.get('page'),
'entry_page': q.get('entry_page'),
'exit_page': q.get('exit_page')
@ -102,16 +104,14 @@ class QueryLink extends React.Component {
const QueryLinkWithRouter = withRouter(QueryLink)
export { QueryLinkWithRouter as QueryLink };
class QueryButton extends React.Component {
render() {
const { history, query, to, disabled, className, children } = this.props
function QueryButton({history, query, to, disabled, className, children, onClick}) {
return (
<button
className={className}
onClick={(event) => {
event.preventDefault()
navigateToQuery(history, query, to)
if (this.props.onClick) this.props.onClick(event)
if (onClick) onClick(event)
history.push({ pathname: window.location.pathname, search: generateQueryString(to) })
}}
type="button"
@ -121,7 +121,7 @@ class QueryButton extends React.Component {
</button>
)
}
}
const QueryButtonWithRouter = withRouter(QueryButton)
export { QueryButtonWithRouter as QueryButton };
@ -139,6 +139,7 @@ export function toHuman(query) {
} if (query.period === '12mo') {
return 'in the last 12 months'
}
return ''
}
export function eventName(query) {
@ -165,6 +166,8 @@ export const formattedFilters = {
'os': 'Operating System',
'os_version': 'Operating System Version',
'country': 'Country',
'region': 'Region',
'city': 'City',
'page': 'Page',
'entry_page': 'Entry Page',
'exit_page': 'Exit Page'

View File

@ -1,4 +1,6 @@
import React, { useEffect } from 'react';
import {BrowserRouter, Switch, Route, useLocation} from "react-router-dom";
import Dash from './index'
import SourcesModal from './stats/modals/sources'
import ReferrersDrilldownModal from './stats/modals/referrer-drilldown'
@ -7,10 +9,9 @@ import PagesModal from './stats/modals/pages'
import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages'
import CountriesModal from './stats/modals/countries'
import ModalTable from './stats/modals/table'
import FilterModal from './stats/modals/filter'
import {BrowserRouter, Switch, Route, useLocation} from "react-router-dom";
function ScrollToTop() {
const location = useLocation();
@ -51,6 +52,12 @@ export default function Router({site, loggedIn, currentUserRole}) {
<Route path="/:domain/countries">
<CountriesModal site={site} />
</Route>
<Route path="/:domain/regions">
<ModalTable title="Top regions" site={site} endpoint={`/api/stats/${encodeURIComponent(site.domain)}/regions`} filter={{region: 'code', region_name: 'name'}} keyLabel="Region" />
</Route>
<Route path="/:domain/cities">
<ModalTable title="Top cities" site={site} endpoint={`/api/stats/${encodeURIComponent(site.domain)}/cities`} filter={{city: 'code', city_name: 'name'}} keyLabel="City" />
</Route>
<Route path={["/:domain/filter/:field"]}>
<FilterModal site={site} />
</Route>

View File

@ -4,8 +4,46 @@ import * as storage from '../../storage'
import Countries from './countries';
import CountriesMap from './map'
import * as api from '../../api'
import {apiPath, sitePath} from '../../url'
import ListReport from '../reports/list'
function Regions({query, site}) {
function fetchData() {
return api.get(apiPath(site, '/regions'), query, {country_name: query.filters.country, limit: 9})
}
return (
<ListReport
title="Regions"
fetchData={fetchData}
filter={{region: 'code', region_name: 'name'}}
keyLabel="Region"
detailsLink={sitePath(site, '/regions')}
query={query}
/>
)
}
function Cities({query, site}) {
function fetchData() {
return api.get(apiPath(site, '/cities'), query, {limit: 9})
}
return (
<ListReport
title="Cities"
fetchData={fetchData}
filter={{city: 'code', city_name: 'name'}}
keyLabel="City"
detailsLink={sitePath(site, '/cities')}
query={query}
/>
)
}
const labelFor = {
'map': 'Countries Map',
'countries': 'Countries',
'regions': 'Regions',
'cities': 'Cities',
@ -30,6 +68,10 @@ 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}/>
case "regions":
return <Regions site={this.props.site} query={this.props.query} timer={this.props.timer}/>
case "countries":
return <Countries site={this.props.site} query={this.props.query} timer={this.props.timer}/>
case "map":
@ -76,8 +118,8 @@ export default class Locations extends React.Component {
<ul className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
{ this.renderPill('Map', 'map') }
{ this.renderPill('Countries', 'countries') }
{/* { this.renderPill('Regions', 'regions') } */}
{/* { this.renderPill('Cities', 'cities') } */}
{ this.props.site.cities && this.renderPill('Regions', 'regions') }
{ this.props.site.cities && this.renderPill('Cities', 'cities') }
</ul>
</div>
{ this.renderContent() }

View File

@ -11,7 +11,7 @@ import * as api from '../../api'
import { navigateToQuery } from '../../query'
import { withThemeConsumer } from '../../theme-consumer-hoc';
class CountriesMap extends React.Component {
class Countries extends React.Component {
constructor(props) {
super(props)
this.resizeMap = this.resizeMap.bind(this)
@ -21,18 +21,9 @@ class CountriesMap extends React.Component {
this.onVisible = this.onVisible.bind(this)
}
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))
}
componentWillUnmount() {
window.removeEventListener('resize', this.resizeMap);
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({loading: true, countries: null})
this.fetchCountries().then(this.drawMap)
}
@ -46,18 +37,29 @@ class CountriesMap extends React.Component {
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.resizeMap);
}
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))
}
getDataset() {
var dataset = {};
const dataset = {};
var onlyValues = this.state.countries.map(function(obj){ return obj.visitors });
var maxValue = Math.max.apply(null, onlyValues);
var paletteScale = d3.scale.linear()
// eslint-disable-next-line no-undef
const paletteScale = d3.scale.linear()
.domain([0,maxValue])
.range([
this.props.darkTheme ? "#2e3954" : "#f3ebff",
this.props.darkTheme ? "#6366f1" : "#a779e9"
]);
])
this.state.countries.forEach(function(item){
dataset[item.name] = {numberOfThings: item.visitors, fillColor: paletteScale(item.visitors)};
@ -82,7 +84,7 @@ class CountriesMap extends React.Component {
}
drawMap() {
var dataset = this.getDataset();
const dataset = this.getDataset();
const label = this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
const defaultFill = this.props.darkTheme ? '#2d3747' : '#f8fafc'
const highlightFill = this.props.darkTheme ? '#374151' : '#F5F5F5'
@ -98,16 +100,14 @@ class CountriesMap extends React.Component {
geographyConfig: {
borderColor,
highlightBorderWidth: 2,
highlightFillColor: function(geo) {
return geo['fillColor'] || highlightFill;
},
highlightFillColor: (geo) => geo.fillColor || highlightFill,
highlightBorderColor,
popupTemplate: function(geo, data) {
if (!data) { return ; }
popupTemplate: (geo, data) => {
if (!data) { return null; }
const pluralizedLabel = data.numberOfThings === 1 ? label.slice(0, -1) : label
return ['<div class="hoverinfo dark:bg-gray-800 dark:shadow-gray-850 dark:border-gray-850 dark:text-gray-200">',
'<strong>', geo.properties.name, '</strong>',
'<br><strong class="dark:text-indigo-400">', numberFormatter(data.numberOfThings), '</strong> ' + pluralizedLabel,
'<br><strong class="dark:text-indigo-400">', numberFormatter(data.numberOfThings), '</strong>', pluralizedLabel,
'</div>'].join('');
}
},
@ -131,18 +131,22 @@ class CountriesMap extends React.Component {
<span className="text-xs text-gray-500 absolute bottom-4 right-3">IP Geolocation by <a target="_blank" href="https://db-ip.com" rel="noreferrer" className="text-indigo-600">DB-IP</a></span>
)
}
return null
}
renderBody() {
if (this.state.countries) {
return (
<React.Fragment>
<>
<div className="mx-auto mt-4" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div>
<MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />
{ this.geolocationDbNotice() }
</React.Fragment>
</>
)
}
return null
}
render() {
@ -157,4 +161,4 @@ class CountriesMap extends React.Component {
}
}
export default withRouter(withThemeConsumer(CountriesMap))
export default withRouter(withThemeConsumer(Countries))

View File

@ -48,11 +48,7 @@ class CountriesModal extends React.Component {
return (
<tr className="text-sm dark:text-gray-200" key={country.name}>
<td className="p-2">
<Link
className="hover:underline"
to={{search: query.toString(),
pathname: `/${ encodeURIComponent(this.props.site.domain)}`}}
>
<Link className="hover:underline" to={{search: query.toString(), pathname: `/${encodeURIComponent(this.props.site.domain)}`}}>
{countryFullName}
</Link>
</td>
@ -107,6 +103,8 @@ class CountriesModal extends React.Component {
</>
)
}
return null
}
render() {

View File

@ -14,7 +14,7 @@ import {apiPath, sitePath} from '../../url'
export const FILTER_GROUPS = {
'page': ['page'],
'source': ['source', 'referrer'],
'country': ['country'],
'country': ['country', 'region', 'city'],
'screen': ['screen'],
'browser': ['browser', 'browser_version'],
'os': ['os', 'os_version'],
@ -32,10 +32,22 @@ function getCountryName(ISOCode) {
function getFormState(filterGroup, query) {
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
let filterValue = query.filters[filter] || ''
const filterValue = query.filters[filter] || ''
let filterName = filterValue
const type = filterValue[0] === '!' ? 'is_not' : 'is'
if (filter === 'country') filterValue = getCountryName(filterValue)
return Object.assign(result, {[filter]: {value: filterValue, type}})
if (filter === 'country') {
filterName = getCountryName(filterValue)
}
if (filter === 'region' && filterValue !== '') {
filterName = (new URLSearchParams(window.location.search)).get('region_name')
}
if (filter === 'city' && filterValue !== '') {
filterName = (new URLSearchParams(window.location.search)).get('city_name')
}
return Object.assign(result, {[filter]: {name: filterName, value: filterValue, type}})
}, {})
}
@ -102,7 +114,7 @@ class FilterModal extends React.Component {
handleSubmit() {
const { formState } = this.state;
const filters = Object.entries(formState).reduce((res, [filterKey, {type, value}]) => {
const filters = Object.entries(formState).reduce((res, [filterKey, {type, value, name}]) => {
let finalFilterValue = value
if (filterKey === 'country') {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
@ -110,6 +122,9 @@ class FilterModal extends React.Component {
finalFilterValue = selectedCountry.id
}
if (filterKey === 'region') { res.push({filter: 'region_name', value: name}) }
if (filterKey === 'city') { res.push({filter: 'city_name', value: name}) }
finalFilterValue = (type === 'is_not' ? '!' : '') + finalFilterValue.trim()
res.push({filter: filterKey, value: finalFilterValue})
@ -119,10 +134,26 @@ class FilterModal extends React.Component {
this.selectFiltersAndCloseModal(filters)
}
onInput(filterName) {
return (val) => {
onSelect(filterName) {
if (this.state.selectedFilterGroup !== 'country') {
return () => {}
}
return (value) => {
this.setState(prevState => ({formState: Object.assign(prevState.formState, {
[filterName]: Object.assign(prevState.formState[filterName], {value: val})
[filterName]: Object.assign(prevState.formState[filterName], {value: value.code, name: value.name})
})}))
}
}
onInput(filterName) {
if (this.state.selectedFilterGroup === 'country') {
return () => {}
}
return (value) => {
this.setState(prevState => ({formState: Object.assign(prevState.formState, {
[filterName]: Object.assign(prevState.formState[filterName], {value})
})}))
}
}
@ -137,17 +168,10 @@ class FilterModal extends React.Component {
return (input) => {
const {query, formState} = this.state
const formFilters = Object.fromEntries(
Object.entries(formState).map(([k, v]) => [k, v.value])
Object.entries(formState).map(([k, v]) => [k, v.code || v.value])
)
const updatedQuery = {...query, filters: { ...query.filters, ...formFilters, [filter]: null }}
if (filter === 'country') {
const matchedCountries = Datamap.prototype.worldTopo.objects.world.geometries.filter(c => c.properties.name.toLowerCase().includes(input.trim().toLowerCase()))
const matches = matchedCountries.map(c => c.id)
return api.get(apiPath(this.props.site, '/suggestions/country'), updatedQuery, { q: matches })
.then((res) => res.map(code => matchedCountries.filter(c => c.id === code)[0].properties.name))
}
return api.get(apiPath(this.props.site, `/suggestions/${filter}`), updatedQuery, { q: input.trim() })
}
@ -176,7 +200,15 @@ class FilterModal extends React.Component {
}
renderFilterInputs() {
return FILTER_GROUPS[this.state.selectedFilterGroup].map((filter) => (
const groups = FILTER_GROUPS[this.state.selectedFilterGroup].filter((filterName) => {
if (['city', 'region'].includes(filterName)) {
return this.props.site.cities
}
return true
})
return groups.map((filter) => {
return (
<div className="mt-4" key={filter}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{ formattedFilters[filter] }</div>
<div className="flex items-start mt-1">
@ -185,14 +217,15 @@ class FilterModal extends React.Component {
<SearchSelect
key={filter}
fetchOptions={this.fetchOptions(filter)}
initialSelectedItem={this.state.formState[filter].value}
initialSelectedItem={this.state.formState[filter]}
onInput={this.onInput(filter)}
onSelect={this.onSelect(filter)}
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`}
/>
</div>
</div>
))
)
})
}
renderFilterTypeSelector(filterName) {

View File

@ -0,0 +1,108 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import Datamap from 'datamaps'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../number-formatter'
import {parseQuery} from '../../query'
class ModalTable extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site)
}
}
componentDidMount() {
api.get(this.props.endpoint, this.state.query, {limit: 100})
.then((res) => this.setState({loading: false, list: res}))
}
label() {
return this.state.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderTableItem(tableItem) {
const query = new URLSearchParams(window.location.search)
Object.entries(this.props.filter).forEach((([key, valueKey]) => {
query.set(key, tableItem[valueKey])
}))
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const thisCountry = allCountries.find((c) => c.id === tableItem.name) || {properties: {name: tableItem.name}};
const countryFullName = thisCountry.properties.name
return (
<tr className="text-sm dark:text-gray-200" key={tableItem.name}>
<td className="p-2">
<Link className="hover:underline" to={{search: query.toString(), pathname: `/${encodeURIComponent(this.props.site.domain)}`}}>
{countryFullName}
</Link>
</td>
<td className="p-2 w-32 font-medium" align="right">
{numberFormatter(tableItem.visitors)}
{tableItem.percentage &&
<span className="inline-block text-xs w-8 text-right">({tableItem.percentage}%)</span> }
</td>
</tr>
)
}
renderBody() {
if (this.state.loading) {
return (
<div className="loading mt-32 mx-auto"><div></div></div>
)
}
if (this.state.list) {
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">{this.props.title}</h1>
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th
className="p-2 w-48 lg:w-1/2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="left"
>
{this.props.keyLabel}
</th>
<th
// eslint-disable-next-line max-len
className="p-2 w-32 lg:w-1/2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="right"
>
{this.label()}
</th>
</tr>
</thead>
<tbody>
{ this.state.list.map(this.renderTableItem.bind(this)) }
</tbody>
</table>
</main>
</>
)
}
return null
}
render() {
return (
<Modal site={this.props.site} show={!this.state.loading}>
{ this.renderBody() }
</Modal>
)
}
}
export default withRouter(ModalTable)

View File

@ -1,12 +1,12 @@
import React from 'react';
import { Link } from 'react-router-dom'
export default function MoreLink({site, list, endpoint}) {
export default function MoreLink({url, site, list, endpoint}) {
if (list.length > 0) {
return (
<div className="text-center w-full py-3 md:pb-3 md:pt-0 md:absolute md:bottom-0 md:left-0">
<Link
to={`/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
to={url || `/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
// eslint-disable-next-line max-len
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
>

View File

@ -0,0 +1,97 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FadeIn from '../../fade-in'
import MoreLink from '../more-link'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import LazyLoader from '../../lazy-loader'
export default class ListReport extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
this.onVisible = this.onVisible.bind(this)
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.fetchData()
}
}
onVisible() {
this.fetchData()
if (this.props.timer) this.props.timer.onTick(this.fetchData.bind(this))
}
fetchData() {
this.setState({loading: true, list: null})
this.props.fetchData()
.then((res) => this.setState({loading: false, list: res}))
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderListItem(listItem) {
const query = new URLSearchParams(window.location.search)
Object.entries(this.props.filter).forEach((([key, valueKey]) => {
query.set(key, listItem[valueKey])
}))
return (
<div className="flex items-center justify-between my-1 text-sm" key={listItem.name}>
<Bar
count={listItem.visitors}
all={this.state.list}
bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15"
maxWidthDeduction="6rem"
>
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
<Link className="md:truncate block hover:underline" to={{search: query.toString()}}>
{listItem.name}
</Link>
</span>
</Bar>
<span className="font-medium dark:text-gray-200">
{numberFormatter(listItem.visitors)}
{
listItem.percentage &&
<span className="inline-block w-8 text-xs text-right">({listItem.percentage}%)</span>
}
</span>
</div>
)
}
renderList() {
if (this.state.list && this.state.list.length > 0) {
return (
<>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>{ this.props.keyLabel }</span>
<span>{ this.label() }</span>
</div>
{ this.state.list && this.state.list.map(this.renderListItem.bind(this)) }
</>
)
}
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
}
render() {
return (
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
{ this.state.loading && <div className="mx-auto loading mt-44"><div></div></div> }
<FadeIn show={!this.state.loading} className="flex-grow">
{ this.renderList() }
</FadeIn>
{this.props.detailsLink && !this.state.loading && <MoreLink url={this.props.detailsLink} list={this.state.list} />}
</LazyLoader>
)
}
}

View File

@ -3,7 +3,7 @@ export function apiPath(site, path = '') {
}
export function sitePath(site, path = '') {
return `/${encodeURIComponent(site.domain)}${path}`
return `/${encodeURIComponent(site.domain)}${path}${window.location.search}`
}
export function setQuery(key, value) {

View File

@ -8,3 +8,5 @@ LOG_LEVEL=debug
SELFHOST=false
DISABLE_CRON=true
ADMIN_USER_IDS=1
IP_GEOLOCATION_DB=/home/uku/plausible/analytics/city_database.mmdb
SHOW_CITIES=true

View File

@ -113,6 +113,8 @@ geolite2_country_db =
Application.app_dir(:plausible) <> "/priv/geodb/dbip-country.mmdb"
)
ip_geolocation_db = get_var_from_path_or_env(config_dir, "IP_GEOLOCATION_DB", geolite2_country_db)
disable_auth =
config_dir
|> get_var_from_path_or_env("DISABLE_AUTH", "false")
@ -146,6 +148,11 @@ is_selfhost =
|> get_var_from_path_or_env("SELFHOST", "true")
|> String.to_existing_atom()
show_cities =
config_dir
|> get_var_from_path_or_env("SHOW_CITIES", "false")
|> String.to_existing_atom()
custom_script_name =
config_dir
|> get_var_from_path_or_env("CUSTOM_SCRIPT_NAME", "script")
@ -186,6 +193,7 @@ config :plausible,
site_limit: site_limit,
site_limit_exempt: site_limit_exempt,
is_selfhost: is_selfhost,
show_cities: show_cities,
custom_script_name: custom_script_name,
domain_blacklist: domain_blacklist
@ -390,13 +398,13 @@ config :kaffy,
]
]
if config_env() != :test && geolite2_country_db do
if config_env() != :test do
config :geolix,
databases: [
%{
id: :country,
id: :geolocation,
adapter: Geolix.Adapter.MMDB2,
source: geolite2_country_db,
source: ip_geolocation_db,
result_as: :raw
}
]

View File

@ -21,7 +21,7 @@ config :plausible,
config :geolix,
databases: [
%{
id: :country,
id: :geolocation,
adapter: Geolix.Adapter.Fake,
data: %{
{1, 1, 1, 1} => %{country: %{iso_code: "US"}},

View File

@ -19,6 +19,10 @@ defmodule Plausible.ClickhouseEvent do
field :utm_campaign, :string, default: ""
field :country_code, :string, default: ""
field :subdivision1_code, :string, default: ""
field :subdivision2_code, :string, default: ""
field :city_geoname_id, :integer, default: 0
field :screen_size, :string, default: ""
field :operating_system, :string, default: ""
field :operating_system_version, :string, default: ""
@ -50,6 +54,9 @@ defmodule Plausible.ClickhouseEvent do
:utm_source,
:utm_campaign,
:country_code,
:subdivision1_code,
:subdivision2_code,
:city_geoname_id,
:screen_size,
:"meta.key",
:"meta.value"

View File

@ -24,7 +24,11 @@ defmodule Plausible.ClickhouseSession do
field :referrer, :string
field :referrer_source, :string
field :country_code, :string
field :country_code, :string, default: ""
field :subdivision1_code, :string, default: ""
field :subdivision2_code, :string, default: ""
field :city_geoname_id, :integer, default: 0
field :screen_size, :string
field :operating_system, :string
field :operating_system_version, :string
@ -57,6 +61,10 @@ defmodule Plausible.ClickhouseSession do
:utm_source,
:utm_campaign,
:country_code,
:country_geoname_id,
:subdivision1_code,
:subdivision2_code,
:city_geoname_id,
:screen_size
])
|> validate_required([:hostname, :domain, :fingerprint, :is_bounce, :start])

View File

@ -114,6 +114,9 @@ defmodule Plausible.Session.Store do
utm_source: event.utm_source,
utm_campaign: event.utm_campaign,
country_code: event.country_code,
subdivision1_code: event.subdivision1_code,
subdivision2_code: event.subdivision2_code,
city_geoname_id: event.city_geoname_id,
screen_size: event.screen_size,
operating_system: event.operating_system,
operating_system_version: event.operating_system_version,

View File

@ -117,7 +117,9 @@ defmodule Plausible.Stats.Base do
"screen" => "screen_size",
"os" => "operating_system",
"os_version" => "operating_system_version",
"country" => "country_code"
"country" => "country_code",
"region" => "subdivision1_code",
"city" => "city_geoname_id"
}
def query_sessions(site, query) do

View File

@ -338,6 +338,24 @@ defmodule Plausible.Stats.Breakdown do
)
end
defp do_group_by(q, "visit:region") do
from(
s in q,
group_by: s.subdivision1_code,
where: s.subdivision1_code != "",
select_merge: %{"region" => s.subdivision1_code}
)
end
defp do_group_by(q, "visit:city") do
from(
s in q,
group_by: s.city_geoname_id,
where: s.city_geoname_id != 0,
select_merge: %{"city" => s.city_geoname_id}
)
end
defp do_group_by(q, "visit:entry_page") do
from(
s in q,

View File

@ -1,4 +1,23 @@
defmodule Plausible.Stats.CountryName do
@iso3166_2_source Application.app_dir(:plausible, "priv/iso_3166-2.json")
@geonames_source Application.app_dir(:plausible, "priv/cities500.txt")
@subdivision_names File.read!(@iso3166_2_source)
|> Jason.decode!()
|> Map.get("3166-2")
|> Enum.map(fn %{"code" => code, "name" => name} -> {code, name} end)
|> Enum.into(%{})
@city_names File.stream!(@geonames_source)
|> Stream.map(&String.trim(&1))
|> Stream.map(&String.split(&1, "\t"))
|> Stream.map(fn [id, name | _rest] -> {String.to_integer(id), name} end)
|> Enum.into(%{})
@city_codes @city_names
|> Enum.map(fn {code, name} -> {name, code} end)
|> Enum.into(%{})
@country_codes_to_names %{
"AF" => "Afghanistan",
"AX" => "Aland Islands",
@ -505,7 +524,52 @@ defmodule Plausible.Stats.CountryName do
Map.get(@alpha2_codes, code, code)
end
def search_alpha2(name_search_query) do
Enum.reduce(@country_codes_to_names, [], fn {code, name}, acc ->
matches =
name
|> String.downcase()
|> String.contains?(String.downcase(name_search_query))
if matches, do: [code | acc], else: acc
end)
end
def from_iso3166(code) do
Map.get(@country_codes_to_names, code, code)
end
def from_iso3166_2(code) do
Map.get(@subdivision_names, code, code)
end
def search_iso3166_2(name_search_query) do
Enum.reduce(@subdivision_names, [], fn {code, name}, acc ->
matches =
name
|> String.downcase()
|> String.contains?(String.downcase(name_search_query))
if matches, do: [code | acc], else: acc
end)
end
def search_geoname(name_search_query) do
Enum.reduce(@city_names, [], fn {code, name}, acc ->
matches =
name
|> String.downcase()
|> String.contains?(String.downcase(name_search_query))
if matches, do: [code | acc], else: acc
end)
end
def from_geoname_id(geoname_id, default) do
Map.get(@city_names, geoname_id, default)
end
def to_geoname_id(city_name) do
Map.get(@city_codes, city_name)
end
end

View File

@ -2,25 +2,109 @@ defmodule Plausible.Stats.FilterSuggestions do
use Plausible.Repo
use Plausible.ClickhouseRepo
import Plausible.Stats.Base
alias Plausible.Stats.CountryName
def filter_suggestions(site, query, "country", filter_search) do
filter_search =
String.split(filter_search, ",")
|> Enum.map(fn c -> Plausible.Stats.CountryName.to_alpha2(c) end)
matches = Plausible.Stats.CountryName.search_alpha2(filter_search)
q =
from(
e in query_sessions(site, query),
group_by: e.country_code,
order_by: [desc: fragment("count(*)")],
select: {e.country_code}
select: e.country_code
)
ClickhouseRepo.all(q)
|> Enum.map(fn {x} -> x end)
|> Enum.filter(fn c -> Enum.find(filter_search, false, fn x -> x == c end) end)
|> Enum.map(fn c -> Plausible.Stats.CountryName.to_alpha3(c) end)
|> Enum.filter(fn c -> Enum.find(matches, false, fn x -> x == c end) end)
|> Enum.slice(0..24)
|> Enum.map(fn c ->
%{
code: CountryName.to_alpha3(c),
name: CountryName.from_iso3166(c)
}
end)
end
def filter_suggestions(site, query, "region", "") do
from(
e in query_sessions(site, query),
group_by: e.subdivision1_code,
order_by: [desc: fragment("count(*)")],
select: e.subdivision1_code,
where: e.subdivision1_code != "",
limit: 24
)
|> ClickhouseRepo.all()
|> Enum.map(fn c ->
%{
code: c,
name: CountryName.from_iso3166_2(c)
}
end)
end
def filter_suggestions(site, query, "region", filter_search) do
matches = Plausible.Stats.CountryName.search_iso3166_2(filter_search)
q =
from(
e in query_sessions(site, query),
group_by: e.subdivision1_code,
order_by: [desc: fragment("count(*)")],
select: e.subdivision1_code
)
ClickhouseRepo.all(q)
|> Enum.filter(fn c -> Enum.find(matches, false, fn x -> x == c end) end)
|> Enum.slice(0..24)
|> Enum.map(fn c ->
%{
code: c,
name: CountryName.from_iso3166_2(c)
}
end)
end
def filter_suggestions(site, query, "city", "") do
from(
e in query_sessions(site, query),
group_by: e.city_geoname_id,
order_by: [desc: fragment("count(*)")],
select: e.city_geoname_id,
where: e.city_geoname_id != 0,
limit: 24
)
|> ClickhouseRepo.all()
|> Enum.map(fn c ->
%{
code: Integer.to_string(c),
name: CountryName.from_geoname_id(c, "N/A")
}
end)
end
def filter_suggestions(site, query, "city", filter_search) do
matches = Plausible.Stats.CountryName.search_geoname(filter_search)
q =
from(
e in query_sessions(site, query),
group_by: e.city_geoname_id,
order_by: [desc: fragment("count(*)")],
select: e.city_geoname_id,
where: e.city_geoname_id != 0
)
ClickhouseRepo.all(q)
|> Enum.filter(fn c -> Enum.find(matches, false, fn x -> x == c end) end)
|> Enum.slice(0..24)
|> Enum.map(fn c ->
%{
code: Integer.to_string(c),
name: CountryName.from_geoname_id(c, "N/A")
}
end)
end
def filter_suggestions(site, _query, "goal", filter_search) do

View File

@ -12,6 +12,8 @@ defmodule Plausible.Stats.Filters do
"os",
"os_version",
"country",
"region",
"city",
"entry_page",
"exit_page"
]

View File

@ -91,7 +91,7 @@ defmodule PlausibleWeb.Api.ExternalController do
query = decode_query_params(uri)
ref = parse_referrer(uri, params["referrer"])
country_code = visitor_country(conn)
location_details = visitor_location_details(conn)
salts = Plausible.Session.Salts.fetch()
event_attrs = %{
@ -104,7 +104,11 @@ defmodule PlausibleWeb.Api.ExternalController do
utm_medium: query["utm_medium"],
utm_source: query["utm_source"],
utm_campaign: query["utm_campaign"],
country_code: country_code,
country_code: location_details[:country_code],
country_geoname_id: location_details[:country_geoname_id],
subdivision1_code: location_details[:subdivision1_code],
subdivision2_code: location_details[:subdivision2_code],
city_geoname_id: location_details[:city_geoname_id],
operating_system: ua && os_name(ua),
operating_system_version: ua && os_version(ua),
browser: ua && browser_name(ua),
@ -216,12 +220,38 @@ defmodule PlausibleWeb.Api.ExternalController do
end
@decorate trace("ingest.geolocation")
defp visitor_country(conn) do
defp visitor_location_details(conn) do
result =
PlausibleWeb.RemoteIp.get(conn)
|> Geolix.lookup()
get_in(result, [:country, :country, :iso_code])
country_code = get_in(result, [:geolocation, :country, :iso_code])
city_geoname_id = get_in(result, [:geolocation, :city, :geoname_id])
subdivision1_code =
case result do
%{geolocation: %{subdivisions: [%{iso_code: iso_code} | _rest]}} ->
country_code <> "-" <> iso_code
_ ->
""
end
subdivision2_code =
case result do
%{geolocation: %{subdivisions: [_first, %{iso_code: iso_code} | _rest]}} ->
country_code <> "-" <> iso_code
_ ->
""
end
%{
country_code: country_code,
subdivision1_code: subdivision1_code,
subdivision2_code: subdivision2_code,
city_geoname_id: city_geoname_id
}
end
@decorate trace("ingest.parse_referrer")

View File

@ -516,6 +516,38 @@ defmodule PlausibleWeb.Api.StatsController do
end
end
def regions(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params) |> Filters.add_prefix()
pagination = parse_pagination(params)
countries =
Stats.breakdown(site, query, "visit:region", ["visitors"], pagination)
|> transform_keys(%{"region" => "code"})
|> Enum.map(fn region ->
name = Stats.CountryName.from_iso3166_2(region["code"])
Map.put(region, "name", name)
end)
json(conn, countries)
end
def cities(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params) |> Filters.add_prefix()
pagination = parse_pagination(params)
cities =
Stats.breakdown(site, query, "visit:city", ["visitors"], pagination)
|> transform_keys(%{"city" => "code"})
|> Enum.map(fn city ->
name = Stats.CountryName.from_geoname_id(city["code"], "N/A")
Map.put(city, "name", name)
end)
json(conn, cities)
end
def browsers(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params) |> Filters.add_prefix()

View File

@ -61,6 +61,8 @@ defmodule PlausibleWeb.Router do
get "/:domain/entry-pages", StatsController, :entry_pages
get "/:domain/exit-pages", StatsController, :exit_pages
get "/:domain/countries", StatsController, :countries
get "/:domain/regions", StatsController, :regions
get "/:domain/cities", StatsController, :cities
get "/:domain/browsers", StatsController, :browsers
get "/:domain/browser-versions", StatsController, :browser_versions
get "/:domain/operating-systems", StatsController, :operating_systems

View File

@ -5,7 +5,7 @@
</div>
<% end %>
<div class="pt-6"></div>
<div id="stats-react-container" data-domain="<%= @site.domain %>" data-offset="<%= Timex.Timezone.total_offset(Timex.Timezone.get(@site.timezone)) %>" data-has-goals="<%= @has_goals %>" data-logged-in="<%= !!@conn.assigns[:current_user] %>" data-inserted-at="<%= @site.inserted_at %>" data-shared-link-auth="<%= assigns[:shared_link_auth] %>" data-embedded="<%= @conn.assigns[:embedded] %>" data-background="<%= @conn.assigns[:background] %>" data-selfhosted="<%= Application.get_env(:plausible, :is_selfhost) %>" data-current-user-role="<%= @conn.assigns[:current_user_role] %>"></div>
<div id="stats-react-container" data-domain="<%= @site.domain %>" data-offset="<%= Timex.Timezone.total_offset(Timex.Timezone.get(@site.timezone)) %>" data-has-goals="<%= @has_goals %>" data-logged-in="<%= !!@conn.assigns[:current_user] %>" data-inserted-at="<%= @site.inserted_at %>" data-shared-link-auth="<%= assigns[:shared_link_auth] %>" data-embedded="<%= @conn.assigns[:embedded] %>" data-background="<%= @conn.assigns[:background] %>" data-selfhosted="<%= Application.get_env(:plausible, :is_selfhost) %>" data-cities="<%= Application.get_env(:plausible, :show_cities) %>" data-current-user-role="<%= @conn.assigns[:current_user_role] %>"></div>
<div id="modal_root"></div>
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
<div class="bg-gray-50 dark:bg-gray-850">

196716
priv/cities500.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
defmodule Plausible.ClickhouseRepo.Migrations.AddMoreLocationDetails do
use Ecto.Migration
def change do
alter table(:events) do
add(:subdivision1_code, :"LowCardinality(String)")
add(:subdivision2_code, :"LowCardinality(String)")
add(:city_geoname_id, :UInt32)
end
alter table(:sessions) do
add(:subdivision1_code, :"LowCardinality(String)")
add(:subdivision2_code, :"LowCardinality(String)")
add(:city_geoname_id, :UInt32)
end
end
end

1677
priv/iso_3166-1.json Normal file

File diff suppressed because it is too large Load Diff

25734
priv/iso_3166-2.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -45,10 +45,44 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
conn =
get(
conn,
"/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=GBR,UKR,URY,USA"
"/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=Unit"
)
assert json_response(conn, 200) == ["USA"]
assert json_response(conn, 200) == [%{"code" => "USA", "name" => "United States"}]
end
test "returns suggestions for regions", %{conn: conn, user: user} do
{:ok, [site: site]} = create_new_site(%{user: user})
populate_stats(site, [
build(:pageview, country_code: "EE", subdivision1_code: "EE-37"),
build(:pageview, country_code: "EE", subdivision1_code: "EE-39")
])
conn =
get(
conn,
"/api/stats/#{site.domain}/suggestions/region?q=Har"
)
assert json_response(conn, 200) == [%{"code" => "EE-37", "name" => "Harjumaa"}]
end
test "returns suggestions for cities", %{conn: conn, user: user} do
{:ok, [site: site]} = create_new_site(%{user: user})
populate_stats(site, [
build(:pageview, country_code: "EE", subdivision1_code: "EE-37", city_geoname_id: 588_409),
build(:pageview, country_code: "EE", subdivision1_code: "EE-39", city_geoname_id: 591_632)
])
conn =
get(
conn,
"/api/stats/#{site.domain}/suggestions/city?q=Kär"
)
assert json_response(conn, 200) == [%{"code" => "591632", "name" => "Kärdla"}]
end
test "returns suggestions for countries without country in search", %{conn: conn, site: site} do