Created section to display orphan visits stats

This commit is contained in:
Alejandro Celaya
2021-02-27 20:03:51 +01:00
parent 46d012b6ff
commit 5479210366
23 changed files with 342 additions and 36 deletions

View File

@@ -51,6 +51,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);

View File

@@ -19,6 +19,7 @@ const MenuLayout = (
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
OrphanVisits: FC,
ServerError: FC,
Overview: FC,
) => withSelectedServer(({ location, selectedServer }) => {
@@ -31,6 +32,7 @@ const MenuLayout = (
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const addOrphanVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.6.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
@@ -67,6 +69,7 @@ const MenuLayout = (
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}

View File

@@ -34,6 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'OrphanVisits',
'ServerError',
'Overview',
);

View File

@@ -16,6 +16,7 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types';
export interface ShlinkState {
servers: ServersMap;
@@ -29,6 +30,7 @@ export interface ShlinkState {
shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
orphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;

View File

@@ -10,6 +10,7 @@ import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
@@ -32,6 +33,7 @@ export default combineReducers<ShlinkState>({
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
orphanVisits: orphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,

View File

@@ -83,10 +83,14 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
(currentShortUrl) => {
// Find the last of the new visit for this short URL, and pick the amount of visits from it
const lastVisit = last(
createdVisits.filter(({ shortUrl }) => shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain)),
createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
);
return lastVisit ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) : currentShortUrl;
return lastVisit?.shortUrl
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
: currentShortUrl;
},
),
state,

View File

@@ -76,7 +76,7 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags
}, { ...stats });
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
createdVisits.reduce((acc, { shortUrl }) => {
shortUrl.tags.forEach((tag) => {
shortUrl?.tags.forEach((tag) => {
acc[tag] = (acc[tag] || 0) + 1;
});

View File

@@ -0,0 +1,29 @@
import { RouteComponentProps } from 'react-router';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader';
export interface OrphanVisitsProps extends RouteComponentProps<{ tag: string }> {
getOrphanVisits: (params: ShlinkVisitsParams) => void;
orphanVisits: TagVisitsState;
cancelGetOrphanVisits: () => void;
}
export const OrphanVisits = boundToMercureHub(({
history: { goBack },
match: { url },
getOrphanVisits,
orphanVisits,
cancelGetOrphanVisits,
}: OrphanVisitsProps) => (
<VisitsStats
getVisits={getOrphanVisits}
cancelGetVisits={cancelGetOrphanVisits}
visitsInfo={orphanVisits}
baseUrl={url}
>
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
</VisitsStats>
), () => 'https://shlink.io/new-orphan-visit');

View File

@@ -0,0 +1,15 @@
import VisitsHeader from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
import { VisitsInfo } from './types';
interface OrphanVisitsHeader {
orphanVisits: VisitsInfo;
goBack: () => void;
}
export const OrphanVisitsHeader = ({ orphanVisits, goBack }: OrphanVisitsHeader) => {
const { visits } = orphanVisits;
const visitsStatsTitle = <span className="mr-2">Orphan visits</span>;
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
};

View File

@@ -66,7 +66,7 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
tag={RouterNavLink}
className="visits-stats__nav-link"
to={to}
isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)}
isActive={(_: null, { pathname }: Location) => pathname.endsWith(`visits${subPath}`)}
replace
>
<FontAwesomeIcon icon={icon} />

View File

@@ -0,0 +1,69 @@
import { Action, Dispatch } from 'redux';
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */
export const GET_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_ORPHAN_VISITS_START';
export const GET_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_ORPHAN_VISITS_ERROR';
export const GET_ORPHAN_VISITS = 'shlink/orphanVisits/GET_ORPHAN_VISITS';
export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE';
export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL';
export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED';
/* eslint-enable padding-line-between-statements */
export interface OrphanVisitsAction extends Action<string> {
visits: Visit[];
}
type OrphanVisitsCombinedAction = OrphanVisitsAction
& VisitsLoadProgressChangedAction
& CreateVisitsAction
& VisitsLoadFailedAction;
const initialState: VisitsInfo = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_ORPHAN_VISITS]: (_, { visits }) => ({ ...initialState, visits }),
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => {
const { visits } = state;
const newVisits = createdVisits.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };
},
}, initialState);
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (query = {}) => async (
dispatch: Dispatch,
getState: GetState,
) => {
const { getOrphanVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage });
const shouldCancel = () => getState().orphanVisits.cancelLoad;
const actionMap = {
start: GET_ORPHAN_VISITS_START,
large: GET_ORPHAN_VISITS_LARGE,
finish: GET_ORPHAN_VISITS,
error: GET_ORPHAN_VISITS_ERROR,
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
};
return getVisitsWithLoader(visitsLoader, {}, actionMap, dispatch, shouldCancel);
};
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);

