Merge pull request #441 from acelaya-forks/feature/bots-support

Feature/bots support
This commit is contained in:
Alejandro Celaya 2021-06-13 11:58:58 +02:00 committed by GitHub
commit a0ab9533cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 145 additions and 47 deletions

View file

@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided. * `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a a `conf.d` folder. * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a a `conf.d` folder.
* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
### Changed ### Changed
* *Nothing* * *Nothing*

View file

@ -1,13 +1,13 @@
FROM node:14.15-alpine as node FROM node:14.17-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \ RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.19.6-alpine FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY scripts/docker/servers.json_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
COPY --from=node /shlink-web-client/build /usr/share/nginx/html COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View file

@ -3,7 +3,7 @@ version: '3'
services: services:
shlink_web_client_node: shlink_web_client_node:
container_name: shlink_web_client_node container_name: shlink_web_client_node
image: node:14.15-alpine image: node:14.17-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www

View file

@ -23,3 +23,5 @@ export const supportsOrphanVisits = supportsShortUrlTitle;
export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsQrCodeMargin = supportsShortUrlTitle;
export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle;
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });

View file

@ -2,17 +2,16 @@ import { RouteComponentProps } from 'react-router';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types'; import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings } from '../settings/reducers/settings';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { OrphanVisitsHeader } from './OrphanVisitsHeader';
import { NormalizedVisit, VisitsInfo } from './types'; import { NormalizedVisit, VisitsInfo } from './types';
import { VisitsExporter } from './services/VisitsExporter'; 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; getOrphanVisits: (params: ShlinkVisitsParams) => void;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void; cancelGetOrphanVisits: () => void;
settings: Settings;
} }
export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -22,6 +21,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
orphanVisits, orphanVisits,
cancelGetOrphanVisits, cancelGetOrphanVisits,
settings, settings,
selectedServer,
}: OrphanVisitsProps) => { }: OrphanVisitsProps) => {
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
baseUrl={url} baseUrl={url}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
isOrphanVisits isOrphanVisits
> >
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} /> <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />

View file

@ -5,20 +5,19 @@ import { ShlinkVisitsParams } from '../api/types';
import { parseQuery } from '../utils/helpers/query'; import { parseQuery } from '../utils/helpers/query';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { Settings } from '../settings/reducers/settings';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter'; import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types'; 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; getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
shortUrlVisits: ShortUrlVisitsState; shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: Function; getShortUrlDetail: Function;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
cancelGetShortUrlVisits: () => void; cancelGetShortUrlVisits: () => void;
settings: Settings;
} }
const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -31,6 +30,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
getShortUrlDetail, getShortUrlDetail,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
settings, settings,
selectedServer,
}: ShortUrlVisitsProps) => { }: ShortUrlVisitsProps) => {
const { shortCode } = params; const { shortCode } = params;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
@ -53,6 +53,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
domain={domain} domain={domain}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} /> <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats> </VisitsStats>

View file

@ -3,18 +3,17 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { ShlinkVisitsParams } from '../api/types'; import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings } from '../settings/reducers/settings';
import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import TagVisitsHeader from './TagVisitsHeader'; import TagVisitsHeader from './TagVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter'; import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types'; 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; getTagVisits: (tag: string, query: any) => void;
tagVisits: TagVisitsState; tagVisits: TagVisitsState;
cancelGetTagVisits: () => void; cancelGetTagVisits: () => void;
settings: Settings;
} }
const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({
@ -24,6 +23,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
tagVisits, tagVisits,
cancelGetTagVisits, cancelGetTagVisits,
settings, settings,
selectedServer,
}: TagVisitsProps) => { }: TagVisitsProps) => {
const { tag } = params; const { tag } = params;
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params);
@ -37,6 +37,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
baseUrl={url} baseUrl={url}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} /> <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats> </VisitsStats>

View file

@ -15,6 +15,7 @@ import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/typ
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data';
import SortableBarGraph from './helpers/SortableBarGraph'; import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard'; import GraphCard from './helpers/GraphCard';
import LineChartCard from './helpers/LineChartCard'; import LineChartCard from './helpers/LineChartCard';
@ -23,13 +24,14 @@ import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } f
import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { processStatsFromVisits } from './services/VisitsParser'; import { processStatsFromVisits } from './services/VisitsParser';
import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown';
import './VisitsStats.scss';
import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
import './VisitsStats.scss';
export interface VisitsStatsProps { export interface VisitsStatsProps {
getVisits: (params: Partial<ShlinkVisitsParams>) => void; getVisits: (params: Partial<ShlinkVisitsParams>) => void;
visitsInfo: VisitsInfo; visitsInfo: VisitsInfo;
settings: Settings; settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void; cancelGetVisits: () => void;
baseUrl: string; baseUrl: string;
domain?: string; domain?: string;
@ -67,9 +69,18 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
</NavLink> </NavLink>
); );
const VisitsStats: FC<VisitsStatsProps> = ( const VisitsStats: FC<VisitsStatsProps> = ({
{ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false }, children,
) => { visitsInfo,
getVisits,
cancelGetVisits,
baseUrl,
domain,
settings,
exportCsv,
selectedServer,
isOrphanVisits = false,
}) => {
const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval)); const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]); const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
@ -243,6 +254,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
selectedVisits={highlightedVisits} selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits} setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/> />
</div> </div>
</Route> </Route>

View file

@ -6,24 +6,29 @@ import {
faCaretDown as caretDownIcon, faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon, faCaretUp as caretUpIcon,
faCheck as checkIcon, faCheck as checkIcon,
faRobot as botIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UncontrolledTooltip } from 'reactstrap';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { determineOrderDir, OrderDir } from '../utils/utils'; import { determineOrderDir, OrderDir } from '../utils/utils';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss'; import './VisitsTable.scss';
interface VisitsTableProps { export interface VisitsTableProps {
visits: NormalizedVisit[]; visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[]; selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void; setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: (query: string) => MediaQueryList; matchMedia?: (query: string) => MediaQueryList;
isOrphanVisits?: boolean; 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 { interface Order {
field?: OrderableFields; field?: OrderableFields;
@ -58,6 +63,7 @@ const VisitsTable = ({
visits, visits,
selectedVisits = [], selectedVisits = [],
setSelectedVisits, setSelectedVisits,
selectedServer,
matchMedia = window.matchMedia, matchMedia = window.matchMedia,
isOrphanVisits = false, isOrphanVisits = false,
}: VisitsTableProps) => { }: VisitsTableProps) => {
@ -69,10 +75,11 @@ const VisitsTable = ({
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE; const start = end - PAGE_SIZE;
const supportsBots = supportsBotVisits(selectedServer);
const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) => const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
@ -102,13 +109,19 @@ const VisitsTable = ({
<thead className="visits-table__header"> <thead className="visits-table__header">
<tr> <tr>
<th <th
className="visits-table__header-cell visits-table__sticky text-center" className={`${headerCellsClass} text-center`}
onClick={() => setSelectedVisits( onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)} )}
> >
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} /> <FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th> </th>
{supportsBots && (
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
)}
<th className={headerCellsClass} onClick={orderByColumn('date')}> <th className={headerCellsClass} onClick={orderByColumn('date')}>
Date Date
{renderOrderIcon('date')} {renderOrderIcon('date')}
@ -141,7 +154,7 @@ const VisitsTable = ({
)} )}
</tr> </tr>
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="p-0"> <td colSpan={fullSizeColSpan} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} /> <SearchField noBorder large={false} onChange={setSearchTerm} />
</td> </td>
</tr> </tr>
@ -149,7 +162,7 @@ const VisitsTable = ({
<tbody> <tbody>
{!resultSet.visitsGroups[page - 1]?.length && ( {!resultSet.visitsGroups[page - 1]?.length && (
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="text-center"> <td colSpan={fullSizeColSpan} className="text-center">
No visits found with current filtering No visits found with current filtering
</td> </td>
</tr> </tr>
@ -169,6 +182,18 @@ const VisitsTable = ({
<td className="text-center"> <td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />} {isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td> </td>
{supportsBots && (
<td className="text-center">
{visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
)}
<td> <td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment> <Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td> </td>
@ -185,7 +210,7 @@ const VisitsTable = ({
{resultSet.total > PAGE_SIZE && ( {resultSet.total > PAGE_SIZE && (
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="visits-table__footer-cell visits-table__sticky"> <td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
<div className="row"> <div className="row">
<div className="col-md-6"> <div className="col-md-6">
<SimplePaginator <SimplePaginator

View file

@ -81,9 +81,10 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
); );
export const normalizeVisits = map((visit: Visit): NormalizedVisit => { export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation } = visit; const { userAgent, date, referer, visitLocation, potentialBot = false } = visit;
const common = { const common = {
date, date,
potentialBot,
...parseUserAgent(userAgent), ...parseUserAgent(userAgent),
referer: extractDomain(referer), referer: extractDomain(referer),
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing

View file

@ -18,19 +18,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter');
bottle.decorator('TagVisits', connect( bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings' ], [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo', 'settings' ], [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));

View file

@ -0,0 +1,7 @@
import { SelectedServer } from '../../servers/data';
import { Settings } from '../../settings/reducers/settings';
export interface CommonVisitsProps {
selectedServer: SelectedServer;
settings: Settings;
}

View file

@ -38,6 +38,7 @@ export interface RegularVisit {
date: string; date: string;
userAgent: string; userAgent: string;
visitLocation: VisitLocation | null; visitLocation: VisitLocation | null;
potentialBot?: boolean; // Optional only when using Shlink older than v2.7
} }
export interface OrphanVisit extends RegularVisit { export interface OrphanVisit extends RegularVisit {
@ -59,6 +60,7 @@ export interface NormalizedRegularVisit extends UserAgent {
city: string; city: string;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
potentialBot: boolean;
} }
export interface NormalizedOrphanVisit extends NormalizedRegularVisit { export interface NormalizedOrphanVisit extends NormalizedRegularVisit {

View file

@ -9,6 +9,7 @@ import VisitsStats from '../../src/visits/VisitsStats';
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter'; import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
import { SelectedServer } from '../../src/servers/data';
describe('<OrphanVisits />', () => { describe('<OrphanVisits />', () => {
it('wraps visits stats and header', () => { it('wraps visits stats and header', () => {
@ -28,6 +29,7 @@ describe('<OrphanVisits />', () => {
location={Mock.all<Location>()} location={Mock.all<Location>()}
match={Mock.of<match>({ url: 'the_base_url' })} match={Mock.of<match>({ url: 'the_base_url' })}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
selectedServer={Mock.all<SelectedServer>()}
/>, />,
).dive(); ).dive();
const stats = wrapper.find(VisitsStats); const stats = wrapper.find(VisitsStats);

View file

@ -10,6 +10,7 @@ import LineChartCard from '../../src/visits/helpers/LineChartCard';
import VisitsTable from '../../src/visits/VisitsTable'; import VisitsTable from '../../src/visits/VisitsTable';
import { Result } from '../../src/utils/Result'; import { Result } from '../../src/utils/Result';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { SelectedServer } from '../../src/servers/data';
describe('<VisitStats />', () => { describe('<VisitStats />', () => {
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ]; const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@ -27,6 +28,7 @@ describe('<VisitStats />', () => {
baseUrl={''} baseUrl={''}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={Mock.all<SelectedServer>()}
/>, />,
); );

View file

@ -1,43 +1,62 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import VisitsTable from '../../src/visits/VisitsTable'; import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import SimplePaginator from '../../src/common/SimplePaginator'; import SimplePaginator from '../../src/common/SimplePaginator';
import SearchField from '../../src/utils/SearchField'; import SearchField from '../../src/utils/SearchField';
import { NormalizedVisit } from '../../src/visits/types'; import { NormalizedVisit } from '../../src/visits/types';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
describe('<VisitsTable />', () => { describe('<VisitsTable />', () => {
const matchMedia = () => Mock.of<MediaQueryList>({ matches: false }); const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
const setSelectedVisits = jest.fn(); const setSelectedVisits = jest.fn();
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => { const wrapperFactory = (props: Partial<VisitsTableProps> = {}) => {
wrapper = shallow( wrapper = shallow(
<VisitsTable <VisitsTable
visits={visits} visits={[]}
selectedVisits={selectedVisits} selectedServer={Mock.all<SelectedServer>()}
setSelectedVisits={setSelectedVisits} {...props}
matchMedia={matchMedia} matchMedia={matchMedia}
isOrphanVisits={isOrphanVisits} setSelectedVisits={setSelectedVisits}
/>, />,
); );
return wrapper; return wrapper;
}; };
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => wrapperFactory(
{ visits, selectedVisits },
);
const createOrphanVisitsWrapper = (isOrphanVisits: boolean, version: SemVer) => wrapperFactory({
isOrphanVisits,
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const createServerVersionWrapper = (version: SemVer) => wrapperFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const createWrapperWithBots = () => wrapperFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: '2.7.0', version: '2.7.0' }),
visits: [
Mock.of<NormalizedVisit>({ potentialBot: false }),
Mock.of<NormalizedVisit>({ potentialBot: true }),
],
});
afterEach(jest.resetAllMocks); afterEach(jest.resetAllMocks);
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
it('renders columns as expected', () => { it.each([
const wrapper = createWrapper([]); [ '2.6.0' as SemVer, [ 'Date', 'Country', 'City', 'Browser', 'OS', 'Referrer' ]],
[ '2.7.0' as SemVer, [ 'fa-robot', 'Date', 'Country', 'City', 'Browser', 'OS', 'Referrer' ]],
])('renders columns as expected', (version, expectedColumns) => {
const wrapper = createServerVersionWrapper(version);
const th = wrapper.find('thead').find('th'); const th = wrapper.find('thead').find('th');
expect(th).toHaveLength(7); expect(th).toHaveLength(expectedColumns.length + 1);
expect(th.at(1).text()).toContain('Date'); expectedColumns.forEach((column, index) => {
expect(th.at(2).text()).toContain('Country'); expect(th.at(index + 1).html()).toContain(column);
expect(th.at(3).text()).toContain('City'); });
expect(th.at(4).text()).toContain('Browser');
expect(th.at(5).text()).toContain('OS');
expect(th.at(6).text()).toContain('Referrer');
}); });
it('shows warning when no visits are found', () => { it('shows warning when no visits are found', () => {
@ -137,10 +156,12 @@ describe('<VisitsTable />', () => {
}); });
it.each([ it.each([
[ true, 8 ], [ true, '2.6.0' as SemVer, 8 ],
[ false, 7 ], [ false, '2.6.0' as SemVer, 7 ],
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => { [ true, '2.7.0' as SemVer, 9 ],
const wrapper = createWrapper([], [], isOrphanVisits); [ false, '2.7.0' as SemVer, 8 ],
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, version, expectedCols) => {
const wrapper = createOrphanVisitsWrapper(isOrphanVisits, version);
const rowsWithColspan = wrapper.find('[colSpan]'); const rowsWithColspan = wrapper.find('[colSpan]');
const cols = wrapper.find('th'); const cols = wrapper.find('th');
@ -148,4 +169,12 @@ describe('<VisitsTable />', () => {
expect(rowsWithColspan).toHaveLength(2); expect(rowsWithColspan).toHaveLength(2);
rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols)); rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols));
}); });
it('displays bots icon when a visit is a potential bot', () => {
const wrapper = createWrapperWithBots();
const rows = wrapper.find('tbody').find('tr');
expect(rows.at(0).find('td').at(1).text()).not.toContain('FontAwesomeIcon');
expect(rows.at(1).find('td').at(1).text()).toContain('FontAwesomeIcon');
});
}); });

View file

@ -39,6 +39,7 @@ describe('VisitsExporter', () => {
longitude: 0, longitude: 0,
os: 'os', os: 'os',
referer: 'referer', referer: 'referer',
potentialBot: false,
}, },
]; ];

View file

@ -43,6 +43,7 @@ describe('VisitsParser', () => {
}), }),
Mock.of<Visit>({ Mock.of<Visit>({
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', 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[] = [ const orphanVisits: OrphanVisit[] = [
@ -61,6 +62,7 @@ describe('VisitsParser', () => {
Mock.of<OrphanVisit>({ Mock.of<OrphanVisit>({
type: 'regular_404', type: 'regular_404',
visitedUrl: 'bar', visitedUrl: 'bar',
potentialBot: true,
}), }),
Mock.of<OrphanVisit>({ Mock.of<OrphanVisit>({
type: 'invalid_short_url', type: 'invalid_short_url',
@ -73,6 +75,7 @@ describe('VisitsParser', () => {
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
}, },
potentialBot: false,
}), }),
]; ];
@ -176,6 +179,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
potentialBot: false,
}, },
{ {
browser: 'Firefox', browser: 'Firefox',
@ -186,6 +190,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 1029, latitude: 1029,
longitude: 6758, longitude: 6758,
potentialBot: false,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -196,6 +201,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: undefined, latitude: undefined,
longitude: undefined, longitude: undefined,
potentialBot: false,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -206,6 +212,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: 123.45, latitude: 123.45,
longitude: -543.21, longitude: -543.21,
potentialBot: false,
}, },
{ {
browser: 'Opera', browser: 'Opera',
@ -216,6 +223,7 @@ describe('VisitsParser', () => {
date: undefined, date: undefined,
latitude: undefined, latitude: undefined,
longitude: undefined, longitude: undefined,
potentialBot: true,
}, },
]); ]);
}); });
@ -233,6 +241,7 @@ describe('VisitsParser', () => {
longitude: 6758, longitude: 6758,
type: 'base_url', type: 'base_url',
visitedUrl: 'foo', visitedUrl: 'foo',
potentialBot: false,
}, },
{ {
type: 'regular_404', type: 'regular_404',
@ -245,6 +254,7 @@ describe('VisitsParser', () => {
longitude: undefined, longitude: undefined,
os: 'Others', os: 'Others',
referer: 'Direct', referer: 'Direct',
potentialBot: true,
}, },
{ {
browser: 'Chrome', browser: 'Chrome',
@ -257,6 +267,7 @@ describe('VisitsParser', () => {
longitude: -543.21, longitude: -543.21,
type: 'invalid_short_url', type: 'invalid_short_url',
visitedUrl: 'bar', visitedUrl: 'bar',
potentialBot: false,
}, },
]); ]);
}); });