Added column to display if a visit is a potential bot in the visits table

This commit is contained in:
Alejandro Celaya 2021-06-13 11:07:32 +02:00
parent a3550f8e52
commit db0c43dcdd
12 changed files with 88 additions and 22 deletions

View file

@ -23,3 +23,5 @@ export const supportsOrphanVisits = supportsShortUrlTitle;
export const supportsQrCodeMargin = 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 { 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
>
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />

View file

@ -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}
>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>

View file

@ -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}
>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>

View file

@ -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<ShlinkVisitsParams>) => void;
visitsInfo: VisitsInfo;
settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void;
baseUrl: string;
domain?: string;
@ -67,9 +69,18 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
</NavLink>
);
const VisitsStats: FC<VisitsStatsProps> = (
{ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false },
) => {
const VisitsStats: FC<VisitsStatsProps> = ({
children,
visitsInfo,
getVisits,
cancelGetVisits,
baseUrl,
domain,
settings,
exportCsv,
selectedServer,
isOrphanVisits = false,
}) => {
const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
@ -243,6 +254,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/>
</div>
</Route>

View file

@ -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<Order>({ 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 = ({
<thead className="visits-table__header">
<tr>
<th
className="visits-table__header-cell visits-table__sticky text-center"
className={`${headerCellsClass} text-center`}
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)}
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
{supportsBots && (
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
)}
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
@ -141,7 +155,7 @@ const VisitsTable = ({
)}
</tr>
<tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="p-0">
<td colSpan={fullSizeColSpan} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
@ -149,7 +163,7 @@ const VisitsTable = ({
<tbody>
{!resultSet.visitsGroups[page - 1]?.length && (
<tr>
<td colSpan={isOrphanVisits ? 8 : 7} className="text-center">
<td colSpan={fullSizeColSpan} className="text-center">
No visits found with current filtering
</td>
</tr>
@ -169,6 +183,18 @@ const VisitsTable = ({
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</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>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td>
@ -185,7 +211,7 @@ const VisitsTable = ({
{resultSet.total > PAGE_SIZE && (
<tfoot>
<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="col-md-6">
<SimplePaginator

View file

@ -81,9 +81,10 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => 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

View file

@ -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' ],
));

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;
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 {

View file

@ -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('<VisitsTable />', () => {
const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
@ -18,6 +19,7 @@ describe('<VisitsTable />', () => {
setSelectedVisits={setSelectedVisits}
matchMedia={matchMedia}
isOrphanVisits={isOrphanVisits}
selectedServer={Mock.all<SelectedServer>()}
/>,
);

View file

@ -43,6 +43,7 @@ describe('VisitsParser', () => {
}),
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',
potentialBot: true,
}),
];
const orphanVisits: OrphanVisit[] = [
@ -61,6 +62,7 @@ describe('VisitsParser', () => {
Mock.of<OrphanVisit>({
type: 'regular_404',
visitedUrl: 'bar',
potentialBot: true,
}),
Mock.of<OrphanVisit>({
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,
},
]);
});