UI – Team-level host expiry setting (#16276)

## ➡️ #15965 

Without global setting:

![Screenshot-2024-01-24-at-12035PM(1)](https://github.com/fleetdm/fleet/assets/61553566/a98007a4-21b2-4f55-84e3-f58cf529af43)

With global setting:

![Screenshot-2024-01-24-at-12925PM(1)](https://github.com/fleetdm/fleet/assets/61553566/e6d20038-d2c2-4f75-a82e-3d4c0c8cb1fd)

 
- [x] Changes file added for user-visible changes in `changes/`
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
Co-authored-by: Sharon Katz <121527325+sharon-fdm@users.noreply.github.com>
This commit is contained in:
Jacob Shandling 2024-01-25 18:19:49 +00:00 committed by GitHub
parent aa60187aa1
commit 7550fd69fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 459 additions and 39 deletions

View File

@ -0,0 +1 @@
* Implement the UI for the new team-level host expiry setting feature.

View File

@ -325,16 +325,6 @@ Below are a few need-to-knows about what's available in Fleet's CSS:
1) When creating a form, **not** in a modal, use the class `${baseClass}__button-wrap` for the
action buttons (cancel, save, delete, etc.) and proceed to style as needed.
## Other
### Local states
Our first line of defense for state management is local states (i.e. `useState`). We
use local states to keep pages/components separate from one another and easy to
maintain. If states need to be passed to direct children, then prop-drilling should
suffice as long as we do not go more than two levels deep. Otherwise, if states need
to be used across multiple unrelated components or 3+ levels from a parent,
then the [app's context](#react-context) should be used.
## Icons and Images
@ -364,3 +354,20 @@ The icon should now be available to use with the `Icon` component from the given
The recommend line limit per page/component is 500 lines. This is only a recommendation.
Larger files are to be split into multiple files if possible.
## Other
### Local states
Our first line of defense for state management is local states (i.e. `useState`). We
use local states to keep pages/components separate from one another and easy to
maintain. If states need to be passed to direct children, then prop-drilling should
suffice as long as we do not go more than two levels deep. Otherwise, if states need
to be used across multiple unrelated components or 3+ levels from a parent,
then the [app's context](#react-context) should be used.
### Reading and updating configs
If you are dealing with a page that *updates* any kind of config, you'll want to access that config
with a fresh API call to be sure you have the updated values. Otherwise, that is, you are dealing
with a page that is only *reading* config values, get them from context.

View File

@ -63,6 +63,10 @@ export interface ITeam extends ITeamSummary {
grace_period_days: number | null;
};
};
host_expiry_settings?: {
host_expiry_enabled: boolean;
host_expiry_window: number; // days
};
}
/**

View File

@ -0,0 +1,11 @@
import { InjectedRouter } from "react-router";
export interface ITeamSubnavProps {
location: {
pathname: string;
search: string;
hash?: string;
query: { team_id?: string };
};
router: InjectedRouter;
}

View File

@ -1,7 +1,6 @@
import React, { useContext, useState, useEffect } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter } from "react-router";
import yaml from "js-yaml";
import { constructErrorString, agentOptionsToYaml } from "utilities/yaml";
import { EMPTY_AGENT_OPTIONS } from "utilities/constants";
@ -21,23 +20,14 @@ import Spinner from "components/Spinner";
import CustomLink from "components/CustomLink";
// @ts-ignore
import YamlAce from "components/YamlAce";
import { ITeamSubnavProps } from "interfaces/team_subnav";
const baseClass = "agent-options";
interface IAgentOptionsPageProps {
location: {
pathname: string;
search: string;
hash?: string;
query: { team_id?: string };
};
router: InjectedRouter;
}
const AgentOptionsPage = ({
location,
router,
}: IAgentOptionsPageProps): JSX.Element => {
}: ITeamSubnavProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const { isRouteOk, teamIdForApi } = useTeamIdParam({

View File

@ -2,7 +2,6 @@
font-size: $x-small;
&__page-description {
padding-top: 40px;
padding-bottom: $pad-medium;
margin: 0;
}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useContext, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter, Link } from "react-router";
import { Link } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
@ -9,6 +9,7 @@ import { IEmptyTableProps } from "interfaces/empty_table";
import { IApiError } from "interfaces/errors";
import { INewMembersBody, ITeam } from "interfaces/team";
import { IUpdateUserFormData, IUser, IUserFormErrors } from "interfaces/user";
import { ITeamSubnavProps } from "interfaces/team_subnav";
import PATHS from "router/paths";
import usersAPI from "services/entities/users";
import inviteAPI from "services/entities/invites";
@ -39,17 +40,7 @@ import {
const baseClass = "members";
const noMembersClass = "no-members";
interface IMembersPageProps {
location: {
pathname: string;
search: string;
hash?: string;
query: { team_id?: string };
};
router: InjectedRouter;
}
const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => {
const MembersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const { config, currentUser, isGlobalAdmin, isPremiumTier } = useContext(
AppContext

View File

@ -1,7 +1,6 @@
.members {
&__page-description {
font-size: $x-small;
padding-top: 40px;
padding-bottom: $pad-medium;
margin: 0;
}

View File

@ -51,6 +51,10 @@ const teamDetailsSubNav: ITeamDetailsSubNavItem[] = [
name: "Agent options",
getPathname: PATHS.TEAM_DETAILS_OPTIONS,
},
{
name: "Settings",
getPathname: PATHS.TEAM_DETAILS_SETTINGS,
},
];
interface ITeamDetailsPageProps {

View File

@ -0,0 +1,223 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import { IApiError } from "interfaces/errors";
import { IConfig } from "interfaces/config";
import { ITeamConfig } from "interfaces/team";
import { ITeamSubnavProps } from "interfaces/team_subnav";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import Button from "components/buttons/Button";
import DataError from "components/DataError";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Spinner from "components/Spinner";
import TeamHostExpiryToggle from "./components/TeamHostExpiryToggle";
const baseClass = "team-settings";
const HOST_EXPIRY_ERROR_TEXT = "Host expiry window must be a positive number.";
const TeamSettings = ({ location, router }: ITeamSubnavProps) => {
const [
formDataTeamHostExpiryEnabled,
setFormDataTeamHostExpiryEnabled,
] = useState(false); // default false until API response
const [
formDataTeamHostExpiryWindow,
setFormDataTeamHostExpiryWindow,
] = useState<number | string>("");
const [updatingTeamSettings, setUpdatingTeamSettings] = useState(false);
const [formErrors, setFormErrors] = useState<Record<string, string | null>>(
{}
);
const { renderFlash } = useContext(NotificationContext);
const { isRouteOk, teamIdForApi } = useTeamIdParam({
location,
router,
includeAllTeams: false,
includeNoTeam: false,
permittedAccessByTeamRole: {
admin: true,
maintainer: false,
observer: false,
observer_plus: false,
},
});
const {
data: appConfig,
isLoading: isLoadingAppConfig,
error: errorLoadGlobalConfig,
} = useQuery<IConfig, Error, IConfig>(
["globalConfig"],
() => configAPI.loadAll(),
{ refetchOnWindowFocus: false }
);
const {
host_expiry_settings: {
host_expiry_enabled: globalHostExpiryEnabled,
host_expiry_window: globalHostExpiryWindow,
},
} = appConfig ?? { host_expiry_settings: {} };
const {
isLoading: isLoadingTeamConfig,
refetch: refetchTeamConfig,
error: errorLoadTeamConfig,
} = useQuery<ILoadTeamResponse, Error, ITeamConfig>(
["teamConfig", teamIdForApi],
() => teamsAPI.load(teamIdForApi),
{
enabled: isRouteOk && !!teamIdForApi,
select: (data) => data.team,
onSuccess: (teamConfig) => {
// default this setting to current team setting
// can be updated by user actions
setFormDataTeamHostExpiryEnabled(
teamConfig?.host_expiry_settings?.host_expiry_enabled ?? false
);
setFormDataTeamHostExpiryWindow(
teamConfig.host_expiry_settings?.host_expiry_window ?? ""
);
},
refetchOnWindowFocus: false,
}
);
const validate = useCallback(() => {
const errors: Record<string, string> = {};
const numHostExpiryWindow = Number(formDataTeamHostExpiryWindow);
if (
// with no global setting, team window can't be empty if enabled
(!globalHostExpiryEnabled &&
formDataTeamHostExpiryEnabled &&
!numHostExpiryWindow) ||
// if nonempty, must be a positive number
isNaN(numHostExpiryWindow) ||
// if overriding a global setting, can be empty to disable local setting
numHostExpiryWindow < 0
) {
errors.host_expiry_window = HOST_EXPIRY_ERROR_TEXT;
}
setFormErrors(errors);
}, [
formDataTeamHostExpiryEnabled,
formDataTeamHostExpiryWindow,
globalHostExpiryEnabled,
]);
useEffect(() => {
validate();
}, [formDataTeamHostExpiryEnabled, formDataTeamHostExpiryWindow, validate]);
const updateTeamHostExpiry = useCallback(
(evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
setUpdatingTeamSettings(true);
const castedHostExpiryWindow = Number(formDataTeamHostExpiryWindow);
let enableHostExpiry;
if (globalHostExpiryEnabled) {
if (!castedHostExpiryWindow) {
enableHostExpiry = false;
} else {
enableHostExpiry = formDataTeamHostExpiryEnabled;
}
} else {
enableHostExpiry = formDataTeamHostExpiryEnabled;
}
teamsAPI
.update(
{
host_expiry_settings: {
host_expiry_enabled: enableHostExpiry,
host_expiry_window: castedHostExpiryWindow,
},
},
teamIdForApi
)
.then(() => {
renderFlash("success", "Successfully updated settings.");
refetchTeamConfig();
})
.catch((errorResponse: { data: IApiError }) => {
renderFlash(
"error",
`Could not update team settings. ${errorResponse.data.errors[0].reason}`
);
})
.finally(() => {
setUpdatingTeamSettings(false);
});
},
[
formDataTeamHostExpiryEnabled,
formDataTeamHostExpiryWindow,
globalHostExpiryEnabled,
refetchTeamConfig,
renderFlash,
teamIdForApi,
]
);
const renderForm = () => {
if (errorLoadGlobalConfig || errorLoadTeamConfig) {
return <DataError />;
}
if (isLoadingTeamConfig || isLoadingAppConfig) {
return <Spinner />;
}
return (
<form onSubmit={updateTeamHostExpiry}>
{globalHostExpiryEnabled !== undefined && (
<TeamHostExpiryToggle
globalHostExpiryEnabled={globalHostExpiryEnabled}
globalHostExpiryWindow={globalHostExpiryWindow}
teamExpiryEnabled={formDataTeamHostExpiryEnabled}
setTeamExpiryEnabled={setFormDataTeamHostExpiryEnabled}
/>
)}
{formDataTeamHostExpiryEnabled && (
<InputField
label="Host expiry window"
// type="text" allows `validate` to differentiate between
// non-numerical input and an empty input
type="text"
onChange={setFormDataTeamHostExpiryWindow}
name="host-expiry-window"
value={formDataTeamHostExpiryWindow}
error={formErrors.host_expiry_window}
/>
)}
<Button
type="submit"
variant="brand"
className="button-wrap"
isLoading={updatingTeamSettings}
disabled={Object.keys(formErrors).length > 0}
>
Save
</Button>
</form>
);
};
return (
<section className={`${baseClass}`}>
<div className="section-header">Host expiry settings</div>
{renderForm()}
</section>
);
};
export default TeamSettings;

View File

@ -0,0 +1,7 @@
.team-settings {
max-width: $settings-form-max-width;
.section-header {
border-bottom: 1px solid #e3e3e3;
padding-bottom: 8px;
}
}

View File

@ -0,0 +1,57 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import TeamHostExpiryToggle from "./TeamHostExpiryToggle";
describe("TeamHostExpiryToggle component", () => {
// global setting disabled
it("Renders correctly with no global window set", () => {
render(
<TeamHostExpiryToggle
globalHostExpiryEnabled={false}
globalHostExpiryWindow={undefined}
teamExpiryEnabled={false}
setTeamExpiryEnabled={jest.fn()}
/>
);
expect(screen.getByText(/Enable host expiry/)).toBeInTheDocument();
expect(screen.queryByText(/Host expiry is globally enabled/)).toBeNull();
});
// global setting enabled
it("Renders as expected when global enabled, local disabled", () => {
render(
<TeamHostExpiryToggle
globalHostExpiryEnabled
globalHostExpiryWindow={2}
teamExpiryEnabled={false}
setTeamExpiryEnabled={jest.fn()}
/>
);
expect(screen.getByText(/Enable host expiry/)).toBeInTheDocument();
expect(
screen.getByText(/Host expiry is globally enabled/)
).toBeInTheDocument();
expect(screen.getByText(/Add custom expiry window/)).toBeInTheDocument();
});
it("Renders as expected when global enabled, local enabled", () => {
render(
<TeamHostExpiryToggle
globalHostExpiryEnabled
globalHostExpiryWindow={2}
teamExpiryEnabled
setTeamExpiryEnabled={jest.fn()}
/>
);
expect(screen.getByText(/Enable host expiry/)).toBeInTheDocument();
expect(
screen.getByText(/Host expiry is globally enabled/)
).toBeInTheDocument();
expect(screen.queryByText(/Add custom expiry window/)).toBeNull();
});
});

View File

@ -0,0 +1,85 @@
import Checkbox from "components/forms/fields/Checkbox";
import Icon from "components/Icon";
import React from "react";
import { Link } from "react-router";
const baseClass = "team-host-expiry-toggle";
interface ITeamHostExpiryToggle {
globalHostExpiryEnabled: boolean;
globalHostExpiryWindow?: number;
teamExpiryEnabled: boolean;
setTeamExpiryEnabled: (value: boolean) => void;
}
const TeamHostExpiryToggle = ({
globalHostExpiryEnabled,
globalHostExpiryWindow,
teamExpiryEnabled,
setTeamExpiryEnabled,
}: ITeamHostExpiryToggle) => {
const renderHelpText = () =>
// this will never be rendered while globalHostExpiryWindow is undefined
globalHostExpiryEnabled ? (
<div className="help-text">
Host expiry is globally enabled in organization settings. By default,
hosts expire after {globalHostExpiryWindow} days.{" "}
{!teamExpiryEnabled && (
<Link
to={""}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
setTeamExpiryEnabled(true);
}}
className={`${baseClass}__add-custom-window`}
>
<>
Add custom expiry window
<Icon name="chevron-right" color="core-fleet-blue" size="small" />
</>
</Link>
)}
</div>
) : (
<></>
);
return (
<div className={`${baseClass}`}>
<Checkbox
name="enableHostExpiry"
onChange={setTeamExpiryEnabled}
value={teamExpiryEnabled || globalHostExpiryEnabled}
wrapperClassName={
globalHostExpiryEnabled
? `${baseClass}__disabled-team-host-expiry-toggle`
: ""
}
helpText={renderHelpText()}
tooltipContent={
!globalHostExpiryEnabled && (
<>
When enabled, allows automatic cleanup of
<br />
hosts that have not communicated with Fleet in
<br />
the number of days specified in the{" "}
<strong>
Host expiry
<br />
window
</strong>{" "}
setting.{" "}
<em>
(Default: <strong>Off</strong>)
</em>
</>
)
}
>
Enable host expiry
</Checkbox>
</div>
);
};
export default TeamHostExpiryToggle;

View File

@ -0,0 +1,13 @@
.team-host-expiry-toggle {
&__disabled-team-host-expiry-toggle > .fleet-checkbox {
@include disabled;
}
&__add-custom-window {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: inherit;
font-size: inherit;
}
}

View File

@ -0,0 +1 @@
export { default } from "./TeamHostExpiryToggle";

View File

@ -0,0 +1 @@
export { default } from "./TeamSettings";

View File

@ -27,4 +27,8 @@
margin-left: $pad-medium;
padding: 4px 12px;
}
.react-tabs__tab-list {
margin-bottom: 50px;
}
}

View File

@ -23,7 +23,7 @@
// Global style max width of all content to 754px
> * {
width: 100%;
max-width: 754px;
max-width: $settings-form-max-width;
}
}
}

View File

@ -63,6 +63,7 @@ import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles";
import SoftwareOS from "pages/SoftwarePage/SoftwareOS";
import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage";
import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import TeamSettings from "pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings";
import SoftwareOSDetailsPage from "pages/SoftwarePage/SoftwareOSDetailsPage";
import PATHS from "router/paths";
@ -158,6 +159,7 @@ const routes = (
<Route path="teams" component={TeamDetailsWrapper}>
<Route path="members" component={MembersPage} />
<Route path="options" component={AgentOptionsPage} />
<Route path="settings" component={TeamSettings} />
</Route>
<Redirect from="teams/:team_id" to="teams" />
<Redirect from="teams/:team_id/members" to="teams" />

View File

@ -143,6 +143,12 @@ export default {
}
return `${URL_PREFIX}/settings/teams`;
},
TEAM_DETAILS_SETTINGS: (teamId?: number) => {
if (teamId !== undefined && teamId > 0) {
return `${URL_PREFIX}/settings/teams/settings?team_id=${teamId}`;
}
return `${URL_PREFIX}/settings/teams`;
},
MANAGE_PACKS: `${URL_PREFIX}/packs/manage`,
NEW_PACK: `${URL_PREFIX}/packs/new`,
MANAGE_QUERIES: `${URL_PREFIX}/queries/manage`,

View File

@ -50,6 +50,10 @@ export interface IUpdateTeamFormData {
grace_period_days: number;
};
};
host_expiry_settings: {
host_expiry_enabled: boolean;
host_expiry_window: number; // days
};
}
export default {
@ -91,7 +95,13 @@ export default {
return sendRequest("GET", path);
},
update: (
{ name, webhook_settings, integrations, mdm }: Partial<IUpdateTeamFormData>,
{
name,
webhook_settings,
integrations,
mdm,
host_expiry_settings,
}: Partial<IUpdateTeamFormData>,
teamId?: number
): Promise<ITeamConfig> => {
if (typeof teamId === "undefined") {
@ -123,6 +133,9 @@ export default {
if (mdm) {
requestBody.mdm = mdm;
}
if (host_expiry_settings) {
requestBody.host_expiry_settings = host_expiry_settings;
}
return sendRequest("PATCH", path, requestBody);
},

View File

@ -6,3 +6,6 @@ $border-radius-xxlarge: 16px;
// box shadow
$box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4);
// dimensions
$settings-form-max-width: 754px;

View File

@ -1 +0,0 @@
$form-field-label-height: 24px;