Enhance activity feed UI (#4177)

* Refactor for useQuery
* Add apply policy spec activity
This commit is contained in:
gillespi314 2022-02-17 16:54:27 -06:00 committed by GitHub
parent 745ca2cb80
commit 1526a1b336
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 142 additions and 128 deletions

View file

@ -1,10 +1,13 @@
import PropTypes from "prop-types";
import { IPolicy } from "./policy";
import { IQuery } from "./query";
export enum ActivityType {
CreatedPack = "created_pack",
DeletedPack = "deleted_pack",
EditedPack = "edited_pack",
CreatedPolicy = "created_policy",
DeletedPolicy = "deleted_policy",
EditedPolicy = "edited_policy",
CreatedSavedQuery = "created_saved_query",
DeletedSavedQuery = "deleted_saved_query",
EditedSavedQuery = "edited_saved_query",
@ -12,6 +15,7 @@ export enum ActivityType {
DeletedTeam = "deleted_team",
LiveQuery = "live_query",
AppliedSpecPack = "applied_spec_pack",
AppliedSpecPolicy = "applied_spec_policy",
AppliedSpecSavedQuery = "applied_spec_saved_query",
}
export interface IActivity {
@ -27,29 +31,12 @@ export interface IActivity {
export interface IActivityDetails {
pack_id?: number;
pack_name?: string;
policy_id?: number;
policy_name?: string;
query_id?: number;
query_name?: string;
team_id?: number;
team_name?: string;
targets_count?: number;
specs?: IQuery[];
specs?: IQuery[] | IPolicy[];
}
export default PropTypes.shape({
created_at: PropTypes.string,
id: PropTypes.number,
actor_full_name: PropTypes.string,
actor_id: PropTypes.number,
actor_gravatar: PropTypes.string,
actor_email: PropTypes.string,
type: PropTypes.string,
details: PropTypes.shape({
pack_id: PropTypes.number,
pack_name: PropTypes.string,
query_id: PropTypes.number,
query_name: PropTypes.string,
team_id: PropTypes.number,
team_name: PropTypes.string,
targets_count: PropTypes.number,
}),
});

View file

@ -1,10 +1,11 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { useQuery } from "react-query";
import { find, isEmpty, lowerCase } from "lodash";
import { formatDistanceToNow } from "date-fns";
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
// @ts-ignore
// import Fleet from "fleet";
import activitiesAPI from "services/entities/activities";
import activitiesAPI, {
IActivitiesResponse,
} from "services/entities/activities";
import { addGravatarUrlToResource } from "fleet/helpers";
import { IActivity, ActivityType } from "interfaces/activity";
@ -23,9 +24,15 @@ interface IActvityCardProps {
setShowActivityFeedTitle: (showActivityFeedTitle: boolean) => void;
}
interface IActivityDisplay extends IActivity {
key?: string;
}
const DEFAULT_GRAVATAR_URL =
"https://www.gravatar.com/avatar/00000000000000000000000000000000?d=blank&size=200";
const DEFAULT_PER_PAGE = 8;
const TAGGED_TEMPLATES = {
liveQueryActivityTemplate: (activity: IActivity) => {
const count = activity.details?.targets_count;
@ -36,6 +43,9 @@ const TAGGED_TEMPLATES = {
editPackCtlActivityTemplate: () => {
return "edited a pack using fleetctl";
},
editPolicyCtlActivityTemplate: () => {
return "edited policies using fleetctl";
},
editQueryCtlActivityTemplate: (activity: IActivity) => {
const count = activity.details?.specs?.length;
return typeof count === "undefined" || typeof count !== "number"
@ -62,88 +72,67 @@ const TAGGED_TEMPLATES = {
const ActivityFeed = ({
setShowActivityFeedTitle,
}: IActvityCardProps): JSX.Element => {
const [activities, setActivities] = useState<IActivity[] | []>([]);
const [isLoadingError, setIsLoadingError] = useState<boolean>(false);
const [pageIndex, setPageIndex] = useState<number>(0);
const [showMore, setShowMore] = useState<boolean>(true);
const [isLoadingActivityFeed, setIsLoadingActivityFeed] = useState<boolean>(
true
);
useEffect((): void => {
const getActivities = async (): Promise<void> => {
try {
const { activities: responseActivities } = await activitiesAPI.loadNext(
pageIndex
);
if (responseActivities.length) {
setActivities(responseActivities);
} else {
const {
data: activities,
error: errorActivities,
isFetching: isFetchingActivities,
} = useQuery<
IActivitiesResponse,
Error,
IActivity[],
Array<{
scope: string;
pageIndex: number;
perPage: number;
}>
>(
[{ scope: "activities", pageIndex, perPage: DEFAULT_PER_PAGE }],
({ queryKey: [{ pageIndex: page, perPage }] }) => {
return activitiesAPI.loadNext(page, perPage);
},
{
keepPreviousData: true,
staleTime: 5000,
select: (data) => data.activities,
onSuccess: (results) => {
setShowActivityFeedTitle(true);
if (results.length < DEFAULT_PER_PAGE) {
setShowMore(false);
}
setShowActivityFeedTitle(true);
setIsLoadingActivityFeed(false);
} catch (err) {
setIsLoadingError(true);
setIsLoadingActivityFeed(false);
}
};
getActivities();
}, [pageIndex]);
},
}
);
const onLoadPrevious = () => {
setIsLoadingActivityFeed(true);
setShowMore(true);
setPageIndex(pageIndex - 1);
};
const onLoadNext = () => {
setIsLoadingActivityFeed(true);
setPageIndex(pageIndex + 1);
};
const getDetail = (activity: IActivity) => {
if (activity.type === ActivityType.LiveQuery) {
return TAGGED_TEMPLATES.liveQueryActivityTemplate(activity);
switch (activity.type) {
case ActivityType.LiveQuery: {
return TAGGED_TEMPLATES.liveQueryActivityTemplate(activity);
}
case ActivityType.AppliedSpecPack: {
return TAGGED_TEMPLATES.editPackCtlActivityTemplate();
}
case ActivityType.AppliedSpecPolicy: {
return TAGGED_TEMPLATES.editPolicyCtlActivityTemplate();
}
case ActivityType.AppliedSpecSavedQuery: {
return TAGGED_TEMPLATES.editQueryCtlActivityTemplate(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
}
if (activity.type === ActivityType.AppliedSpecPack) {
return TAGGED_TEMPLATES.editPackCtlActivityTemplate();
}
if (activity.type === ActivityType.AppliedSpecSavedQuery) {
return TAGGED_TEMPLATES.editQueryCtlActivityTemplate(activity);
}
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
};
const renderActivityBlock = (activity: IActivity, i: number) => {
const { actor_email } = activity;
const { gravatarURL } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatarURL: DEFAULT_GRAVATAR_URL };
return (
<div className={`${baseClass}__block`} key={i}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{
gravatarURL,
}}
size="small"
/>
<div className={`${baseClass}__details`}>
<p className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> {getDetail(activity)}.
</p>
<span className={`${baseClass}__details-bottomline`}>
{formatDistanceToNow(new Date(activity.created_at), {
addSuffix: true,
})}
</span>
</div>
</div>
);
};
const renderError = () => {
@ -175,7 +164,7 @@ const ActivityFeed = ({
return (
<div className={`${baseClass}__no-activities`}>
<p>
<b>Fleet has not recorded any activities.</b>
<b>This is the start of your Fleet activities.</b>
</p>
<p>
Did you recently edit your queries, update your packs, or run a live
@ -185,52 +174,80 @@ const ActivityFeed = ({
);
};
const renderActivities = activities.map((activity: IActivity, i: number) =>
renderActivityBlock(activity, i)
);
const renderActivityBlock = (activity: IActivityDisplay) => {
const { actor_email, id, key } = activity;
const { gravatarURL } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatarURL: DEFAULT_GRAVATAR_URL };
return (
<div className={`${baseClass}__block`} key={key || id}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{
gravatarURL,
}}
size="small"
/>
<div className={`${baseClass}__details`}>
<p className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> {getDetail(activity)}.
</p>
<span className={`${baseClass}__details-bottomline`}>
{formatDistanceToNowStrict(new Date(activity.created_at), {
addSuffix: true,
})}
</span>
</div>
</div>
);
};
// Renders opaque information as activity feed is loading
const opacity = isLoadingActivityFeed ? { opacity: 0.4 } : { opacity: 1 };
const opacity = isFetchingActivities ? { opacity: 0.4 } : { opacity: 1 };
return (
<div className={baseClass}>
{isLoadingError && renderError()}
{!isLoadingError && !isLoadingActivityFeed && isEmpty(activities) ? (
{errorActivities && renderError()}
{!errorActivities && !isFetchingActivities && isEmpty(activities) ? (
renderNoActivities()
) : (
<>
{isLoadingActivityFeed && (
{isFetchingActivities && (
<div className="spinner">
<Spinner />
</div>
)}
<div style={opacity}>{renderActivities}</div>
<div style={opacity}>
{activities?.map((activity) => renderActivityBlock(activity))}
</div>
</>
)}
{!isLoadingError && !isEmpty(activities) && (
<div className={`${baseClass}__pagination`}>
<Button
disabled={isLoadingActivityFeed || pageIndex === 0}
onClick={onLoadPrevious}
variant="unstyled"
className={`${baseClass}__load-activities-button`}
>
<>
<FleetIcon name="chevronleft" /> Previous
</>
</Button>
<Button
disabled={isLoadingActivityFeed || !showMore}
onClick={onLoadNext}
variant="unstyled"
className={`${baseClass}__load-activities-button`}
>
<>
Next <FleetIcon name="chevronright" />
</>
</Button>
</div>
)}
{!errorActivities &&
(!isEmpty(activities) || (isEmpty(activities) && pageIndex > 0)) && (
<div className={`${baseClass}__pagination`}>
<Button
disabled={isFetchingActivities || pageIndex === 0}
onClick={onLoadPrevious}
variant="unstyled"
className={`${baseClass}__load-activities-button`}
>
<>
<FleetIcon name="chevronleft" /> Previous
</>
</Button>
<Button
disabled={isFetchingActivities || !showMore}
onClick={onLoadNext}
variant="unstyled"
className={`${baseClass}__load-activities-button`}
>
<>
Next <FleetIcon name="chevronright" />
</>
</Button>
</div>
)}
</div>
);
};

View file

@ -3,7 +3,7 @@
display: flex;
flex-direction: column;
position: relative;
min-height: 400px;
min-height: 592px;
&__header-wrap {
.form-field {
@ -166,6 +166,8 @@
display: flex;
justify-content: center;
align-items: center;
margin-top: 40px; // Aligns with software table spinner
.loading-spinner {
margin-top: 33px;
}
}
}

View file

@ -1,13 +1,21 @@
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { IActivity } from "interfaces/activity";
import sendRequest from "services";
const DEFAULT_PAGE = 0;
const PER_PAGE = 8;
const ORDER_KEY = "created_at";
const ORDER_DIRECTION = "desc";
export interface IActivitiesResponse {
activities: IActivity[];
}
export default {
loadNext: (page = DEFAULT_PAGE, perPage = PER_PAGE) => {
loadNext: (
page = DEFAULT_PAGE,
perPage = PER_PAGE
): Promise<IActivitiesResponse> => {
const { ACTIVITIES } = endpoints;
const pagination = `page=${page}&per_page=${perPage}`;
const sort = `order_key=${ORDER_KEY}&order_direction=${ORDER_DIRECTION}`;