mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-04-19 21:16:18 +00:00
Created section to display orphan visits stats
This commit is contained in:
29
src/visits/OrphanVisits.tsx
Normal file
29
src/visits/OrphanVisits.tsx
Normal 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');
|
||||
15
src/visits/OrphanVisitsHeader.tsx
Normal file
15
src/visits/OrphanVisitsHeader.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
69
src/visits/reducers/orphanVisits.ts
Normal file
69
src/visits/reducers/orphanVisits.ts
Normal 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);
|
||||
@@ -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 ] };
|
||||
|
||||
@@ -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 ] };
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
14
src/visits/types/helpers.ts
Normal file
14
src/visits/types/helpers.ts
Normal 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 }),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user