View File

@@ -52,11 +52,10 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand
[CREATE_VISITS]: (state, { createdVisits }) => {
const { shortCode, domain, visits } = state;
const newVisits = createdVisits
.filter(({ shortUrl }) => shortUrlMatches(shortUrl, shortCode, domain))
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };

View File

@@ -46,10 +46,10 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand
[CREATE_VISITS]: (state, { createdVisits }) => {
const { tag, visits } = state;
const newVisits = createdVisits
.filter(({ shortUrl }) => shortUrl.tags.includes(tag))
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };

View File

@@ -3,6 +3,7 @@ import { ShlinkVisitsOverview } from '../../api/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { groupNewVisitsByType } from '../types/helpers';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */
@@ -31,10 +32,15 @@ export default buildReducer<VisitsOverview, GetVisitsOverviewAction & CreateVisi
[GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }),
[GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }),
[GET_OVERVIEW]: (_, { visitsCount, orphanVisitsCount }) => ({ ...initialState, visitsCount, orphanVisitsCount }),
[CREATE_VISITS]: ({ visitsCount, ...rest }, { createdVisits }) => ({
...rest,
visitsCount: visitsCount + createdVisits.length,
}),
[CREATE_VISITS]: ({ visitsCount, orphanVisitsCount = 0, ...rest }, { createdVisits }) => {
const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits);
return {
...rest,
visitsCount: visitsCount + regularVisits.length,
orphanVisitsCount: orphanVisitsCount + orphanVisits.length,
};
},
}, initialState);
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (

View File

@@ -2,6 +2,7 @@ import { isNil, map } from 'ramda';
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
import { hasValue } from '../../utils/utils';
import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
import { isOrphanVisit } from '../types/helpers';
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
!isNil(visit) && hasValue(visit[propertyName]);
@@ -68,15 +69,24 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} },
);
export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }: Visit): NormalizedVisit => ({
date,
...parseUserAgent(userAgent),
referer: extractDomain(referer),
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
latitude: visitLocation?.latitude,
longitude: visitLocation?.longitude,
}));
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation } = visit;
const common = {
date,
...parseUserAgent(userAgent),
referer: extractDomain(referer),
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
latitude: visitLocation?.latitude,
longitude: visitLocation?.longitude,
};
if (!isOrphanVisit(visit)) {
return common;
}
return { ...common, type: visit.type, visitedUrl: visit.visitedUrl };
});
export interface VisitsParser {
processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats;

View File

@@ -4,8 +4,10 @@ import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrl
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import MapModal from '../helpers/MapModal';
import { createNewVisits } from '../reducers/visitCreation';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import TagVisits from '../TagVisits';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import { OrphanVisits } from '../OrphanVisits';
import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits';
import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser';
@@ -13,17 +15,25 @@ import * as visitsParser from './VisitsParser';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits);
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator');
bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('OrphanVisits', () => OrphanVisits);
bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
));
// Services
bottle.serviceFactory('VisitsParser', () => visitsParser);
@@ -35,6 +45,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits);
bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
};

View File

@@ -0,0 +1,14 @@
import { groupBy, pipe } from 'ramda';
import { Visit, OrphanVisit, CreateVisit } from './index';
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
interface GroupedNewVisits {
orphanVisits: CreateVisit[];
regularVisits: CreateVisit[];
}
export const groupNewVisitsByType = pipe(
groupBy((newVisit: CreateVisit) => isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'regularVisits'),
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
);

View File

@@ -20,6 +20,8 @@ export interface VisitsLoadFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
interface VisitLocation {
countryCode: string | null;
countryName: string | null;
@@ -31,19 +33,26 @@ interface VisitLocation {
isEmpty: boolean;
}
export interface Visit {
export interface RegularVisit {
referer: string;
date: string;
userAgent: string;
visitLocation: VisitLocation | null;
}
export interface OrphanVisit extends RegularVisit {
visitedUrl: string;
type: OrphanVisitType;
}
export type Visit = RegularVisit | OrphanVisit;
export interface UserAgent {
browser: string;
os: string;
}
export interface NormalizedVisit extends UserAgent {
export interface NormalizedRegularVisit extends UserAgent {
date: string;
referer: string;
country: string;
@@ -52,8 +61,15 @@ export interface NormalizedVisit extends UserAgent {
longitude?: number | null;
}
export interface NormalizedOrphanVisit extends NormalizedRegularVisit {
visitedUrl: string;
type: OrphanVisitType;
}
export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit;
export interface CreateVisit {
shortUrl: ShortUrl;
shortUrl?: ShortUrl;
visit: Visit;
}