mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +03:00
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:
parent
06d0d0eafa
commit
05bf43c1be
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@ -2,7 +2,7 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, stable ]
|
||||
branches: [ master, stable, dev ]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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>
|
||||
))
|
||||
}
|
||||
|
@ -12,7 +12,9 @@ function removeFilter(key, history, query) {
|
||||
const newOpts = {
|
||||
[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(
|
||||
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) {
|
||||
|
@ -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'
|
||||
|
@ -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,26 +104,24 @@ 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
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
navigateToQuery(history, query, to)
|
||||
if (this.props.onClick) this.props.onClick(event)
|
||||
history.push({ pathname: window.location.pathname, search: generateQueryString(to) })
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
function QueryButton({history, query, to, disabled, className, children, onClick}) {
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
navigateToQuery(history, query, to)
|
||||
if (onClick) onClick(event)
|
||||
history.push({ pathname: window.location.pathname, search: generateQueryString(to) })
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</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'
|
||||
|
@ -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>
|
||||
|
@ -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() }
|
||||
|
@ -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))
|
||||
|
@ -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() {
|
||||
|
@ -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,18 +168,11 @@ 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() })
|
||||
return api.get(apiPath(this.props.site, `/suggestions/${filter}`), updatedQuery, { q: input.trim() })
|
||||
|
||||
}
|
||||
}
|
||||
@ -176,23 +200,32 @@ class FilterModal extends React.Component {
|
||||
}
|
||||
|
||||
renderFilterInputs() {
|
||||
return FILTER_GROUPS[this.state.selectedFilterGroup].map((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="flex items-start mt-1">
|
||||
{ this.renderFilterTypeSelector(filter) }
|
||||
const groups = FILTER_GROUPS[this.state.selectedFilterGroup].filter((filterName) => {
|
||||
if (['city', 'region'].includes(filterName)) {
|
||||
return this.props.site.cities
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
<SearchSelect
|
||||
key={filter}
|
||||
fetchOptions={this.fetchOptions(filter)}
|
||||
initialSelectedItem={this.state.formState[filter].value}
|
||||
onInput={this.onInput(filter)}
|
||||
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`}
|
||||
/>
|
||||
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">
|
||||
{ this.renderFilterTypeSelector(filter) }
|
||||
|
||||
<SearchSelect
|
||||
key={filter}
|
||||
fetchOptions={this.fetchOptions(filter)}
|
||||
initialSelectedItem={this.state.formState[filter]}
|
||||
onInput={this.onInput(filter)}
|
||||
onSelect={this.onSelect(filter)}
|
||||
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
renderFilterTypeSelector(filterName) {
|
||||
|
108
assets/js/dashboard/stats/modals/table.js
Normal file
108
assets/js/dashboard/stats/modals/table.js
Normal 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)
|
@ -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"
|
||||
>
|
||||
|
97
assets/js/dashboard/stats/reports/list.js
Normal file
97
assets/js/dashboard/stats/reports/list.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
|
@ -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"}},
|
||||
|
@ -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"
|
||||
|
@ -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])
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,6 +12,8 @@ defmodule Plausible.Stats.Filters do
|
||||
"os",
|
||||
"os_version",
|
||||
"country",
|
||||
"region",
|
||||
"city",
|
||||
"entry_page",
|
||||
"exit_page"
|
||||
]
|
||||
|
@ -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")
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
196716
priv/cities500.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
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
25734
priv/iso_3166-2.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user