feat(ui) - dashboard - widget drilldown

This commit is contained in:
Shekar Siri 2022-04-14 15:02:19 +02:00
parent 471d6068c1
commit f9df0d2b91
12 changed files with 274 additions and 44 deletions

View file

@ -2,7 +2,7 @@ import React from 'react';
import { NoContent } from 'UI';
import { Styles } from '../../common';
import {
BarChart, Bar, CartesianGrid, Tooltip,
CartesianGrid, Tooltip,
LineChart, Line, Legend, ResponsiveContainer,
XAxis, YAxis
} from 'recharts';
@ -12,15 +12,14 @@ interface Props {
metric?: any
}
function CallsErrors4xx(props: Props) {
const { data, metric } = props;
console.log('asd', metric.data.namesMap)
const { data, metric } = props;
return (
<NoContent
size="small"
show={ metric.data.chart.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<BarChart
<LineChart
data={metric.data.chart}
margin={Styles.chartMargins}
>
@ -40,7 +39,7 @@ function CallsErrors4xx(props: Props) {
{ Array.isArray(metric.data.namesMap) && metric.data.namesMap.map((key, index) => (
<Line key={key} name={key} type="monotone" dataKey={key} stroke={Styles.colors[index]} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } fill="url(#colorCount)" dot={false} />
))}
</BarChart>
</LineChart>
</ResponsiveContainer>
</NoContent>
);

View file

@ -19,7 +19,7 @@ function CallsErrors5xx(props: Props) {
show={ metric.data.chart.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<BarChart
<LineChart
data={metric.data.chart}
margin={Styles.chartMargins}
>
@ -39,7 +39,7 @@ function CallsErrors5xx(props: Props) {
{ Array.isArray(metric.data.namesMap) && metric.data.namesMap.map((key, index) => (
<Line key={key} name={key} type="monotone" dataKey={key} stroke={Styles.colors[index]} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } fill="url(#colorCount)" dot={false} />
))}
</BarChart>
</LineChart>
</ResponsiveContainer>
</NoContent>
);

View file

@ -16,6 +16,7 @@ function ErrorsPerDomain(props: Props) {
<NoContent
size="small"
show={ metric.data.chart.length === 0 }
style={{ height: '240px'}}
>
<div className="w-full" style={{ height: '240px' }}>
{metric.data.chart.map((item, i) =>

View file

@ -19,6 +19,7 @@ function WidgetChart(props: Props) {
const { isWidget = false, metric } = props;
const { dashboardStore } = useStore();
const period = useObserver(() => dashboardStore.period);
const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter);
const colors = Styles.customMetricColors;
const [loading, setLoading] = useState(false)
const isOverviewWidget = metric.metricType === 'predefined' && metric.viewType === 'overview';
@ -34,6 +35,11 @@ function WidgetChart(props: Props) {
const periodTimestamps = metric.metricType === 'timeseries' ?
getStartAndEndTimestampsByDensity(timestamp, period.start, period.end, params.density) :
period.toTimestamps();
drillDownFilter.merge({
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp,
});
// const activeWidget = {
// widget: metric,
@ -42,8 +48,6 @@ function WidgetChart(props: Props) {
// timestamp: payload.timestamp,
// index,
// }
// props.setActiveWidget(activeWidget);
}
}

View file

@ -20,7 +20,8 @@ interface Props {
function WidgetForm(props: Props) {
const [showDashboardSelectionModal, setShowDashboardSelectionModal] = useState(false);
const { history, match: { params: { siteId, dashboardId, metricId } } } = props;
const { metricStore } = useStore();
const { metricStore, dashboardStore } = useStore();
const dashboards = dashboardStore.dashboards;
const isSaving = useObserver(() => metricStore.isSaving);
const metric: any = useObserver(() => metricStore.instance);
@ -29,6 +30,7 @@ function WidgetForm(props: Props) {
const isTable = metric.metricType === 'table';
const isTimeSeries = metric.metricType === 'timeseries';
const _issueOptions = [{ text: 'All', value: 'all' }].concat(issueOptions);
const canAddToDashboard = metric.exists() && dashboards.length > 0;
const write = ({ target: { value, name } }) => metricStore.merge({ [ name ]: value });
const writeOption = (e, { value, name }) => {
@ -193,7 +195,12 @@ function WidgetForm(props: Props) {
<Icon name="trash" size="14" className="mr-2" color="teal"/>
Delete
</Button>
<Button plain size="small" className="flex items-center ml-2" onClick={() => setShowDashboardSelectionModal(true)}>
<Button
plain size="small"
className="flex items-center ml-2"
onClick={() => setShowDashboardSelectionModal(true)}
disabled={!canAddToDashboard}
>
<Icon name="columns-gap" size="14" className="mr-2" color="teal"/>
Add to Dashboard
</Button>
@ -201,13 +208,13 @@ function WidgetForm(props: Props) {
)}
</div>
</div>
<DashboardSelectionModal
metricId={metric.metricId}
show={showDashboardSelectionModal}
closeHandler={() => setShowDashboardSelectionModal(false)}
/>
{ canAddToDashboard && (
<DashboardSelectionModal
metricId={metric.metricId}
show={showDashboardSelectionModal}
closeHandler={() => setShowDashboardSelectionModal(false)}
/>
)}
</div>
));
}

View file

@ -1,43 +1,106 @@
import React from 'react';
import { NoContent } from 'UI';
import React, { useEffect, useState } from 'react';
import { NoContent, Dropdown, Icon, Loader } from 'UI';
import cn from 'classnames';
import { useStore } from 'App/mstore';
import SessionItem from 'Shared/SessionItem';
import { useObserver } from 'mobx-react-lite';
import { observer, useObserver } from 'mobx-react-lite';
import { DateTime } from 'luxon';
interface Props {
className?: string;
}
function WidgetSessions(props: Props) {
const { className = '' } = props;
const { dashboardStore } = useStore();
const filter = useObserver(() => dashboardStore.drillDownFilter);
const widget = dashboardStore.currentWidget;
const [data, setData] = useState<any>([]);
const [seriesOptions, setSeriesOptions] = useState([
{ text: 'All', value: 'all' },
]);
// const range = period.toTimestamps()
const [activeSeries, setActiveSeries] = useState('all');
const writeOption = (e, { name, value }) => setActiveSeries(value);
useEffect(() => {
if (!data) return;
const seriesOptions = data.map(item => ({
text: item.seriesName,
value: item.seriesId,
}));
setSeriesOptions([
{ text: 'All', value: 'all' },
...seriesOptions,
]);
}, [data]);
const filteredSessions = getListSessionsBySeries(data, activeSeries);
const { dashboardStore, metricStore } = useStore();
const filter = useObserver(() => dashboardStore.drillDownFilter);
const widget: any = metricStore.instance;
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm a');
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat('LLL dd, yyyy HH:mm a');
useEffect(() => {
widget.fetchSessions({ ...filter, filter: widget.toJsonDrilldown()}).then(res => {
console.log('res', res)
setData(res);
});
}, [filter.startTimestamp, filter.endTimestamp, widget.filter]);
return useObserver(() => (
<div className={cn(className)}>
<div className="flex items-baseline">
<h2 className="text-2xl">Sessions</h2>
<div className="ml-2 color-gray-medium">between <span className="font-medium color-gray-darkest">{startTime}</span> and <span className="font-medium color-gray-darkest">{endTime}</span> </div>
<div className="flex items-center justify-between">
<div className="flex items-baseline">
<h2 className="text-2xl">Sessions</h2>
<div className="ml-2 color-gray-medium">between <span className="font-medium color-gray-darkest">{startTime}</span> and <span className="font-medium color-gray-darkest">{endTime}</span> </div>
</div>
{ widget.metricType !== 'table' && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Series</span>
<Dropdown
// className={stl.dropdown}
className="font-medium flex items-center hover:bg-gray-light rounded px-2 py-1"
direction="left"
options={ seriesOptions }
name="change"
value={ activeSeries }
onChange={ writeOption }
id="change-dropdown"
// icon={null}
icon={ <Icon name="chevron-down" color="gray-dark" size="14" className="ml-2" /> }
/>
</div>
)}
</div>
<div className="mt-3">
<NoContent
title="No recordings found"
show={widget.sessions.length === 0}
animatedIcon="no-results"
>
{widget.sessions.map((session: any) => (
<SessionItem key={ session.sessionId } session={ session } />
))}
</NoContent>
<Loader loading={widget.sessionsLoading}>
<NoContent
title="No recordings found"
show={filteredSessions.length === 0}
animatedIcon="no-results"
>
{filteredSessions.map((session: any) => (
<SessionItem key={ session.sessionId } session={ session } />
))}
</NoContent>
</Loader>
</div>
</div>
));
}
export default WidgetSessions;
const getListSessionsBySeries = (data, seriesId) => {
const arr: any = []
data.forEach(element => {
if (seriesId === 'all') {
const sessionIds = arr.map(i => i.sessionId);
arr.push(...element.sessions.filter(i => !sessionIds.includes(i.sessionId)));
} else {
if (element.seriesId === seriesId) {
arr.push(...element.sessions)
}
}
});
return arr;
}
export default observer(WidgetSessions);

View file

@ -219,7 +219,7 @@ export default class List extends React.PureComponent {
<NoContent
title="No Errors Found!"
subtext="Please try to change your search parameters."
icon="exclamation-circle"
animatedIcon="empty-state"
show={ !loading && list.size === 0}
>
<Loader loading={ loading }>

View file

@ -5,7 +5,7 @@ import { dashboardService, metricService } from "App/services";
import { toast } from 'react-toastify';
import Period, { LAST_24_HOURS, LAST_7_DAYS } from 'Types/app/period';
import { getChartFormatter } from 'Types/dashboard/helper';
import Filter from "./types/filter";
import Filter, { IFilter } from "./types/filter";
export interface IDashboardSotre {
dashboards: IDashboard[]
@ -15,7 +15,7 @@ export interface IDashboardSotre {
startTimestamp: number
endTimestamp: number
period: Period
drillDownFilter: Filter
drillDownFilter: IFilter
siteId: any
currentWidget: Widget
@ -29,6 +29,7 @@ export interface IDashboardSotre {
isSaving: boolean
isDeleting: boolean
fetchingDashboard: boolean
sessionsLoading: boolean
toggleAllSelectedWidgets: (isSelected: boolean) => void
removeSelectedWidgetByCategory(category: string): void
@ -89,9 +90,11 @@ export default class DashboardStore implements IDashboardSotre {
isSaving: boolean = false
isDeleting: boolean = false
fetchingDashboard: boolean = false
sessionsLoading: boolean = false;
constructor() {
makeAutoObservable(this, {
drillDownFilter: observable.ref,
widgetCategories: observable.ref,
resetCurrentWidget: action,
addDashboard: action,

View file

@ -2,8 +2,27 @@ import { makeAutoObservable, runInAction, observable, action, reaction } from "m
import { FilterKey, FilterType } from 'Types/filter/filterType'
import { filtersMap } from 'Types/filter/newFilter'
import FilterItem from "./filterItem"
export default class Filter {
export interface IFilter {
filterId: string
name: string
filters: FilterItem[]
eventsOrder: string
startTimestamp: number
endTimestamp: number
merge: (filter: any) => void
addFilter: (filter: FilterItem) => void
updateFilter: (index:number, filter: any) => void
updateKey: (key: any, value: any) => void
removeFilter: (index: number) => void
fromJson: (json: any) => void
toJson: () => any
toJsonDrilldown: () => any
}
export default class Filter implements IFilter {
public static get ID_KEY():string { return "filterId" }
filterId: string = ''
name: string = ''
filters: FilterItem[] = []
eventsOrder: string = 'then'
@ -14,10 +33,19 @@ export default class Filter {
makeAutoObservable(this, {
filters: observable,
eventsOrder: observable,
startTimestamp: observable,
endTimestamp: observable,
addFilter: action,
removeFilter: action,
updateKey: action,
merge: action,
})
}
merge(filter: any) {
runInAction(() => {
Object.assign(this, filter)
})
}
@ -36,7 +64,7 @@ export default class Filter {
this.filters[index] = new FilterItem(filter)
}
updateKey(key, value) {
updateKey(key: string, value) {
this[key] = value
}

View file

@ -0,0 +1,79 @@
import { runInAction, makeAutoObservable, observable } from 'mobx'
import { List, Map } from 'immutable';
import { DateTime, Duration } from 'luxon';
const HASH_MOD = 1610612741;
const HASH_P = 53;
function hashString(s: string): number {
let mul = 1;
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (hash + s.charCodeAt(i) * mul) % HASH_MOD;
mul = (mul*HASH_P) % HASH_MOD;
}
return hash;
}
export interface ISession {
sessionId: string
viewed: boolean
duration: number
metadata: any,
startedAt: number
userBrowser: string
userOs: string
userId: string
userDeviceType: string
userCountry: string
eventsCount: number
userNumericHash: number
userDisplayName: string
}
export default class Session implements ISession {
sessionId: string = "";
viewed: boolean = false
duration: number = 0
metadata: any = Map()
startedAt: number = 0
userBrowser: string = ""
userOs: string = ""
userId: string = ""
userDeviceType: string = ""
userCountry: string = ""
eventsCount: number = 0
userNumericHash: number = 0
userDisplayName: string = ""
constructor() {
makeAutoObservable(this, {
sessionId: observable,
})
}
fromJson(session: any) {
runInAction(() => {
Object.keys(session).forEach(key => {
this[key] = session[key]
})
const { startTs, timestamp } = session;
const startedAt = +startTs || +timestamp;
this.sessionId = session.sessionId
this.viewed = session.viewed
this.duration = Duration.fromMillis(session.duration < 1000 ? 1000 : session.duration);
this.metadata = Map(session.metadata)
this.startedAt = startedAt
this.userBrowser = session.userBrowser
this.userOs = session.userOs
this.userId = session.userId
this.userDeviceType = session.userDeviceType
this.eventsCount = session.eventsCount
this.userCountry = session.userCountry
this.userNumericHash = hashString(session.userId || session.userAnonymousId || session.userUuid || session.userID || session.userUUID || "")
this.userDisplayName = session.userId || session.userAnonymousId || session.userID || 'Anonymous User'
})
return this
}
}

View file

@ -1,7 +1,9 @@
import { makeAutoObservable, runInAction, observable, action, reaction, computed } from "mobx"
import FilterSeries from "./filterSeries";
import { DateTime } from 'luxon';
import { IFilter } from "./filter";
import { metricService } from "App/services";
import Session, { ISession } from "App/mstore/types/session";
export interface IWidget {
metricId: any
widgetId: any
@ -20,6 +22,8 @@ export interface IWidget {
dashboardIds: any[]
config: any
sessionsLoading: boolean
position: number
data: any
isLoading: boolean
@ -34,12 +38,14 @@ export interface IWidget {
removeSeries(index: number): void
addSeries(): void
fromJson(json: any): void
toJsonDrilldown(json: any): void
toJson(): any
validate(): void
update(data: any): void
exists(): boolean
toWidget(): any
setData(data: any): void
fetchSessions(filter: any): Promise<any>
}
export default class Widget implements IWidget {
public static get ID_KEY():string { return "metricId" }
@ -61,6 +67,8 @@ export default class Widget implements IWidget {
config: any = {}
params: any = { density: 70 }
sessionsLoading: boolean = false
position: number = 0
data: any = {
chart: [],
@ -74,7 +82,9 @@ export default class Widget implements IWidget {
constructor() {
makeAutoObservable(this, {
sessionsLoading: observable,
data: observable.ref,
metricId: observable,
widgetId: observable,
name: observable,
metricType: observable,
@ -146,6 +156,12 @@ export default class Widget implements IWidget {
}
}
toJsonDrilldown() {
return {
series: this.series.map((series: any) => series.toJson()),
}
}
toJson() {
return {
metricId: this.metricId,
@ -179,4 +195,21 @@ export default class Widget implements IWidget {
Object.assign(this.data, data)
})
}
fetchSessions(filter: any): Promise<any> {
this.sessionsLoading = true
return new Promise((resolve, reject) => {
console.log('fetching sessions', filter)
metricService.fetchSessions(this.metricId, filter).then(response => {
resolve(response.map(cat => {
return {
...cat,
sessions: cat.sessions.map(s => new Session().fromJson(s))
}
}))
}).finally(() => {
this.sessionsLoading = false
})
})
}
}

View file

@ -1,5 +1,6 @@
import Widget, { IWidget } from "App/mstore/types/widget";
import APIClient from 'App/api_client';
import { IFilter } from "App/mstore/types/filter";
export interface IMetricService {
initClient(client?: APIClient): void;
@ -11,6 +12,7 @@ export interface IMetricService {
getTemplates(): Promise<any>;
getMetricChartData(metric: IWidget, data: any, isWidget: boolean): Promise<any>;
fetchSessions(metricId: string, filter: any): Promise<any>
}
export default class MetricService implements IMetricService {
@ -88,4 +90,15 @@ export default class MetricService implements IMetricService {
.then(response => response.json())
.then(response => response.data || {});
}
/**
* Fetch sessions from the server.
* @param filter
* @returns
*/
fetchSessions(metricId: string, filter: any): Promise<any> {
return this.client.post(`/metrics/${metricId}/sessions`, filter)
.then(response => response.json())
.then(response => response.data || []);
}
}