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 .
+
+
+
+
+ Done
+
+
+
+ );
+ };
+
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 {
+
+
+
+
+ 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 .
+
+
+
+
+ Preview request
+
+
+
+
+
+
+
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 = {