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: on:
push: push:
branches: [ master, stable ] branches: [ master, stable, dev ]
workflow_dispatch: workflow_dispatch:

View File

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Data exported via the download button will contain CSV data for all visible graps in a zip file. - 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. - The `u` option can now be used in the `manual` extension to specify a URL when triggering events.
## v1.4.1 ## v1.4.1

View File

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

View File

@ -25,7 +25,7 @@ export default function SearchSelect(props) {
function fetchOptions({inputValue, isOpen}) { function fetchOptions({inputValue, isOpen}) {
setLoading(isOpen) setLoading(isOpen)
return props.fetchOptions(inputValue).then((loadedItems) => { return props.fetchOptions(inputValue || '').then((loadedItems) => {
setLoading(false) setLoading(false)
setItems(loadedItems) setItems(loadedItems)
}) })
@ -45,9 +45,16 @@ export default function SearchSelect(props) {
closeMenu, closeMenu,
} = useCombobox({ } = useCombobox({
items, items,
itemToString: (item) => item.hasOwnProperty('name') ? item.name : item,
onInputValueChange: (changes) => { onInputValueChange: (changes) => {
debouncedFetchOptions(changes) debouncedFetchOptions(changes)
props.onInput(changes.inputValue) props.onInput(changes.inputValue)
if (changes.inputValue === '') {
props.onSelect({name: '', code: ''})
}
},
onSelectedItemChange: (changes) => {
props.onSelect(changes.selectedItem)
}, },
initialSelectedItem: props.initialSelectedItem, initialSelectedItem: props.initialSelectedItem,
onIsOpenChange: (state) => { onIsOpenChange: (state) => {
@ -87,10 +94,10 @@ export default function SearchSelect(props) {
items.map((item, index) => ( items.map((item, index) => (
<li <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})} 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 })} {...getItemProps({ item, index })}
> >
{item} {item.name ? item.name : item}
</li> </li>
)) ))
} }

View File

@ -13,6 +13,8 @@ function removeFilter(key, history, query) {
[key]: false [key]: false
} }
if (key === 'goal') { newOpts.props = false } if (key === 'goal') { newOpts.props = false }
if (key === 'region') { newOpts.region_name = false }
if (key === 'city') { newOpts.city_name = false }
navigateToQuery( navigateToQuery(
history, history,
query, query,
@ -62,6 +64,18 @@ function filterText(key, rawValue, query) {
return <>Country {type} <b>{selectedCountry.properties.name}</b></> 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] const formattedFilter = formattedFilters[key]
if (formattedFilter) { if (formattedFilter) {

View File

@ -16,7 +16,8 @@ if (container) {
insertedAt: container.dataset.insertedAt, insertedAt: container.dataset.insertedAt,
embedded: container.dataset.embedded, embedded: container.dataset.embedded,
background: container.dataset.background, 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' const loggedIn = container.dataset.loggedIn === 'true'

View File

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

View File

@ -1,4 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import {BrowserRouter, Switch, Route, useLocation} from "react-router-dom";
import Dash from './index' import Dash from './index'
import SourcesModal from './stats/modals/sources' import SourcesModal from './stats/modals/sources'
import ReferrersDrilldownModal from './stats/modals/referrer-drilldown' 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 EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages' import ExitPagesModal from './stats/modals/exit-pages'
import CountriesModal from './stats/modals/countries' import CountriesModal from './stats/modals/countries'
import ModalTable from './stats/modals/table'
import FilterModal from './stats/modals/filter' import FilterModal from './stats/modals/filter'
import {BrowserRouter, Switch, Route, useLocation} from "react-router-dom";
function ScrollToTop() { function ScrollToTop() {
const location = useLocation(); const location = useLocation();
@ -51,6 +52,12 @@ export default function Router({site, loggedIn, currentUserRole}) {
<Route path="/:domain/countries"> <Route path="/:domain/countries">
<CountriesModal site={site} /> <CountriesModal site={site} />
</Route> </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"]}> <Route path={["/:domain/filter/:field"]}>
<FilterModal site={site} /> <FilterModal site={site} />
</Route> </Route>

View File

@ -4,8 +4,46 @@ import * as storage from '../../storage'
import Countries from './countries'; import Countries from './countries';
import CountriesMap from './map' 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 = { const labelFor = {
'map': 'Countries Map',
'countries': 'Countries', 'countries': 'Countries',
'regions': 'Regions', 'regions': 'Regions',
'cities': 'Cities', 'cities': 'Cities',
@ -30,6 +68,10 @@ export default class Locations extends React.Component {
renderContent() { renderContent() {
switch(this.state.mode) { 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": case "countries":
return <Countries site={this.props.site} query={this.props.query} timer={this.props.timer}/> return <Countries site={this.props.site} query={this.props.query} timer={this.props.timer}/>
case "map": 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"> <ul className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
{ this.renderPill('Map', 'map') } { this.renderPill('Map', 'map') }
{ this.renderPill('Countries', 'countries') } { this.renderPill('Countries', 'countries') }
{/* { this.renderPill('Regions', 'regions') } */} { this.props.site.cities && this.renderPill('Regions', 'regions') }
{/* { this.renderPill('Cities', 'cities') } */} { this.props.site.cities && this.renderPill('Cities', 'cities') }
</ul> </ul>
</div> </div>
{ this.renderContent() } { this.renderContent() }

View File

@ -11,7 +11,7 @@ import * as api from '../../api'
import { navigateToQuery } from '../../query' import { navigateToQuery } from '../../query'
import { withThemeConsumer } from '../../theme-consumer-hoc'; import { withThemeConsumer } from '../../theme-consumer-hoc';
class CountriesMap extends React.Component { class Countries extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.resizeMap = this.resizeMap.bind(this) this.resizeMap = this.resizeMap.bind(this)
@ -21,18 +21,9 @@ class CountriesMap extends React.Component {
this.onVisible = this.onVisible.bind(this) 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) { componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) { if (this.props.query !== prevProps.query) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({loading: true, countries: null}) this.setState({loading: true, countries: null})
this.fetchCountries().then(this.drawMap) 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() { getDataset() {
var dataset = {}; const dataset = {};
var onlyValues = this.state.countries.map(function(obj){ return obj.visitors }); var onlyValues = this.state.countries.map(function(obj){ return obj.visitors });
var maxValue = Math.max.apply(null, onlyValues); 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]) .domain([0,maxValue])
.range([ .range([
this.props.darkTheme ? "#2e3954" : "#f3ebff", this.props.darkTheme ? "#2e3954" : "#f3ebff",
this.props.darkTheme ? "#6366f1" : "#a779e9" this.props.darkTheme ? "#6366f1" : "#a779e9"
]); ])
this.state.countries.forEach(function(item){ this.state.countries.forEach(function(item){
dataset[item.name] = {numberOfThings: item.visitors, fillColor: paletteScale(item.visitors)}; dataset[item.name] = {numberOfThings: item.visitors, fillColor: paletteScale(item.visitors)};
@ -82,7 +84,7 @@ class CountriesMap extends React.Component {
} }
drawMap() { drawMap() {
var dataset = this.getDataset(); const dataset = this.getDataset();
const label = this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' const label = this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
const defaultFill = this.props.darkTheme ? '#2d3747' : '#f8fafc' const defaultFill = this.props.darkTheme ? '#2d3747' : '#f8fafc'
const highlightFill = this.props.darkTheme ? '#374151' : '#F5F5F5' const highlightFill = this.props.darkTheme ? '#374151' : '#F5F5F5'
@ -98,16 +100,14 @@ class CountriesMap extends React.Component {
geographyConfig: { geographyConfig: {
borderColor, borderColor,
highlightBorderWidth: 2, highlightBorderWidth: 2,
highlightFillColor: function(geo) { highlightFillColor: (geo) => geo.fillColor || highlightFill,
return geo['fillColor'] || highlightFill;
},
highlightBorderColor, highlightBorderColor,
popupTemplate: function(geo, data) { popupTemplate: (geo, data) => {
if (!data) { return ; } if (!data) { return null; }
const pluralizedLabel = data.numberOfThings === 1 ? label.slice(0, -1) : label 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">', 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>', '<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(''); '</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> <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() { renderBody() {
if (this.state.countries) { if (this.state.countries) {
return ( return (
<React.Fragment> <>
<div className="mx-auto mt-4" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div> <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" /> <MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />
{ this.geolocationDbNotice() } { this.geolocationDbNotice() }
</React.Fragment> </>
) )
} }
return null
} }
render() { 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 ( return (
<tr className="text-sm dark:text-gray-200" key={country.name}> <tr className="text-sm dark:text-gray-200" key={country.name}>
<td className="p-2"> <td className="p-2">
<Link <Link className="hover:underline" to={{search: query.toString(), pathname: `/${encodeURIComponent(this.props.site.domain)}`}}>
className="hover:underline"
to={{search: query.toString(),
pathname: `/${ encodeURIComponent(this.props.site.domain)}`}}
>
{countryFullName} {countryFullName}
</Link> </Link>
</td> </td>
@ -107,6 +103,8 @@ class CountriesModal extends React.Component {
</> </>
) )
} }
return null
} }
render() { render() {

View File

@ -14,7 +14,7 @@ import {apiPath, sitePath} from '../../url'
export const FILTER_GROUPS = { export const FILTER_GROUPS = {
'page': ['page'], 'page': ['page'],
'source': ['source', 'referrer'], 'source': ['source', 'referrer'],
'country': ['country'], 'country': ['country', 'region', 'city'],
'screen': ['screen'], 'screen': ['screen'],
'browser': ['browser', 'browser_version'], 'browser': ['browser', 'browser_version'],
'os': ['os', 'os_version'], 'os': ['os', 'os_version'],
@ -32,10 +32,22 @@ function getCountryName(ISOCode) {
function getFormState(filterGroup, query) { function getFormState(filterGroup, query) {
return FILTER_GROUPS[filterGroup].reduce((result, filter) => { 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' 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() { handleSubmit() {
const { formState } = this.state; 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 let finalFilterValue = value
if (filterKey === 'country') { if (filterKey === 'country') {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries; const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
@ -110,6 +122,9 @@ class FilterModal extends React.Component {
finalFilterValue = selectedCountry.id 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() finalFilterValue = (type === 'is_not' ? '!' : '') + finalFilterValue.trim()
res.push({filter: filterKey, value: finalFilterValue}) res.push({filter: filterKey, value: finalFilterValue})
@ -119,10 +134,26 @@ class FilterModal extends React.Component {
this.selectFiltersAndCloseModal(filters) this.selectFiltersAndCloseModal(filters)
} }
onInput(filterName) { onSelect(filterName) {
return (val) => { if (this.state.selectedFilterGroup !== 'country') {
return () => {}
}
return (value) => {
this.setState(prevState => ({formState: Object.assign(prevState.formState, { 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) => { return (input) => {
const {query, formState} = this.state const {query, formState} = this.state
const formFilters = Object.fromEntries( 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 }} 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() }) return api.get(apiPath(this.props.site, `/suggestions/${filter}`), updatedQuery, { q: input.trim() })
} }
@ -176,7 +200,15 @@ class FilterModal extends React.Component {
} }
renderFilterInputs() { 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="mt-4" key={filter}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{ formattedFilters[filter] }</div> <div className="text-sm font-medium text-gray-700 dark:text-gray-300">{ formattedFilters[filter] }</div>
<div className="flex items-start mt-1"> <div className="flex items-start mt-1">
@ -185,14 +217,15 @@ class FilterModal extends React.Component {
<SearchSelect <SearchSelect
key={filter} key={filter}
fetchOptions={this.fetchOptions(filter)} fetchOptions={this.fetchOptions(filter)}
initialSelectedItem={this.state.formState[filter].value} initialSelectedItem={this.state.formState[filter]}
onInput={this.onInput(filter)} onInput={this.onInput(filter)}
onSelect={this.onSelect(filter)}
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`}
/> />
</div> </div>
</div> </div>
)) )
})
} }
renderFilterTypeSelector(filterName) { 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 React from 'react';
import { Link } from 'react-router-dom' 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) { if (list.length > 0) {
return ( return (
<div className="text-center w-full py-3 md:pb-3 md:pt-0 md:absolute md:bottom-0 md:left-0"> <div className="text-center w-full py-3 md:pb-3 md:pt-0 md:absolute md:bottom-0 md:left-0">
<Link <Link
to={`/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`} to={url || `/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
// eslint-disable-next-line max-len // 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" 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 = '') { export function sitePath(site, path = '') {
return `/${encodeURIComponent(site.domain)}${path}` return `/${encodeURIComponent(site.domain)}${path}${window.location.search}`
} }
export function setQuery(key, value) { export function setQuery(key, value) {

View File

@ -8,3 +8,5 @@ LOG_LEVEL=debug
SELFHOST=false SELFHOST=false
DISABLE_CRON=true DISABLE_CRON=true
ADMIN_USER_IDS=1 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" 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 = disable_auth =
config_dir config_dir
|> get_var_from_path_or_env("DISABLE_AUTH", "false") |> get_var_from_path_or_env("DISABLE_AUTH", "false")
@ -146,6 +148,11 @@ is_selfhost =
|> get_var_from_path_or_env("SELFHOST", "true") |> get_var_from_path_or_env("SELFHOST", "true")
|> String.to_existing_atom() |> String.to_existing_atom()
show_cities =
config_dir
|> get_var_from_path_or_env("SHOW_CITIES", "false")
|> String.to_existing_atom()
custom_script_name = custom_script_name =
config_dir config_dir
|> get_var_from_path_or_env("CUSTOM_SCRIPT_NAME", "script") |> get_var_from_path_or_env("CUSTOM_SCRIPT_NAME", "script")
@ -186,6 +193,7 @@ config :plausible,
site_limit: site_limit, site_limit: site_limit,
site_limit_exempt: site_limit_exempt, site_limit_exempt: site_limit_exempt,
is_selfhost: is_selfhost, is_selfhost: is_selfhost,
show_cities: show_cities,
custom_script_name: custom_script_name, custom_script_name: custom_script_name,
domain_blacklist: domain_blacklist 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, config :geolix,
databases: [ databases: [
%{ %{
id: :country, id: :geolocation,
adapter: Geolix.Adapter.MMDB2, adapter: Geolix.Adapter.MMDB2,
source: geolite2_country_db, source: ip_geolocation_db,
result_as: :raw result_as: :raw
} }
] ]

View File

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

View File

@ -19,6 +19,10 @@ defmodule Plausible.ClickhouseEvent do
field :utm_campaign, :string, default: "" field :utm_campaign, :string, default: ""
field :country_code, :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 :screen_size, :string, default: ""
field :operating_system, :string, default: "" field :operating_system, :string, default: ""
field :operating_system_version, :string, default: "" field :operating_system_version, :string, default: ""
@ -50,6 +54,9 @@ defmodule Plausible.ClickhouseEvent do
:utm_source, :utm_source,
:utm_campaign, :utm_campaign,
:country_code, :country_code,
:subdivision1_code,
:subdivision2_code,
:city_geoname_id,
:screen_size, :screen_size,
:"meta.key", :"meta.key",
:"meta.value" :"meta.value"

View File

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

View File

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

View File

@ -338,6 +338,24 @@ defmodule Plausible.Stats.Breakdown do
) )
end 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 defp do_group_by(q, "visit:entry_page") do
from( from(
s in q, s in q,

View File

@ -1,4 +1,23 @@
defmodule Plausible.Stats.CountryName do 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 %{ @country_codes_to_names %{
"AF" => "Afghanistan", "AF" => "Afghanistan",
"AX" => "Aland Islands", "AX" => "Aland Islands",
@ -505,7 +524,52 @@ defmodule Plausible.Stats.CountryName do
Map.get(@alpha2_codes, code, code) Map.get(@alpha2_codes, code, code)
end 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 def from_iso3166(code) do
Map.get(@country_codes_to_names, code, code) Map.get(@country_codes_to_names, code, code)
end 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 end

View File

@ -2,25 +2,109 @@ defmodule Plausible.Stats.FilterSuggestions do
use Plausible.Repo use Plausible.Repo
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
import Plausible.Stats.Base import Plausible.Stats.Base
alias Plausible.Stats.CountryName
def filter_suggestions(site, query, "country", filter_search) do def filter_suggestions(site, query, "country", filter_search) do
filter_search = matches = Plausible.Stats.CountryName.search_alpha2(filter_search)
String.split(filter_search, ",")
|> Enum.map(fn c -> Plausible.Stats.CountryName.to_alpha2(c) end)
q = q =
from( from(
e in query_sessions(site, query), e in query_sessions(site, query),
group_by: e.country_code, group_by: e.country_code,
order_by: [desc: fragment("count(*)")], order_by: [desc: fragment("count(*)")],
select: {e.country_code} select: e.country_code
) )
ClickhouseRepo.all(q) ClickhouseRepo.all(q)
|> Enum.map(fn {x} -> x end) |> Enum.filter(fn c -> Enum.find(matches, false, fn x -> x == c end) 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.slice(0..24) |> 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 end
def filter_suggestions(site, _query, "goal", filter_search) do def filter_suggestions(site, _query, "goal", filter_search) do

View File

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

View File

@ -91,7 +91,7 @@ defmodule PlausibleWeb.Api.ExternalController do
query = decode_query_params(uri) query = decode_query_params(uri)
ref = parse_referrer(uri, params["referrer"]) ref = parse_referrer(uri, params["referrer"])
country_code = visitor_country(conn) location_details = visitor_location_details(conn)
salts = Plausible.Session.Salts.fetch() salts = Plausible.Session.Salts.fetch()
event_attrs = %{ event_attrs = %{
@ -104,7 +104,11 @@ defmodule PlausibleWeb.Api.ExternalController do
utm_medium: query["utm_medium"], utm_medium: query["utm_medium"],
utm_source: query["utm_source"], utm_source: query["utm_source"],
utm_campaign: query["utm_campaign"], 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: ua && os_name(ua),
operating_system_version: ua && os_version(ua), operating_system_version: ua && os_version(ua),
browser: ua && browser_name(ua), browser: ua && browser_name(ua),
@ -216,12 +220,38 @@ defmodule PlausibleWeb.Api.ExternalController do
end end
@decorate trace("ingest.geolocation") @decorate trace("ingest.geolocation")
defp visitor_country(conn) do defp visitor_location_details(conn) do
result = result =
PlausibleWeb.RemoteIp.get(conn) PlausibleWeb.RemoteIp.get(conn)
|> Geolix.lookup() |> 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 end
@decorate trace("ingest.parse_referrer") @decorate trace("ingest.parse_referrer")

View File

@ -516,6 +516,38 @@ defmodule PlausibleWeb.Api.StatsController do
end end
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 def browsers(conn, params) do
site = conn.assigns[:site] site = conn.assigns[:site]
query = Query.from(site.timezone, params) |> Filters.add_prefix() 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/entry-pages", StatsController, :entry_pages
get "/:domain/exit-pages", StatsController, :exit_pages get "/:domain/exit-pages", StatsController, :exit_pages
get "/:domain/countries", StatsController, :countries get "/:domain/countries", StatsController, :countries
get "/:domain/regions", StatsController, :regions
get "/:domain/cities", StatsController, :cities
get "/:domain/browsers", StatsController, :browsers get "/:domain/browsers", StatsController, :browsers
get "/:domain/browser-versions", StatsController, :browser_versions get "/:domain/browser-versions", StatsController, :browser_versions
get "/:domain/operating-systems", StatsController, :operating_systems get "/:domain/operating-systems", StatsController, :operating_systems

View File

@ -5,7 +5,7 @@
</div> </div>
<% end %> <% end %>
<div class="pt-6"></div> <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> <div id="modal_root"></div>
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %> <%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
<div class="bg-gray-50 dark:bg-gray-850"> <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 = conn =
get( get(
conn, 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 end
test "returns suggestions for countries without country in search", %{conn: conn, site: site} do test "returns suggestions for countries without country in search", %{conn: conn, site: site} do