diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 2caecc75..4d7633df 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -23,3 +23,5 @@ export const supportsOrphanVisits = supportsShortUrlTitle; export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle; + +export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index b79cfad8..8a252e95 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -2,17 +2,16 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; -import { Settings } from '../settings/reducers/settings'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { NormalizedVisit, VisitsInfo } from './types'; import { VisitsExporter } from './services/VisitsExporter'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface OrphanVisitsProps extends RouteComponentProps { +export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { getOrphanVisits: (params: ShlinkVisitsParams) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; - settings: Settings; } export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -22,6 +21,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure orphanVisits, cancelGetOrphanVisits, settings, + selectedServer, }: OrphanVisitsProps) => { const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); @@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure baseUrl={url} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} isOrphanVisits > diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index eac8b953..538b4eea 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -5,20 +5,19 @@ import { ShlinkVisitsParams } from '../api/types'; import { parseQuery } from '../utils/helpers/query'; import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; -import { Settings } from '../settings/reducers/settings'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import VisitsStats from './VisitsStats'; import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> { +export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> { getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; shortUrlDetail: ShortUrlDetail; cancelGetShortUrlVisits: () => void; - settings: Settings; } const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -31,6 +30,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( getShortUrlDetail, cancelGetShortUrlVisits, settings, + selectedServer, }: ShortUrlVisitsProps) => { const { shortCode } = params; const { domain } = parseQuery<{ domain?: string }>(search); @@ -53,6 +53,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( domain={domain} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} > diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index b7dd573b..d4d47eaa 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -3,18 +3,17 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import ColorGenerator from '../utils/services/ColorGenerator'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; -import { Settings } from '../settings/reducers/settings'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; import VisitsStats from './VisitsStats'; import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; -export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> { +export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { getTagVisits: (tag: string, query: any) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; - settings: Settings; } const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({ @@ -24,6 +23,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor tagVisits, cancelGetTagVisits, settings, + selectedServer, }: TagVisitsProps) => { const { tag } = params; const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); @@ -37,6 +37,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor baseUrl={url} settings={settings} exportCsv={exportCsv} + selectedServer={selectedServer} > diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index ad2e13a1..628964ca 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -15,6 +15,7 @@ import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/typ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; +import { SelectedServer } from '../servers/data'; import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; @@ -23,13 +24,14 @@ import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } f import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { processStatsFromVisits } from './services/VisitsParser'; import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; -import './VisitsStats.scss'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; +import './VisitsStats.scss'; export interface VisitsStatsProps { getVisits: (params: Partial) => void; visitsInfo: VisitsInfo; settings: Settings; + selectedServer: SelectedServer; cancelGetVisits: () => void; baseUrl: string; domain?: string; @@ -67,9 +69,18 @@ const VisitsNavLink: FC = ({ subPath, title ); -const VisitsStats: FC = ( - { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false }, -) => { +const VisitsStats: FC = ({ + children, + visitsInfo, + getVisits, + cancelGetVisits, + baseUrl, + domain, + settings, + exportCsv, + selectedServer, + isOrphanVisits = false, +}) => { const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); @@ -243,6 +254,7 @@ const VisitsStats: FC = ( selectedVisits={highlightedVisits} setSelectedVisits={setSelectedVisits} isOrphanVisits={isOrphanVisits} + selectedServer={selectedServer} /> diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 9eb0eef2..445bf017 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -6,12 +6,16 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon, faCheck as checkIcon, + faRobot as botIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UncontrolledTooltip } from 'reactstrap'; import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; import { determineOrderDir, OrderDir } from '../utils/utils'; import { prettify } from '../utils/helpers/numbers'; +import { supportsBotVisits } from '../utils/helpers/features'; +import { SelectedServer } from '../servers/data'; import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import './VisitsTable.scss'; @@ -21,9 +25,10 @@ interface VisitsTableProps { setSelectedVisits: (visits: NormalizedVisit[]) => void; matchMedia?: (query: string) => MediaQueryList; isOrphanVisits?: boolean; + selectedServer: SelectedServer; } -type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl'; +type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; interface Order { field?: OrderableFields; @@ -58,6 +63,7 @@ const VisitsTable = ({ visits, selectedVisits = [], setSelectedVisits, + selectedServer, matchMedia = window.matchMedia, isOrphanVisits = false, }: VisitsTableProps) => { @@ -69,10 +75,12 @@ const VisitsTable = ({ const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const isFirstLoad = useRef(true); + const supportsBots = supportsBotVisits(selectedServer); const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; + const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits); const orderByColumn = (field: OrderableFields) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); @@ -102,13 +110,19 @@ const VisitsTable = ({ setSelectedVisits( selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], )} > 0 })} /> + {supportsBots && ( + + + {renderOrderIcon('potentialBot')} + + )} Date {renderOrderIcon('date')} @@ -141,7 +155,7 @@ const VisitsTable = ({ )} - + @@ -149,7 +163,7 @@ const VisitsTable = ({ {!resultSet.visitsGroups[page - 1]?.length && ( - + No visits found with current filtering @@ -169,6 +183,18 @@ const VisitsTable = ({ {isSelected && } + {supportsBots && ( + + {visit.potentialBot && ( + <> + + + Potentially a visit from a bot or crawler + + + )} + + )} {visit.date} @@ -185,7 +211,7 @@ const VisitsTable = ({ {resultSet.total > PAGE_SIZE && ( - +
visits.redu ); export const normalizeVisits = map((visit: Visit): NormalizedVisit => { - const { userAgent, date, referer, visitLocation } = visit; + const { userAgent, date, referer, visitLocation, potentialBot = false } = visit; const common = { date, + potentialBot, ...parseUserAgent(userAgent), referer: extractDomain(referer), country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 733cac95..9eb3f5eb 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -18,19 +18,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter'); bottle.decorator('ShortUrlVisits', connect( - [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], + [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); bottle.decorator('TagVisits', connect( - [ 'tagVisits', 'mercureInfo', 'settings' ], + [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); bottle.decorator('OrphanVisits', connect( - [ 'orphanVisits', 'mercureInfo', 'settings' ], + [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], )); diff --git a/src/visits/types/CommonVisitsProps.ts b/src/visits/types/CommonVisitsProps.ts new file mode 100644 index 00000000..7e5a21b4 --- /dev/null +++ b/src/visits/types/CommonVisitsProps.ts @@ -0,0 +1,7 @@ +import { SelectedServer } from '../../servers/data'; +import { Settings } from '../../settings/reducers/settings'; + +export interface CommonVisitsProps { + selectedServer: SelectedServer; + settings: Settings; +} diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 0e2879d5..ffaccb47 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -38,6 +38,7 @@ export interface RegularVisit { date: string; userAgent: string; visitLocation: VisitLocation | null; + potentialBot?: boolean; // Optional only when using Shlink older than v2.7 } export interface OrphanVisit extends RegularVisit { @@ -59,6 +60,7 @@ export interface NormalizedRegularVisit extends UserAgent { city: string; latitude?: number | null; longitude?: number | null; + potentialBot: boolean; } export interface NormalizedOrphanVisit extends NormalizedRegularVisit { diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index 393ab64d..65c71992 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -5,6 +5,7 @@ import { rangeOf } from '../../src/utils/utils'; import SimplePaginator from '../../src/common/SimplePaginator'; import SearchField from '../../src/utils/SearchField'; import { NormalizedVisit } from '../../src/visits/types'; +import { SelectedServer } from '../../src/servers/data'; describe('', () => { const matchMedia = () => Mock.of({ matches: false }); @@ -18,6 +19,7 @@ describe('', () => { setSelectedVisits={setSelectedVisits} matchMedia={matchMedia} isOrphanVisits={isOrphanVisits} + selectedServer={Mock.all()} />, ); diff --git a/test/visits/services/VisitsParser.test.ts b/test/visits/services/VisitsParser.test.ts index 7edafab8..d629985e 100644 --- a/test/visits/services/VisitsParser.test.ts +++ b/test/visits/services/VisitsParser.test.ts @@ -43,6 +43,7 @@ describe('VisitsParser', () => { }), Mock.of({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41', + potentialBot: true, }), ]; const orphanVisits: OrphanVisit[] = [ @@ -61,6 +62,7 @@ describe('VisitsParser', () => { Mock.of({ type: 'regular_404', visitedUrl: 'bar', + potentialBot: true, }), Mock.of({ type: 'invalid_short_url', @@ -73,6 +75,7 @@ describe('VisitsParser', () => { latitude: 123.45, longitude: -543.21, }, + potentialBot: false, }), ]; @@ -176,6 +179,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 123.45, longitude: -543.21, + potentialBot: false, }, { browser: 'Firefox', @@ -186,6 +190,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 1029, longitude: 6758, + potentialBot: false, }, { browser: 'Chrome', @@ -196,6 +201,7 @@ describe('VisitsParser', () => { date: undefined, latitude: undefined, longitude: undefined, + potentialBot: false, }, { browser: 'Chrome', @@ -206,6 +212,7 @@ describe('VisitsParser', () => { date: undefined, latitude: 123.45, longitude: -543.21, + potentialBot: false, }, { browser: 'Opera', @@ -216,6 +223,7 @@ describe('VisitsParser', () => { date: undefined, latitude: undefined, longitude: undefined, + potentialBot: true, }, ]); }); @@ -233,6 +241,7 @@ describe('VisitsParser', () => { longitude: 6758, type: 'base_url', visitedUrl: 'foo', + potentialBot: false, }, { type: 'regular_404', @@ -245,6 +254,7 @@ describe('VisitsParser', () => { longitude: undefined, os: 'Others', referer: 'Direct', + potentialBot: true, }, { browser: 'Chrome', @@ -257,6 +267,7 @@ describe('VisitsParser', () => { longitude: -543.21, type: 'invalid_short_url', visitedUrl: 'bar', + potentialBot: false, }, ]); });