App Settings Page: Host status webhook UI (#1899)

* Skeleton UI

* Rebase to main

* Work towards config API request modification

* Nest and unnest when formatting for server and frontend

* Changelog

* Add validation to UI, fix ? vertical spacing

* Rebase e2e

* 1 of 2 passing unit tests for config

* Update REST-API.md to include webhook_settings

* Destructure / flatten config webhook in unit test

* Merge advance options e2e conflict

* x and y example not x and x

* Fix observer e2e

* Add new data to read only example request

Co-authored-by: Noah Talerman <noahtal@umich.edu>
Co-authored-by: Martavis Parker <martavis@auraticdevelopment.com>
This commit is contained in:
RachelElysia
2021-09-10 10:49:11 -07:00
committed by GitHub
co-authored by Noah Talerman Martavis Parker
parent 13e5e6a6db
commit d32479622c
15 changed files with 292 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
Allow users to create a host status webhook on the app settings page that will trigger when x percent of hosts are offline for y number of days
@@ -66,6 +66,22 @@ describe("Settings flow", () => {
.click()
.type("rachelspassword");
cy.findByLabelText(/enable host status webhook/i).check({ force: true });
cy.findByLabelText(/destination url/i)
.click()
.type("http://server.com/example");
cy.get(".app-config-form__host-percentage").click();
cy.get(".app-config-form__host-percentage").contains(/5%/i).click();
cy.get(".app-config-form__days-count").click();
cy.get(".app-config-form__days-count")
.contains(/7 days/i)
.click();
cy.findByLabelText(/domain/i)
.click()
.type("http://www.fleetdm.com");
@@ -135,6 +151,17 @@ describe("Settings flow", () => {
"rachelsusername"
);
cy.findByLabelText(/destination url/i).should(
"have.value",
"http://server.com/example"
);
cy.findByText(/5%/i).should("exist");
cy.findByText(/7 days/i).should("exist");
cy.findByText(/1 day/i).should("not.exist");
cy.findByText(/select one/i).should("not.exist");
cy.findByLabelText(/host expiry window/i).should("have.value", "5");
cy.getEmails().then((response) => {
@@ -26,6 +26,7 @@ describe(
// Host manage page: Can see team column
cy.visit("/hosts/manage");
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.get("thead").within(() => {
cy.findByText(/team/i).should("exist");
@@ -65,6 +66,7 @@ describe(
it("Can perform the appropriate basic team observer only actions", () => {
cy.login("toni@organization.com", "user123#");
cy.visit("/hosts/manage");
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText("Hosts").should("exist");
+22 -2
View File
@@ -4922,7 +4922,17 @@ None.
"expiration": "2021-12-31T19:00:00-05:00",
"note": ""
},
"vulnerability_settings": null,
"vulnerability_settings": {
"databases_path": ""
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": true,
"destination_url": "https://server.com",
"host_percentage": 5,
"days_count": 7
}
},
"logging": {
"debug": false,
"json": false,
@@ -5084,7 +5094,17 @@ Modifies the Fleet's configuration with the supplied information.
"overrides": {}
}
},
"vulnerability_settings": null,
"vulnerability_settings": {
"databases_path": ""
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": true,
"destination_url": "https://server.com",
"host_percentage": 5,
"days_count": 7
}
},
"logging": {
"debug": false,
"json": false,
@@ -28,6 +28,19 @@ const authTypeOptions = [
{ label: "Username and Password", value: "authtype_username_password" },
{ label: "None", value: "authtype_none" },
];
const percentageOfHosts = [
{ label: "1%", value: 1 },
{ label: "5%", value: 5 },
{ label: "10%", value: 10 },
{ label: "25%", value: 25 },
];
const numberOfDays = [
{ label: "1 day", value: 1 },
{ label: "3 days", value: 3 },
{ label: "7 days", value: 7 },
{ label: "14 days", value: 14 },
];
const baseClass = "app-config-form";
const formFields = [
"authentication_method",
@@ -58,6 +71,10 @@ const formFields = [
"host_expiry_window",
"live_query_disabled",
"agent_options",
"enable_host_status_webhook",
"destination_url",
"host_percentage",
"days_count",
"enable_analytics",
];
class AppConfigForm extends Component {
@@ -90,6 +107,10 @@ class AppConfigForm extends Component {
host_expiry_window: formFieldInterface.isRequired,
live_query_disabled: formFieldInterface.isRequired,
agent_options: formFieldInterface.isRequired,
enable_host_status_webhook: formFieldInterface.isRequired,
destination_url: formFieldInterface,
host_percentage: formFieldInterface,
days_count: formFieldInterface,
enable_analytics: formFieldInterface.isRequired,
}).isRequired,
enrollSecret: PropTypes.arrayOf(enrollSecretInterface).isRequired,
@@ -101,10 +122,28 @@ class AppConfigForm extends Component {
super(props);
this.state = {
showHostStatusWebhookPreviewModal: false,
showUsageStatsPreviewModal: false,
};
}
onToggleAdvancedOptions = (evt) => {
evt.preventDefault();
const { showAdvancedOptions } = this.state;
this.setState({ showAdvancedOptions: !showAdvancedOptions });
return false;
};
toggleHostStatusWebhookPreviewModal = () => {
const { showHostStatusWebhookPreviewModal } = this.state;
this.setState({
showHostStatusWebhookPreviewModal: !showHostStatusWebhookPreviewModal,
});
};
toggleUsageStatsPreviewModal = () => {
const { showUsageStatsPreviewModal } = this.state;
this.setState({
@@ -181,6 +220,45 @@ class AppConfigForm extends Component {
);
};
renderHostStatusWebhookPreviewModal = () => {
const { toggleHostStatusWebhookPreviewModal } = this;
const { showHostStatusWebhookPreviewModal } = this.state;
if (!showHostStatusWebhookPreviewModal) {
return null;
}
const json = {
message:
"More than X% of your hosts have not checked into Fleet for more than Y days. Youve been sent this message because the Host status webhook is enabled in your Fleet instance.",
data: {
unseen_hosts: 1,
total_hosts: 2,
days_unseen: 3,
},
};
return (
<Modal
title="Host status webhook"
onExit={toggleHostStatusWebhookPreviewModal}
className={`${baseClass}__host-status-webhook-preview-modal`}
>
<p>
An example request sent to your configured <b>Destination URL</b>.
</p>
<div className={`${baseClass}__host-status-webhook-preview`}>
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
</div>
<div className="flex-end">
<Button type="button" onClick={toggleHostStatusWebhookPreviewModal}>
Done
</Button>
</div>
</Modal>
);
};
renderUsageStatsPreviewModal = () => {
const { toggleUsageStatsPreviewModal } = this;
const { showUsageStatsPreviewModal } = this.state;
@@ -217,7 +295,9 @@ class AppConfigForm extends Component {
const {
renderAdvancedOptions,
renderSmtpSection,
toggleHostStatusWebhookPreviewModal,
toggleUsageStatsPreviewModal,
renderHostStatusWebhookPreviewModal,
renderUsageStatsPreviewModal,
} = this;
@@ -398,7 +478,7 @@ class AppConfigForm extends Component {
<div className={`${baseClass}__details`}>
<IconToolTip
text={
"The hostname / IP address and corresponding port of your organization&apos;s SMTP server."
"The hostname / IP address and corresponding port of your organization's SMTP server."
}
/>
</div>
@@ -479,6 +559,89 @@ class AppConfigForm extends Component {
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>
<a id="host-status-webhook">Host status webhook</a>
</h2>
<div className={`${baseClass}__host-status-webhook`}>
<p className={`${baseClass}__section-description`}>
Send an alert if a portion of your hosts go offline.
</p>
<Checkbox {...fields.enable_host_status_webhook}>
Enable host status webhook
</Checkbox>
<p className={`${baseClass}__section-description`}>
A request will be sent to your configured <b>Destination URL</b>{" "}
if the configured <b>Percentage of hosts</b> have not checked
into Fleet for the configured <b>Number of days</b>.
</p>
</div>
<div
className={`${baseClass}__inputs ${baseClass}__inputs--webhook`}
>
<Button
type="button"
variant="inverse"
onClick={toggleHostStatusWebhookPreviewModal}
>
Preview request
</Button>
</div>
<div className={`${baseClass}__inputs`}>
<InputField
{...fields.destination_url}
placeholder="https://server.com/example"
label="Destination URL"
/>
</div>
<div className={`${baseClass}__details`}>
<IconToolTip
isHtml
text={
"\
<center><p>Provide a URL to deliver <br/>the webhook request to.</p></center>\
"
}
/>
</div>
<div
className={`${baseClass}__inputs ${baseClass}__host-percentage`}
>
<Dropdown
{...fields.host_percentage}
label="Percentage of hosts"
options={percentageOfHosts}
/>
</div>
<div className={`${baseClass}__details`}>
<IconToolTip
isHtml
text={
"\
<center><p>Select the minimum percentage of hosts that<br/>must fail to check into Fleet in order to trigger<br/>the webhook request.</p></center>\
"
}
/>
</div>
<div className={`${baseClass}__inputs ${baseClass}__days-count`}>
<Dropdown
{...fields.days_count}
label="Number of days"
options={numberOfDays}
/>
</div>
<div className={`${baseClass}__details`}>
<IconToolTip
isHtml
text={
"\
<center><p>Select the minimum number of days that the<br/>configured <b>Percentage of hosts</b> must fail to<br/>check into Fleet in order to trigger the<br/>webhook request.</p></center>\
"
}
/>
</div>
</div>
<div className={`${baseClass}__section`}>
<h2>
<a id="usage-stats">Usage statistics</a>
@@ -527,6 +690,7 @@ class AppConfigForm extends Component {
</Button>
</form>
{renderUsageStatsPreviewModal()}
{renderHostStatusWebhookPreviewModal()}
</>
);
}
@@ -123,7 +123,7 @@
&__inputs {
width: 60%;
float: left;
padding: 0 $pad-xxlarge 0 0;
padding-right: $pad-small;
box-sizing: border-box;
.input-field {
@@ -152,11 +152,15 @@
&--usage {
margin-top: 24px;
}
&--webhook {
margin-bottom: 24px;
}
}
&__details {
float: right;
width: 40%;
height: 87px;
.icon-tooltip {
margin: $pad-xlarge 0;
@@ -216,7 +220,8 @@
); // because the outline extended beyond the sticky page description
}
&__usage-stats-preview-modal {
&__usage-stats-preview-modal,
&__host-status-webhook-preview-modal {
.flex-end {
margin-top: 20px;
display: flex;
@@ -22,6 +22,10 @@ export default (formData) => {
host_expiry_enabled: hostExpiryEnabled,
host_expiry_window: hostExpiryWindow = 0,
agent_options: agentOptions,
enable_host_status_webhook: enableHostStatusWebhook,
destination_url: destinationUrl,
host_percentage: hostPercentage,
days_count: daysCount,
} = formData;
if (enableSSO) {
@@ -68,6 +72,20 @@ export default (formData) => {
}
}
if (enableHostStatusWebhook) {
if (!destinationUrl) {
errors.destination_url = "Destination URL must be present";
}
if (!hostPercentage) {
errors.host_percentage = "Host percentage must be present";
}
if (!daysCount) {
errors.days_count = "Days count must be present";
}
}
if (hostExpiryEnabled) {
if (isNaN(hostExpiryWindow) || Number(hostExpiryWindow) <= 0) {
errors.host_expiry_window =
+1
View File
@@ -24,6 +24,7 @@ export default (client) => {
},
update: (formData) => {
const { CONFIG } = endpoints;
const configData = helpers.formatConfigDataForServer(formData);
if (get(configData, "smtp_settings.port")) {
+2 -2
View File
@@ -6,7 +6,7 @@ import mocks from "test/mocks";
const { config: configMocks } = mocks;
describe("Kolide - API client (config)", () => {
describe("Fleet - API client (config)", () => {
afterEach(() => {
nock.cleanAll();
Fleet.setBearerToken(null);
@@ -28,7 +28,7 @@ describe("Kolide - API client (config)", () => {
describe("#update", () => {
it("calls the appropriate endpoint with the correct parameters", () => {
const formData = {
org_name: "Kolide",
org_name: "Fleet",
org_logo_url: "0.0.0.0:8080/logo.png",
server_url: "",
configured: false,
+9 -1
View File
@@ -19,7 +19,7 @@ describe("Fleet API - helpers", () => {
describe("#formatConfigDataForServer", () => {
const { formatConfigDataForServer } = helpers;
const config = {
org_name: "Kolide",
org_name: "Fleet",
org_logo_url: "0.0.0.0:8080/logo.png",
server_url: "",
configured: false,
@@ -38,6 +38,10 @@ describe("Fleet API - helpers", () => {
host_expiry_enabled: false,
host_expiry_window: 0,
live_query_disabled: false,
enable_host_status_webhook: false,
destination_url: "http://server.com/example",
host_percentage: 5,
days_count: 7,
};
it("splits config into categories for the server", () => {
@@ -211,12 +215,16 @@ describe("redux app node - helpers", () => {
smtp_settings: smtpSettings,
host_expiry_settings: hostExpirySettings,
} = configStub;
const {
host_status_webhook: webhookSettings,
} = configStub.webhook_settings;
expect(frontendFormattedConfig(configStub)).toEqual({
...orgInfo,
...serverSettings,
...smtpSettings,
...hostExpirySettings,
...webhookSettings,
});
});
});
+12
View File
@@ -118,6 +118,12 @@ export const formatConfigDataForServer = (config: any): any => {
"host_expiry_enabled",
"host_expiry_window",
]);
const webhookSettingsAttrs = pick(config, [
"enable_host_status_webhook",
"destination_url",
"host_percentage",
"days_count",
]);
// because agent_options is already an object
const agentOptionsSettingsAttrs = config.agent_options;
@@ -137,6 +143,9 @@ export const formatConfigDataForServer = (config: any): any => {
const agentOptionsSettings = size(agentOptionsSettingsAttrs) && {
agent_options: yaml.load(agentOptionsSettingsAttrs),
};
const webhookSettings = size(webhookSettingsAttrs) && {
webhook_settings: { host_status_webhook: webhookSettingsAttrs }, // nested to server
};
if (hostExpirySettings) {
hostExpirySettings.host_expiry_settings.host_expiry_window = Number(
@@ -151,6 +160,7 @@ export const formatConfigDataForServer = (config: any): any => {
...ssoSettings,
...hostExpirySettings,
...agentOptionsSettings,
...webhookSettings,
};
};
@@ -162,6 +172,7 @@ export const frontendFormattedConfig = (config: any) => {
smtp_settings: smtpSettings,
sso_settings: ssoSettings,
host_expiry_settings: hostExpirySettings,
webhook_settings: { host_status_webhook: webhookSettings }, // unnested to frontend
license,
} = config;
@@ -175,6 +186,7 @@ export const frontendFormattedConfig = (config: any) => {
...smtpSettings,
...ssoSettings,
...hostExpirySettings,
...webhookSettings,
...license,
agent_options: config.agent_options,
};
+8
View File
@@ -37,6 +37,10 @@ export default PropTypes.shape({
expiration: PropTypes.string,
note: PropTypes.string,
// vulnerability_settings: PropTypes.any, TODO
enable_host_status_webhook: PropTypes.bool,
destination_url: PropTypes.string,
host_percentage: PropTypes.number,
days_count: PropTypes.number,
logging: PropTypes.shape({
debug: PropTypes.bool,
json: PropTypes.bool,
@@ -96,6 +100,10 @@ export interface IConfig {
expiration: string;
note: string;
// vulnerability_settings: any; TODO
enable_host_status_webhook: boolean;
destination_url: string;
host_percentage: number;
days_count: number;
logging: {
debug: boolean;
json: boolean;
@@ -81,6 +81,9 @@ class AppSettingsPage extends Component {
<li>
<a href="#agent-options">Global agent options</a>
</li>
<li>
<a href="#host-status-webhook">Host status webhook</a>
</li>
<li>
<a href="#usage-stats">Usage statistics</a>
</li>
+1
View File
@@ -112,6 +112,7 @@ pre {
background-color: $core-fleet-black;
color: $core-white;
border-radius: 4px;
white-space: pre-wrap;
.string {
color: $rainbow-green;
+14 -2
View File
@@ -15,7 +15,7 @@ export const adminUserStub = {
export const configStub = {
org_info: {
org_name: "Kolide",
org_name: "Fleet",
org_logo_url: "0.0.0.0:8080/logo.png",
},
server_settings: {
@@ -40,10 +40,18 @@ export const configStub = {
host_expiry_enabled: false,
host_expiry_window: 0,
},
webhook_settings: {
host_status_webhook: {
enable_host_status_webhook: false,
destination_url: "http://server.com/example",
host_percentage: 5,
days_count: 7,
},
},
};
export const flatConfigStub = {
org_name: "Kolide",
org_name: "Fleet",
org_logo_url: "0.0.0.0:8080/logo.png",
server_url: "",
configured: false,
@@ -61,6 +69,10 @@ export const flatConfigStub = {
host_expiry_enabled: false,
host_expiry_window: 0,
live_query_disabled: false,
enable_host_status_webhook: false,
destination_url: "http://server.com/example",
host_percentage: 5,
days_count: 7,
};
export const hostStub = {