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:
co-authored by
Noah Talerman
Martavis Parker
parent
13e5e6a6db
commit
d32479622c
@@ -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");
|
||||
|
||||
|
||||
@@ -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. 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 (
|
||||
<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'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 =
|
||||
|
||||
@@ -24,6 +24,7 @@ export default (client) => {
|
||||
},
|
||||
update: (formData) => {
|
||||
const { CONFIG } = endpoints;
|
||||
|
||||
const configData = helpers.formatConfigDataForServer(formData);
|
||||
|
||||
if (get(configData, "smtp_settings.port")) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user