mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Enhance activity feed UI (#4177)
* Refactor for useQuery * Add apply policy spec activity
This commit is contained in:
parent
745ca2cb80
commit
1526a1b336
4 changed files with 142 additions and 128 deletions
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue