From d32479622ca49bdfca240e66c3c71b1c40dd4afc Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 10 Sep 2021 19:49:11 +0200 Subject: [PATCH] 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 Co-authored-by: Martavis Parker --- changes/1899-host-status-webhook | 1 + .../integration/all/app/settingsflow.spec.ts | 27 +++ cypress/integration/premium/observer.spec.ts | 2 + docs/1-Using-Fleet/3-REST-API.md | 24 ++- .../admin/AppConfigForm/AppConfigForm.jsx | 166 +++++++++++++++++- .../forms/admin/AppConfigForm/_styles.scss | 9 +- .../forms/admin/AppConfigForm/validate.js | 18 ++ frontend/fleet/entities/config.js | 1 + frontend/fleet/entities/config.tests.js | 4 +- frontend/fleet/helpers.tests.js | 10 +- frontend/fleet/helpers.ts | 12 ++ frontend/interfaces/config.ts | 8 + .../admin/AppSettingsPage/AppSettingsPage.jsx | 3 + frontend/styles/global/_global.scss | 1 + frontend/test/stubs.ts | 16 +- 15 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 changes/1899-host-status-webhook diff --git a/changes/1899-host-status-webhook b/changes/1899-host-status-webhook new file mode 100644 index 0000000000..013731b4a5 --- /dev/null +++ b/changes/1899-host-status-webhook @@ -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 \ No newline at end of file diff --git a/cypress/integration/all/app/settingsflow.spec.ts b/cypress/integration/all/app/settingsflow.spec.ts index 7d7a68026c..d5472a6de6 100644 --- a/cypress/integration/all/app/settingsflow.spec.ts +++ b/cypress/integration/all/app/settingsflow.spec.ts @@ -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) => { diff --git a/cypress/integration/premium/observer.spec.ts b/cypress/integration/premium/observer.spec.ts index c11ce98753..d937136d45 100644 --- a/cypress/integration/premium/observer.spec.ts +++ b/cypress/integration/premium/observer.spec.ts @@ -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"); diff --git a/docs/1-Using-Fleet/3-REST-API.md b/docs/1-Using-Fleet/3-REST-API.md index fc19245ace..a38c044da5 100644 --- a/docs/1-Using-Fleet/3-REST-API.md +++ b/docs/1-Using-Fleet/3-REST-API.md @@ -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, diff --git a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx index 5c1d197e28..e037aca8f3 100644 --- a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx +++ b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx @@ -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. You’ve 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 ( + +

+ An example request sent to your configured Destination URL. +

+
+
+        
+
+ +
+
+ ); + }; + 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 {
@@ -479,6 +559,89 @@ class AppConfigForm extends Component { +
+

+ Host status webhook +

+
+

+ Send an alert if a portion of your hosts go offline. +

+ + Enable host status webhook + +

+ A request will be sent to your configured Destination URL{" "} + if the configured Percentage of hosts have not checked + into Fleet for the configured Number of days. +

+
+
+ +
+
+ +
+
+

Provide a URL to deliver
the webhook request to.

\ + " + } + /> +
+
+ +
+
+

Select the minimum percentage of hosts that
must fail to check into Fleet in order to trigger
the webhook request.

\ + " + } + /> +
+
+ +
+
+

Select the minimum number of days that the
configured Percentage of hosts must fail to
check into Fleet in order to trigger the
webhook request.

\ + " + } + /> +
+
+

Usage statistics @@ -527,6 +690,7 @@ class AppConfigForm extends Component { {renderUsageStatsPreviewModal()} + {renderHostStatusWebhookPreviewModal()} ); } diff --git a/frontend/components/forms/admin/AppConfigForm/_styles.scss b/frontend/components/forms/admin/AppConfigForm/_styles.scss index b05214ea17..9dd64db509 100644 --- a/frontend/components/forms/admin/AppConfigForm/_styles.scss +++ b/frontend/components/forms/admin/AppConfigForm/_styles.scss @@ -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; diff --git a/frontend/components/forms/admin/AppConfigForm/validate.js b/frontend/components/forms/admin/AppConfigForm/validate.js index b9819ad774..6e4c72a54b 100644 --- a/frontend/components/forms/admin/AppConfigForm/validate.js +++ b/frontend/components/forms/admin/AppConfigForm/validate.js @@ -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 = diff --git a/frontend/fleet/entities/config.js b/frontend/fleet/entities/config.js index a635ebb8fc..d9e8b95fc9 100644 --- a/frontend/fleet/entities/config.js +++ b/frontend/fleet/entities/config.js @@ -24,6 +24,7 @@ export default (client) => { }, update: (formData) => { const { CONFIG } = endpoints; + const configData = helpers.formatConfigDataForServer(formData); if (get(configData, "smtp_settings.port")) { diff --git a/frontend/fleet/entities/config.tests.js b/frontend/fleet/entities/config.tests.js index cb025c6b6e..87329cd34f 100644 --- a/frontend/fleet/entities/config.tests.js +++ b/frontend/fleet/entities/config.tests.js @@ -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, diff --git a/frontend/fleet/helpers.tests.js b/frontend/fleet/helpers.tests.js index 443262f5aa..1756a3003e 100644 --- a/frontend/fleet/helpers.tests.js +++ b/frontend/fleet/helpers.tests.js @@ -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, }); }); }); diff --git a/frontend/fleet/helpers.ts b/frontend/fleet/helpers.ts index ad9c4f25f7..d4793a04df 100644 --- a/frontend/fleet/helpers.ts +++ b/frontend/fleet/helpers.ts @@ -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, }; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index c10b0e1880..fe7819327f 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -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; diff --git a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx index fe51520115..0e0e9ea8a7 100644 --- a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx +++ b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx @@ -81,6 +81,9 @@ class AppSettingsPage extends Component {
  • Global agent options
  • +
  • + Host status webhook +
  • Usage statistics
  • diff --git a/frontend/styles/global/_global.scss b/frontend/styles/global/_global.scss index eaf5f783e5..d4c7675d35 100644 --- a/frontend/styles/global/_global.scss +++ b/frontend/styles/global/_global.scss @@ -112,6 +112,7 @@ pre { background-color: $core-fleet-black; color: $core-white; border-radius: 4px; + white-space: pre-wrap; .string { color: $rainbow-green; diff --git a/frontend/test/stubs.ts b/frontend/test/stubs.ts index 6203a19832..bad6acee37 100644 --- a/frontend/test/stubs.ts +++ b/frontend/test/stubs.ts @@ -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 = {