From 2829a8b14210fc29ecac3c5737891003bf285a43 Mon Sep 17 00:00:00 2001 From: Mohamed OULD HOCINE <106236152+gally47@users.noreply.github.com> Date: Wed, 19 Oct 2022 16:44:47 +0200 Subject: [PATCH] Sidecar support for tags and collectors with multiple configurations (#13433) * feature branch for multiple configs per collector and tags * Add "tags" field to DTOs (#13367) * Add spooldir variable to sidecars and their default collector configs (#13349) * Add support for multiple configurations per collector This will need a new sidcar release. Old sidecars are still supported with just a single configuration. * Use Java 9 Lists * Add support for ${sidecar.spoolDir} variable * Fix tests * Revert "Add support for multiple configurations per collector" This reverts commit 0a17743a7775ca25e65d978e06343847042bbafb. * Use ${sidecar.spoolDir} for fresh collector templates And add explanation to the UI * make eslint happy * convert tags to Set and fix Autovalue * Support configuration_id in CollectorStatus Co-authored-by: Othello Maurer * Sidecar tagged assignments (#13409) * add assignedFromTags property to assignment * Don't fail for unknown properties on NodeDetails This makes it easier to extend the sidecar in the future * Create sidecar config assignments based on tags This is a first naive approach. Every registration request will rebuild the assignments and this is probably too expensive. * fix test * Fix more Tests * Fine grained EtagService Use three different caches for configs, collectors and assignments. * Add Etag caching for UpdateRegistration results The response to the registration PUT request contains configuration assignments, which can be cached as well. This requires us to invalidate the sidecar etag cache in more situations: - Configration assignments - User triggered collector actions (stop, start, restart) * Cleanup old assignment caching commit * optimize * fix after merge * replace deprecated notempty annotation * cache assignments per sidecar id using just the md5 of an assignment result might leed to sidecars missing out on updates. An assignment is always meant for a single sidecar, caching the entire assignment over all sidecars is wrong. A per sidecar cache entry allows more fine grained cache invalidations. Bump the cacheMaxSize to 5000 entries, which is a more meaningful number, considered that this might have to hold all sidecars. * Refactor sidecar register() call. Only update the tagged assignments when we miss the cache. * change cache naming * more refactoring * improve cache invalidation * use fancy java Co-authored-by: Othello Maurer * Add more cases for tag invalidation and fix tests * add tags to Config summary * Generate EntityTags with a better hash algorithm As @thll noticed, Object.hashCode() is prone to having collisions * Handle manual assignments via API * rename * Make tags available as config variables. This allows configs to be written with conditionals. E.g.: ``` <#if sidecar.tags.apache??> - /var/log/apache/*.log ``` * dont lose tags on UI configuration updates Co-authored-by: Othello Maurer * Sidecar tags tests (#13502) * Only assign tagged configs that match the OS * rename test * add tests for SidecarService.updateTaggedConfigurationAssignments() * add tests for SidecarService.applyManualAssignments() * Fix cache invalidation for collector actions (#13504) * Fix cache invalidation for collector actions The registration cache was using the sidecar "_id", while the ActionService was invalidating the sidecar "node_id". Change the entire cache to using the "node_id" * Rename parameter from sidecarId to sidecarNodeId Co-authored-by: Othello Maurer * Invalidate registration cache only for affected sidecars (#13514) * Invalidate registration cache only for affected sidecars * Use node-id for cache invalidation The cache key has been changed since this PR has been started. * Consider OS for cache invalidation Also use symmetric difference of tags to further reduce unnecessary invalidations * Invalidate less when creating new configs * Add description for tag variables * Add "tags" field to DTOs (#13367) * show CollectorProcessControl as an action button * added CollectorConfigurationModal * made search button in SearchForm optional * Add spooldir variable to sidecars and their default collector configs (#13349) * Add support for multiple configurations per collector This will need a new sidcar release. Old sidecars are still supported with just a single configuration. * Use Java 9 Lists * Add support for ${sidecar.spoolDir} variable * Fix tests * Revert "Add support for multiple configurations per collector" This reverts commit 0a17743a7775ca25e65d978e06343847042bbafb. * Use ${sidecar.spoolDir} for fresh collector templates And add explanation to the UI * make eslint happy * convert tags to Set and fix Autovalue * Support configuration_id in CollectorStatus Co-authored-by: Othello Maurer * handle edge case very long config names * handle edge cases when its not possible to configure * change config list styling * make CollectorConfigurationModal a reusable component * added CollectorConfigurationModal in CollectorsAdministrationActions * icon btn as edit btn progress * Sidecar tagged assignments (#13409) * add assignedFromTags property to assignment * Don't fail for unknown properties on NodeDetails This makes it easier to extend the sidecar in the future * Create sidecar config assignments based on tags This is a first naive approach. Every registration request will rebuild the assignments and this is probably too expensive. * fix test * Fix more Tests * Fine grained EtagService Use three different caches for configs, collectors and assignments. * Add Etag caching for UpdateRegistration results The response to the registration PUT request contains configuration assignments, which can be cached as well. This requires us to invalidate the sidecar etag cache in more situations: - Configration assignments - User triggered collector actions (stop, start, restart) * Cleanup old assignment caching commit * optimize * fix after merge * replace deprecated notempty annotation * cache assignments per sidecar id using just the md5 of an assignment result might leed to sidecars missing out on updates. An assignment is always meant for a single sidecar, caching the entire assignment over all sidecars is wrong. A per sidecar cache entry allows more fine grained cache invalidations. Bump the cacheMaxSize to 5000 entries, which is a more meaningful number, considered that this might have to hold all sidecars. * Refactor sidecar register() call. Only update the tagged assignments when we miss the cache. * change cache naming * more refactoring * improve cache invalidation * use fancy java Co-authored-by: Othello Maurer * Add more cases for tag invalidation and fix tests * add tags to Config summary * Generate EntityTags with a better hash algorithm As @thll noticed, Object.hashCode() is prone to having collisions * Handle manual assignments via API * rename * Make tags available as config variables. This allows configs to be written with conditionals. E.g.: ``` <#if sidecar.tags.apache??> - /var/log/apache/*.log ``` * dont lose tags on UI configuration updates Co-authored-by: Othello Maurer * renamed btn Assign Configurations * fix wrong postion of ColorLabel * disable eslint errors * added ModalSubTitle styling * Sidecar tags tests (#13502) * Only assign tagged configs that match the OS * rename test * add tests for SidecarService.updateTaggedConfigurationAssignments() * add tests for SidecarService.applyManualAssignments() * Fix cache invalidation for collector actions (#13504) * Fix cache invalidation for collector actions The registration cache was using the sidecar "_id", while the ActionService was invalidating the sidecar "node_id". Change the entire cache to using the "node_id" * Rename parameter from sidecarId to sidecarNodeId Co-authored-by: Othello Maurer * Invalidate registration cache only for affected sidecars (#13514) * Invalidate registration cache only for affected sidecars * Use node-id for cache invalidation The cache key has been changed since this PR has been started. * Consider OS for cache invalidation Also use symmetric difference of tags to further reduce unnecessary invalidations * Invalidate less when creating new configs * added logic to CollectorConfigurationModal to support multiple sidecars * onReset resets the search form too * updated confirmConfigurationChange fnct to support multiple sidecars * simplify Configuration summary when multiple sidecars are selected * finalized confirmation summary * fixed exit dialog by ESC key bug * Add description for tag variables * added Configuration Tags field in Collector Configuration page * prototype: implement auto assigned tags in config selection modal * finalized tags UI implementation * fix linter issues * fix sidecars.node_name type error * handled case assigned_from_tags with multiple sidecarse * hide close icon btn when isAssignedFromTags * fixed TemplatesHelper linter issues * cleanup * split CollectorConfigurationModal into 2 components to reduce complexity * small css changes * added tests for CollectorConfigurationModal * fixed too long logic lines * avoid single charachter variables * fixed Missing semicolons * removed React. in hooks * RegExp instance set into a variable * destruct props * used styled components * css styled component changes * destruct CollectorsAdministrationActions props * convert CollectorProcessControl to typescript and using React functional component * removed React. in hooks * convert CollectorsAdministration to typescript and using React functional component * reset file * convert CollectorsAdministrationActions to typescript and using React functional component * added types * fix eslint issues * typed CollectorProcessControl props * typed CollectorsAdministration props * typed CollectorsAdministrationActions props * typed function args and states * converted ConfigurationTagsSelect to typescript and functional component * converted TemplatesHelper to typescript and functional component * converted ConfigurationForm to typescript and functional component * removed unused function * fixed config creation form * removed read only from types * used Proptypes.shape for configuration * fixed console error in SidecarNewConfigurationPage * added type SidecarCollectorPairType * make sure the filterQuery handles case error * made tags field required * ColorLabel using className instead of inline style * converted inline-styling to styled-component in SearchForm * converted CollectorsAdministration.css into styled-components * addedstyled component for ColorLabel * added SidecarCollectorConfigurationFacade.java changes Co-authored-by: Marco Pfatschbacher Co-authored-by: Othello Maurer --- .../common/SidecarPluginConfiguration.java | 2 +- .../V20180212165000_AddDefaultCollectors.java | 28 +- .../sidecar/rest/models/CollectorStatus.java | 13 +- .../sidecar/rest/models/Configuration.java | 23 +- .../rest/models/ConfigurationSummary.java | 13 +- .../sidecar/rest/models/NodeDetails.java | 16 +- .../plugins/sidecar/rest/models/Sidecar.java | 4 +- .../rest/requests/BulkActionRequest.java | 6 +- .../requests/ConfigurationAssignment.java | 11 +- .../rest/resources/CollectorResource.java | 27 +- .../rest/resources/ConfigurationResource.java | 58 ++- .../ConfigurationVariableResource.java | 4 +- .../rest/resources/SidecarResource.java | 59 ++- .../sidecar/services/ActionService.java | 10 +- .../services/ConfigurationService.java | 34 +- .../services/EtagCacheInvalidation.java | 11 +- .../plugins/sidecar/services/EtagService.java | 118 ++++- .../sidecar/services/SidecarService.java | 98 +++- .../SidecarCollectorConfigurationFacade.java | 5 +- .../collectors/ConfigurationServiceTest.java | 4 +- .../collectors/SidecarServiceImplTest.java | 162 ------- .../collectors/SidecarServiceTest.java | 381 ++++++++++++++++ .../collectors/rest/SidecarResourceTest.java | 47 +- .../collectors/collectorsSingleDataset.json | 14 - .../plugins/sidecar/collectors/sidecars.json | 16 + .../src/components/common/SearchForm.jsx | 60 ++- .../CollectorConfigurationModal.test.tsx | 124 ++++++ .../CollectorConfigurationModal.tsx | 262 +++++++++++ .../CollectorConfigurationModalContainer.tsx | 210 +++++++++ .../CollectorConfigurationSelector.jsx | 175 -------- .../CollectorProcessControl.jsx | 151 ------- .../CollectorProcessControl.tsx | 144 ++++++ .../CollectorsAdministration.css | 35 -- .../CollectorsAdministration.jsx | 379 ---------------- .../CollectorsAdministration.tsx | 418 ++++++++++++++++++ .../CollectorsAdministrationActions.jsx | 50 --- .../CollectorsAdministrationActions.tsx | 83 ++++ .../components/sidecars/common/ColorLabel.tsx | 9 +- .../configuration-forms/ConfigurationForm.jsx | 378 ---------------- .../configuration-forms/ConfigurationForm.tsx | 386 ++++++++++++++++ .../ConfigurationTagsSelect.tsx | 53 +++ .../configuration-forms/TemplatesHelper.jsx | 60 --- .../configuration-forms/TemplatesHelper.tsx | 68 +++ .../src/components/sidecars/types.d.ts | 99 +++++ .../pages/SidecarEditConfigurationPage.jsx | 120 ----- .../pages/SidecarEditConfigurationPage.tsx | 99 +++++ .../src/pages/SidecarNewConfigurationPage.jsx | 75 ---- .../src/pages/SidecarNewConfigurationPage.tsx | 56 +++ 48 files changed, 2871 insertions(+), 1787 deletions(-) delete mode 100644 graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceImplTest.java create mode 100644 graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceTest.java delete mode 100644 graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/collectorsSingleDataset.json create mode 100644 graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/sidecars.json create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.test.tsx create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.tsx create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModalContainer.tsx delete mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationSelector.jsx delete mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.jsx create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.tsx delete mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.css delete mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.jsx create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.tsx delete mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.jsx create mode 100644 graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.tsx delete mode 100644 graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.jsx create mode 100644 graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.tsx create mode 100644 graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationTagsSelect.tsx delete mode 100644 graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.jsx create mode 100644 graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.tsx create mode 100644 graylog2-web-interface/src/components/sidecars/types.d.ts delete mode 100644 graylog2-web-interface/src/pages/SidecarEditConfigurationPage.jsx create mode 100644 graylog2-web-interface/src/pages/SidecarEditConfigurationPage.tsx delete mode 100644 graylog2-web-interface/src/pages/SidecarNewConfigurationPage.jsx create mode 100644 graylog2-web-interface/src/pages/SidecarNewConfigurationPage.tsx diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/common/SidecarPluginConfiguration.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/common/SidecarPluginConfiguration.java index a3c54a8aa141..4f880c84a172 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/common/SidecarPluginConfiguration.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/common/SidecarPluginConfiguration.java @@ -33,7 +33,7 @@ public class SidecarPluginConfiguration implements PluginConfigBean { private Duration cacheTime = Duration.hours(1L); @Parameter(value = PREFIX + "cache_max_size", validator = PositiveIntegerValidator.class) - private int cacheMaxSize = 100; + private int cacheMaxSize = 5000; public Duration getCacheTime() { return cacheTime; diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/migrations/V20180212165000_AddDefaultCollectors.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/migrations/V20180212165000_AddDefaultCollectors.java index dae8ef1dbdff..98d2b65bd702 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/migrations/V20180212165000_AddDefaultCollectors.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/migrations/V20180212165000_AddDefaultCollectors.java @@ -79,8 +79,8 @@ public void upgrade() { "output.logstash:\n" + " hosts: [\"192.168.1.1:5044\"]\n" + "path:\n" + - " data: /var/lib/graylog-sidecar/collectors/filebeat/data\n" + - " logs: /var/lib/graylog-sidecar/collectors/filebeat/log" + " data: ${sidecar.spoolDir}/data\n" + + " logs: ${sidecar.spoolDir}/log" ); ensureCollector( "filebeat", @@ -98,8 +98,8 @@ public void upgrade() { "output.logstash:\n" + " hosts: [\"192.168.1.1:5044\"]\n" + "path:\n" + - " data: /var/lib/graylog-sidecar/collectors/filebeat/data\n" + - " logs: /var/lib/graylog-sidecar/collectors/filebeat/log" + " data: ${sidecar.spoolDir}/data\n" + + " logs: ${sidecar.spoolDir}/log" ); ensureCollector( "filebeat", @@ -117,8 +117,8 @@ public void upgrade() { "output.logstash:\n" + " hosts: [\"192.168.1.1:5044\"]\n" + "path:\n" + - " data: /var/lib/graylog-sidecar/collectors/filebeat/data\n" + - " logs: /var/lib/graylog-sidecar/collectors/filebeat/log" + " data: ${sidecar.spoolDir}/data\n" + + " logs: ${sidecar.spoolDir}/log" ); ensureCollector( "winlogbeat", @@ -131,8 +131,8 @@ public void upgrade() { "output.logstash:\n" + " hosts: [\"192.168.1.1:5044\"]\n" + "path:\n" + - " data: C:\\Program Files\\Graylog\\sidecar\\cache\\winlogbeat\\data\n" + - " logs: C:\\Program Files\\Graylog\\sidecar\\logs\n" + + " data: ${sidecar.spoolDir}\\data\n" + + " logs: ${sidecar.spoolDir}\\logs\n" + "tags:\n" + " - windows\n" + "winlogbeat:\n" + @@ -164,9 +164,9 @@ public void upgrade() { "Group nxlog\n" + "\n" + "Moduledir /usr/lib/nxlog/modules\n" + - "CacheDir /var/spool/nxlog/data\n" + - "PidFile /var/run/nxlog/nxlog.pid\n" + - "LogFile /var/log/nxlog/nxlog.log\n" + + "CacheDir ${sidecar.spoolDir}/data\n" + + "PidFile ${sidecar.spoolDir}/nxlog.pid\n" + + "LogFile ${sidecar.spoolDir}/nxlog.log\n" + "LogLevel INFO\n" + "\n" + "\n" + @@ -217,7 +217,7 @@ public void upgrade() { "C:\\Program Files (x86)\\nxlog\\nxlog.exe", "-c \"%s\"", "-v -f -c \"%s\"", - "define ROOT C:\\Program Files (x86)\\nxlog\n" + + "define ROOT ${sidecar.spoolDir}\\nxlog\n" + "\n" + "Moduledir %ROOT%\\modules\n" + "CacheDir %ROOT%\\data\n" + @@ -302,8 +302,8 @@ public void upgrade() { "output.logstash:\n" + " hosts: [\"192.168.1.1:5044\"]\n" + "path:\n" + - " data: C:\\Program Files\\Graylog\\sidecar\\cache\\filebeat\\data\n" + - " logs: C:\\Program Files\\Graylog\\sidecar\\logs\n" + + " data: ${sidecar.spoolDir}\\data\n" + + " logs: ${sidecar.spoolDir}\\logs\n" + "tags:\n" + " - windows\n" + "filebeat.inputs:\n" + diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/CollectorStatus.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/CollectorStatus.java index 650b5cfa0ca0..551cce983654 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/CollectorStatus.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/CollectorStatus.java @@ -21,6 +21,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + @AutoValue @JsonAutoDetect public abstract class CollectorStatus { @@ -36,11 +38,16 @@ public abstract class CollectorStatus { @JsonProperty("verbose_message") public abstract String verboseMessage(); + @JsonProperty("configuration_id") + @Nullable + public abstract String configurationId(); + @JsonCreator public static CollectorStatus create(@JsonProperty("collector_id") String collectorId, @JsonProperty("status") int status, @JsonProperty("message") String message, - @JsonProperty("verbose_message") String verboseMessage) { - return new AutoValue_CollectorStatus(collectorId, status, message, verboseMessage); + @JsonProperty("verbose_message") String verboseMessage, + @JsonProperty("configuration_id") @Nullable String configurationId) { + return new AutoValue_CollectorStatus(collectorId, status, message, verboseMessage, configurationId); } -} \ No newline at end of file +} diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Configuration.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Configuration.java index ff7238efee70..78f0434a971e 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Configuration.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Configuration.java @@ -25,6 +25,7 @@ import org.mongojack.ObjectId; import javax.annotation.Nullable; +import java.util.Set; @AutoValue @WithBeanGetter @@ -35,6 +36,7 @@ public abstract class Configuration { public static final String FIELD_NAME = "name"; public static final String FIELD_COLOR = "color"; public static final String FIELD_TEMPLATE = "template"; + public static final String FIELD_TAGS = "tags"; @Id @ObjectId @@ -54,30 +56,37 @@ public abstract class Configuration { @JsonProperty(FIELD_TEMPLATE) public abstract String template(); + @JsonProperty(FIELD_TAGS) + public abstract Set tags(); + @JsonCreator public static Configuration create(@JsonProperty(FIELD_ID) String id, @JsonProperty(FIELD_COLLECTOR_ID) String collectorId, @JsonProperty(FIELD_NAME) String name, @JsonProperty(FIELD_COLOR) String color, - @JsonProperty(FIELD_TEMPLATE) String template) { + @JsonProperty(FIELD_TEMPLATE) String template, + @JsonProperty(FIELD_TAGS) @Nullable Set tags) { return builder() .id(id) .collectorId(collectorId) .name(name) .color(color) .template(template) + .tags(tags == null ? Set.of() : tags) .build(); } - public static Configuration create(String collectorId, - String name, - String color, - String template) { + public static Configuration createWithoutId(String collectorId, + String name, + String color, + String template, + Set tags) { return create(new org.bson.types.ObjectId().toHexString(), collectorId, name, color, - template); + template, + tags); } public static Builder builder() { @@ -98,6 +107,8 @@ public abstract static class Builder { public abstract Builder template(String template); + public abstract Builder tags(Set tags); + public abstract Configuration build(); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/ConfigurationSummary.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/ConfigurationSummary.java index 4a1f407fb0e9..7d88d65e5baf 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/ConfigurationSummary.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/ConfigurationSummary.java @@ -22,6 +22,8 @@ import org.mongojack.Id; import org.mongojack.ObjectId; +import java.util.Set; + @AutoValue public abstract class ConfigurationSummary { @JsonProperty("id") @@ -38,12 +40,16 @@ public abstract class ConfigurationSummary { @JsonProperty("color") public abstract String color(); + @JsonProperty("tags") + public abstract Set tags(); + @JsonCreator public static ConfigurationSummary create(@JsonProperty("id") @Id @ObjectId String id, @JsonProperty("name") String name, @JsonProperty("collector_id") String collectorId, - @JsonProperty("color") String color) { - return new AutoValue_ConfigurationSummary(id, name, collectorId, color); + @JsonProperty("color") String color, + @JsonProperty("tags") Set tags) { + return new AutoValue_ConfigurationSummary(id, name, collectorId, color, tags); } public static ConfigurationSummary create(Configuration configuration) { @@ -51,7 +57,8 @@ public static ConfigurationSummary create(Configuration configuration) { configuration.id(), configuration.name(), configuration.collectorId(), - configuration.color()); + configuration.color(), + configuration.tags()); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/NodeDetails.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/NodeDetails.java index 202825c47d71..9418b7faeab4 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/NodeDetails.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/NodeDetails.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; @@ -25,9 +26,11 @@ import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; import java.util.List; +import java.util.Set; @AutoValue @JsonAutoDetect +@JsonIgnoreProperties(ignoreUnknown = true) public abstract class NodeDetails { @JsonProperty("operating_system") @NotNull @@ -50,12 +53,21 @@ public abstract class NodeDetails { @Nullable public abstract CollectorStatusList statusList(); + @JsonProperty("tags") + public abstract Set tags(); + + @JsonProperty("collector_configuration_directory") + @Nullable + public abstract String collectorConfigurationDirectory(); + @JsonCreator public static NodeDetails create(@JsonProperty("operating_system") String operatingSystem, @JsonProperty("ip") @Nullable String ip, @JsonProperty("metrics") @Nullable NodeMetrics metrics, @JsonProperty("log_file_list") @Nullable List logFileList, - @JsonProperty("status") @Nullable CollectorStatusList statusList) { - return new AutoValue_NodeDetails(operatingSystem, ip, metrics, logFileList, statusList); + @JsonProperty("status") @Nullable CollectorStatusList statusList, + @JsonProperty("tags") @Nullable Set tags, + @JsonProperty("collector_configuration_directory") @Nullable String configDir) { + return new AutoValue_NodeDetails(operatingSystem, ip, metrics, logFileList, statusList, tags == null ? Set.of() : tags, configDir); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Sidecar.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Sidecar.java index 76777511cb5d..bfb4909d597c 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Sidecar.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/models/Sidecar.java @@ -92,7 +92,6 @@ public static Status fromString(String statusString) { public abstract NodeDetails nodeDetails(); @JsonProperty - @Nullable public abstract List assignments(); @JsonProperty @@ -133,7 +132,7 @@ public static Sidecar create(@JsonProperty(FIELD_ID) @Id @ObjectId String id, .nodeId(nodeId) .nodeName(nodeName) .nodeDetails(nodeDetails) - .assignments(assignments) + .assignments(assignments == null ? List.of() : assignments) .sidecarVersion(sidecarVersion) .lastSeen(lastSeen) .build(); @@ -151,6 +150,7 @@ public static Sidecar create(@JsonProperty(FIELD_NODE_ID) String nodeId, .nodeDetails(nodeDetails) .sidecarVersion(sidecarVersion) .lastSeen(DateTime.now(DateTimeZone.UTC)) + .assignments(List.of()) .build(); } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/BulkActionRequest.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/BulkActionRequest.java index 37c3f6f42cfb..594c3218cc51 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/BulkActionRequest.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/BulkActionRequest.java @@ -33,8 +33,8 @@ public abstract class BulkActionRequest { public abstract List collectorIds(); @JsonCreator - public static BulkActionRequest create(@JsonProperty("sidecar_id") String collectorId, + public static BulkActionRequest create(@JsonProperty("sidecar_id") String sidecarId, @JsonProperty("collector_ids") List collectorIds) { - return new AutoValue_BulkActionRequest(collectorId, collectorIds); + return new AutoValue_BulkActionRequest(sidecarId, collectorIds); } -} \ No newline at end of file +} diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/ConfigurationAssignment.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/ConfigurationAssignment.java index 1aa264919aaa..ee14796e310e 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/ConfigurationAssignment.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/requests/ConfigurationAssignment.java @@ -21,6 +21,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; +import java.util.Set; + @AutoValue @JsonAutoDetect public abstract class ConfigurationAssignment { @@ -30,9 +33,13 @@ public abstract class ConfigurationAssignment { @JsonProperty public abstract String configurationId(); + @JsonProperty + public abstract Set assignedFromTags(); + @JsonCreator public static ConfigurationAssignment create(@JsonProperty("collector_id") String collectorId, - @JsonProperty("configuration_id") String configurationId) { - return new AutoValue_ConfigurationAssignment(collectorId, configurationId); + @JsonProperty("configuration_id") String configurationId, + @JsonProperty("assigned_from_tags") @Nullable Set assignedFromTags) { + return new AutoValue_ConfigurationAssignment(collectorId, configurationId, assignedFromTags == null ? Set.of() : assignedFromTags); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/CollectorResource.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/CollectorResource.java index e35ffe32904f..517e4ed25076 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/CollectorResource.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/CollectorResource.java @@ -17,9 +17,9 @@ package org.graylog.plugins.sidecar.rest.resources; import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.google.common.hash.Hashing; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; @@ -136,15 +136,15 @@ public Collector getCollector(@ApiParam(name = "id", required = true) @RequiresPermissions(SidecarRestPermissions.COLLECTORS_READ) @Produces(MediaType.APPLICATION_JSON) @ApiOperation(value = "List all collectors") - public Response listCollectors(@Context HttpHeaders httpHeaders) { + public Response listCollectors(@Context HttpHeaders httpHeaders) throws JsonProcessingException { String ifNoneMatch = httpHeaders.getHeaderString("If-None-Match"); Boolean etagCached = false; Response.ResponseBuilder builder = Response.noContent(); - // check if client is up to date with a known valid etag + // check if client is up-to-date with a known valid etag if (ifNoneMatch != null) { EntityTag etag = new EntityTag(ifNoneMatch.replaceAll("\"", "")); - if (etagService.isPresent(etag.toString())) { + if (etagService.collectorsAreCached(etag.toString())) { etagCached = true; builder = Response.notModified(); builder.tag(etag); @@ -157,12 +157,10 @@ public Response listCollectors(@Context HttpHeaders httpHeaders) { CollectorListResponse collectorListResponse = CollectorListResponse.create(result.size(), result); // add new etag to cache - String etagString = collectorsToEtag(collectorListResponse); - - EntityTag collectorsEtag = new EntityTag(etagString); + EntityTag collectorsEtag = etagService.buildEntityTagForResponse(collectorListResponse); builder = Response.ok(collectorListResponse); builder.tag(collectorsEtag); - etagService.put(collectorsEtag.toString()); + etagService.registerCollector(collectorsEtag.toString()); } // set cache control @@ -211,7 +209,7 @@ public Response createCollector(@ApiParam(name = "JSON body", required = true) if (validationResult.failed()) { return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build(); } - etagService.invalidateAll(); + etagService.invalidateAllCollectors(); return Response.ok().entity(collectorService.save(collector)).build(); } @@ -230,7 +228,7 @@ public Response updateCollector(@ApiParam(name = "id", required = true) if (validationResult.failed()) { return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build(); } - etagService.invalidateAll(); + etagService.invalidateAllCollectors(); return Response.ok().entity(collectorService.save(collector)).build(); } @@ -248,8 +246,8 @@ public Response copyCollector(@ApiParam(name = "id", required = true) if (validationResult.failed()) { return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build(); } - etagService.invalidateAll(); collectorService.save(collector); + etagService.invalidateAllCollectors(); return Response.accepted().build(); } @@ -272,7 +270,7 @@ public Response deleteCollector(@ApiParam(name = "id", required = true) if (deleted == 0) { return Response.notModified().build(); } - etagService.invalidateAll(); + etagService.invalidateAllCollectors(); return Response.accepted().build(); } @@ -351,9 +349,4 @@ private boolean validatePath(String path) { return VALID_PATH_PATTERN.matcher(path).matches(); } - private String collectorsToEtag(CollectorListResponse collectors) { - return Hashing.md5() - .hashInt(collectors.hashCode()) // avoid negative values - .toString(); - } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationResource.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationResource.java index 238f7e2a9bb7..312937e1d101 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationResource.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationResource.java @@ -17,8 +17,9 @@ package org.graylog.plugins.sidecar.rest.resources; import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableMap; -import com.google.common.hash.Hashing; +import com.google.common.collect.Sets; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; @@ -26,6 +27,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions; import org.graylog.plugins.sidecar.audit.SidecarAuditEventTypes; import org.graylog.plugins.sidecar.permissions.SidecarRestPermissions; +import org.graylog.plugins.sidecar.rest.models.Collector; import org.graylog.plugins.sidecar.rest.models.CollectorUpload; import org.graylog.plugins.sidecar.rest.models.Configuration; import org.graylog.plugins.sidecar.rest.models.ConfigurationSummary; @@ -36,6 +38,7 @@ import org.graylog.plugins.sidecar.rest.responses.ConfigurationListResponse; import org.graylog.plugins.sidecar.rest.responses.ConfigurationPreviewRenderResponse; import org.graylog.plugins.sidecar.rest.responses.ConfigurationSidecarsResponse; +import org.graylog.plugins.sidecar.services.CollectorService; import org.graylog.plugins.sidecar.services.ConfigurationService; import org.graylog.plugins.sidecar.services.EtagService; import org.graylog.plugins.sidecar.services.ImportService; @@ -77,6 +80,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -98,6 +102,7 @@ public class ConfigurationResource extends RestResource implements PluginRestRes private final SidecarService sidecarService; private final EtagService etagService; private final ImportService importService; + private final CollectorService collectorService; private final SearchQueryParser searchQueryParser; private static final ImmutableMap SEARCH_FIELD_MAPPING = ImmutableMap.builder() .put("id", SearchQueryField.create(Configuration.FIELD_ID)) @@ -109,12 +114,14 @@ public class ConfigurationResource extends RestResource implements PluginRestRes public ConfigurationResource(ConfigurationService configurationService, SidecarService sidecarService, EtagService etagService, - ImportService importService) { + ImportService importService, + CollectorService collectorService) { this.configurationService = configurationService; this.sidecarService = sidecarService; this.etagService = etagService; this.importService = importService; - this.searchQueryParser = new SearchQueryParser(Configuration.FIELD_NAME, SEARCH_FIELD_MAPPING);; + this.collectorService = collectorService; + this.searchQueryParser = new SearchQueryParser(Configuration.FIELD_NAME, SEARCH_FIELD_MAPPING); } @GET @@ -207,15 +214,15 @@ public Response renderConfiguration(@Context HttpHeaders httpHeaders, @ApiParam(name = "sidecarId", required = true) @PathParam("sidecarId") String sidecarId, @ApiParam(name = "configurationId", required = true) - @PathParam("configurationId") String configurationId) throws RenderTemplateException { + @PathParam("configurationId") String configurationId) throws RenderTemplateException, JsonProcessingException { String ifNoneMatch = httpHeaders.getHeaderString("If-None-Match"); - Boolean etagCached = false; + boolean etagCached = false; Response.ResponseBuilder builder = Response.noContent(); - // check if client is up to date with a known valid etag + // check if client is up-to-date with a known valid etag if (ifNoneMatch != null) { EntityTag etag = new EntityTag(ifNoneMatch.replaceAll("\"", "")); - if (etagService.isPresent(etag.toString())) { + if (etagService.configurationsAreCached(etag.toString())) { etagCached = true; builder = Response.notModified(); builder.tag(etag); @@ -236,13 +243,10 @@ public Response renderConfiguration(@Context HttpHeaders httpHeaders, Configuration collectorConfiguration = this.configurationService.renderConfigurationForCollector(sidecar, configuration); // add new etag to cache - String etagString = configurationToEtag(collectorConfiguration); - - EntityTag collectorConfigurationEtag = new EntityTag(etagString); + EntityTag collectorConfigurationEtag = etagService.buildEntityTagForResponse(collectorConfiguration); builder = Response.ok(collectorConfiguration); builder.tag(collectorConfigurationEtag); - etagService.put(collectorConfigurationEtag.toString()); - + etagService.registerConfiguration(collectorConfigurationEtag.toString()); } // set cache control @@ -283,7 +287,16 @@ public Response createConfiguration(@ApiParam(name = "JSON body", required = tru return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build(); } - return Response.ok().entity(configurationService.save(configuration)).build(); + final Configuration config = configurationService.save(configuration); + if (!config.tags().isEmpty()) { + final String os = Optional.ofNullable(collectorService.find(request.collectorId())) + .map(Collector::nodeOperatingSystem).orElse(""); + sidecarService.findByTagsAndOS(config.tags(), os) + .map(Sidecar::nodeId) + .forEach(etagService::invalidateRegistration); + } + + return Response.ok().entity(config).build(); } @POST @@ -330,7 +343,16 @@ public Response updateConfiguration(@ApiParam(name = "id", required = true) if (validationResult.failed()) { return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build(); } - etagService.invalidateAll(); + etagService.invalidateAllConfigurations(); + + if (! previousConfiguration.tags().equals(updatedConfiguration.tags())) { + final Set tags = Sets.symmetricDifference(previousConfiguration.tags(), updatedConfiguration.tags()); + final String os = Optional.ofNullable(collectorService.find(request.collectorId())) + .map(Collector::nodeOperatingSystem).orElse(""); + sidecarService.findByTagsAndOS(tags, os) + .map(Sidecar::nodeId) + .forEach(etagService::invalidateRegistration); + } return Response.ok().entity(configurationService.save(updatedConfiguration)).build(); } @@ -351,7 +373,7 @@ public Response deleteConfiguration(@ApiParam(name = "id", required = true) if (deleted == 0) { return Response.notModified().build(); } - etagService.invalidateAll(); + etagService.invalidateAllConfigurations(); return Response.accepted().build(); } @@ -405,12 +427,6 @@ private boolean isConfigurationAssignedToSidecar(String configurationId, Sidecar return assignments.stream().anyMatch(assignment -> assignment.configurationId().equals(configurationId)); } - private String configurationToEtag(Configuration configuration) { - return Hashing.md5() - .hashInt(configuration.hashCode()) // avoid negative values - .toString(); - } - private Configuration configurationFromRequest(String id, Configuration request) { Configuration configuration; if (id == null) { diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationVariableResource.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationVariableResource.java index 04ea7c9df735..a1019ecbb27e 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationVariableResource.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/ConfigurationVariableResource.java @@ -132,7 +132,7 @@ public Response updateConfigurationVariable(@ApiParam(name = "id", required = tr configurationService.replaceVariableNames(previousConfigurationVariable.fullName(), request.fullName()); } final ConfigurationVariable updatedConfigurationVariable = persistConfigurationVariable(id, request); - etagService.invalidateAll(); + etagService.invalidateAllConfigurations(); return Response.ok().entity(updatedConfigurationVariable).build(); } @@ -168,7 +168,7 @@ public Response deleteConfigurationVariable(@ApiParam(name = "id", required = tr if (deleted == 0) { return Response.notModified().build(); } - etagService.invalidateAll(); + etagService.invalidateAllConfigurations(); return Response.accepted().build(); } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/SidecarResource.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/SidecarResource.java index 74f5b7b473a0..b91e2081dbc3 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/SidecarResource.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/rest/resources/SidecarResource.java @@ -17,6 +17,7 @@ package org.graylog.plugins.sidecar.rest.resources; import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.base.Function; import com.google.common.collect.ImmutableMap; import io.swagger.annotations.Api; @@ -42,6 +43,7 @@ import org.graylog.plugins.sidecar.rest.responses.RegistrationResponse; import org.graylog.plugins.sidecar.rest.responses.SidecarListResponse; import org.graylog.plugins.sidecar.services.ActionService; +import org.graylog.plugins.sidecar.services.EtagService; import org.graylog.plugins.sidecar.services.SidecarService; import org.graylog.plugins.sidecar.system.SidecarConfiguration; import org.graylog2.audit.jersey.AuditEvent; @@ -53,12 +55,12 @@ import org.graylog2.search.SearchQueryField; import org.graylog2.search.SearchQueryParser; import org.graylog2.shared.rest.resources.RestResource; -import org.hibernate.validator.constraints.NotEmpty; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import javax.inject.Inject; import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; @@ -71,6 +73,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; +import javax.ws.rs.core.EntityTag; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.List; @@ -99,6 +102,7 @@ public class SidecarResource extends RestResource implements PluginRestResource private final SidecarService sidecarService; private final ActionService actionService; + private final EtagService etagService; private final ActiveSidecarFilter activeSidecarFilter; private final SearchQueryParser searchQueryParser; private final SidecarStatusMapper sidecarStatusMapper; @@ -108,12 +112,14 @@ public class SidecarResource extends RestResource implements PluginRestResource public SidecarResource(SidecarService sidecarService, ActionService actionService, ClusterConfigService clusterConfigService, - SidecarStatusMapper sidecarStatusMapper) { + SidecarStatusMapper sidecarStatusMapper, + EtagService etagService) { this.sidecarService = sidecarService; this.sidecarConfiguration = clusterConfigService.getOrDefault(SidecarConfiguration.class, SidecarConfiguration.defaultConfiguration()); this.actionService = actionService; this.activeSidecarFilter = new ActiveSidecarFilter(sidecarConfiguration.sidecarInactiveThreshold()); this.sidecarStatusMapper = sidecarStatusMapper; + this.etagService = etagService; this.searchQueryParser = new SearchQueryParser(Sidecar.FIELD_NODE_NAME, SEARCH_FIELD_MAPPING); } @@ -195,39 +201,55 @@ public SidecarSummary get(@ApiParam(name = "sidecarId", required = true) @RequiresPermissions(SidecarRestPermissions.SIDECARS_UPDATE) @NoAuditEvent("this is only a ping from Sidecars, and would overflow the audit log") public Response register(@ApiParam(name = "sidecarId", value = "The id this Sidecar is registering as.", required = true) - @PathParam("sidecarId") @NotEmpty String sidecarId, + @PathParam("sidecarId") @NotEmpty String nodeId, @ApiParam(name = "JSON body", required = true) @Valid @NotNull RegistrationRequest request, - @HeaderParam(value = "X-Graylog-Sidecar-Version") @NotEmpty String sidecarVersion) { - final Sidecar newSidecar; - final Sidecar oldSidecar = sidecarService.findByNodeId(sidecarId); - List assignments = null; + @HeaderParam(value = "If-None-Match") String ifNoneMatch, + @HeaderParam(value = "X-Graylog-Sidecar-Version") @NotEmpty String sidecarVersion) throws JsonProcessingException { + + Sidecar sidecar; + final Sidecar oldSidecar = sidecarService.findByNodeId(nodeId); if (oldSidecar != null) { - assignments = oldSidecar.assignments(); - newSidecar = oldSidecar.toBuilder() + sidecar = oldSidecar.toBuilder() .nodeName(request.nodeName()) .nodeDetails(request.nodeDetails()) .sidecarVersion(sidecarVersion) .lastSeen(DateTime.now(DateTimeZone.UTC)) .build(); } else { - newSidecar = sidecarService.fromRequest(sidecarId, request, sidecarVersion); + sidecar = sidecarService.fromRequest(nodeId, request, sidecarVersion); } - sidecarService.save(newSidecar); - final CollectorActions collectorActions = actionService.findActionBySidecar(sidecarId, true); + // If the sidecar has the recent registration, return with HTTP 304 + if (ifNoneMatch != null) { + EntityTag etag = new EntityTag(ifNoneMatch.replaceAll("\"", "")); + if (etagService.registrationIsCached(sidecar.nodeId(), etag.toString())) { + sidecarService.save(sidecar); + return Response.notModified().tag(etag).build(); + } + } + + final Sidecar updated = sidecarService.updateTaggedConfigurationAssignments(sidecar); + sidecarService.save(updated); + sidecar = updated; + + final CollectorActions collectorActions = actionService.findActionBySidecar(nodeId, true); List collectorAction = null; if (collectorActions != null) { collectorAction = collectorActions.action(); } RegistrationResponse sidecarRegistrationResponse = RegistrationResponse.create( SidecarRegistrationConfiguration.create( - this.sidecarConfiguration.sidecarUpdateInterval().toStandardDuration().getStandardSeconds(), - this.sidecarConfiguration.sidecarSendStatus()), - this.sidecarConfiguration.sidecarConfigurationOverride(), + sidecarConfiguration.sidecarUpdateInterval().toStandardDuration().getStandardSeconds(), + sidecarConfiguration.sidecarSendStatus()), + sidecarConfiguration.sidecarConfigurationOverride(), collectorAction, - assignments); - return Response.accepted(sidecarRegistrationResponse).build(); + sidecar.assignments()); + // add new etag to cache + EntityTag registrationEtag = etagService.buildEntityTagForResponse(sidecarRegistrationResponse); + etagService.addSidecarRegistration(sidecar.nodeId(), registrationEtag.toString()); + + return Response.accepted(sidecarRegistrationResponse).tag(registrationEtag).build(); } @PUT @@ -249,8 +271,9 @@ public Response assignConfiguration(@ApiParam(name = "JSON body", required = tru .flatMap(a -> a.assignments().stream()) .collect(Collectors.toList()); try { - Sidecar sidecar = sidecarService.assignConfiguration(nodeId, nodeRelations); + Sidecar sidecar = sidecarService.applyManualAssignments(nodeId, nodeRelations); sidecarService.save(sidecar); + etagService.invalidateRegistration(sidecar.nodeId()); } catch (org.graylog2.database.NotFoundException e) { throw new NotFoundException(e.getMessage()); } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ActionService.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ActionService.java index 91fd257118a9..9167adb44edd 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ActionService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ActionService.java @@ -35,9 +35,13 @@ public class ActionService { private static final String COLLECTION_NAME = "sidecar_collector_actions"; private final JacksonDBCollection dbCollection; + private final EtagService etagService; + @Inject public ActionService(MongoConnection mongoConnection, - MongoJackObjectMapperProvider mapper){ + MongoJackObjectMapperProvider mapper, + EtagService etagService){ + this.etagService = etagService; dbCollection = JacksonDBCollection.wrap( mongoConnection.getDatabase().getCollection(COLLECTION_NAME), CollectorActions.class, @@ -70,7 +74,7 @@ public CollectorActions fromRequest(String sidecarId, List acti } public CollectorActions saveAction(CollectorActions collectorActions) { - return dbCollection.findAndModify( + final CollectorActions actions = dbCollection.findAndModify( DBQuery.is("sidecar_id", collectorActions.sidecarId()), new BasicDBObject(), new BasicDBObject(), @@ -78,6 +82,8 @@ public CollectorActions saveAction(CollectorActions collectorActions) { collectorActions, true, true); + etagService.invalidateRegistration(collectorActions.sidecarId()); + return actions; } public CollectorActions findActionBySidecar(String sidecarId, boolean remove) { diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ConfigurationService.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ConfigurationService.java index 321eacc10702..da1ef990d41a 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ConfigurationService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/ConfigurationService.java @@ -17,6 +17,7 @@ package org.graylog.plugins.sidecar.services; import com.mongodb.BasicDBObject; +import com.mongodb.DBCollection; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.StringTemplateLoader; import freemarker.cache.TemplateLoader; @@ -47,6 +48,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -60,7 +62,7 @@ public class ConfigurationService extends PaginatedDbService { private static final freemarker.template.Configuration templateConfiguration = new freemarker.template.Configuration(freemarker.template.Configuration.VERSION_2_3_28); private static final StringTemplateLoader stringTemplateLoader = new StringTemplateLoader(); - private ConfigurationVariableService configurationVariableService; + private final ConfigurationVariableService configurationVariableService; private static final String COLLECTION_NAME = "sidecar_configurations"; @@ -70,6 +72,7 @@ public ConfigurationService(MongoConnection mongoConnection, ConfigurationVariableService configurationVariableService) { super(mongoConnection, mapper, Configuration.class, COLLECTION_NAME); MongoDbTemplateLoader mongoDbTemplateLoader = new MongoDbTemplateLoader(db); + DBCollection collection = db.getDB().getCollection(COLLECTION_NAME); MultiTemplateLoader multiTemplateLoader = new MultiTemplateLoader(new TemplateLoader[] { mongoDbTemplateLoader, stringTemplateLoader }); @@ -79,6 +82,10 @@ public ConfigurationService(MongoConnection mongoConnection, templateConfiguration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); templateConfiguration.setLogTemplateExceptions(false); this.configurationVariableService = configurationVariableService; + + collection.createIndex(new BasicDBObject(Configuration.FIELD_ID, 1)); + collection.createIndex(new BasicDBObject(Configuration.FIELD_COLLECTOR_ID, 1)); + collection.createIndex(new BasicDBObject(Configuration.FIELD_TAGS, 1)); } public Configuration find(String id) { @@ -116,6 +123,10 @@ public List findByConfigurationVariable(ConfigurationVariable con return findByQuery(query); } + public List findByTags(Set tags) { + return findByQuery(DBQuery.in(Configuration.FIELD_TAGS, tags)); + } + public void replaceVariableNames(String oldName, String newName) { final DBQuery.Query query = DBQuery.regex(Configuration.FIELD_TEMPLATE, Pattern.compile(Pattern.quote(oldName))); List configurations = findByQuery(query); @@ -134,15 +145,17 @@ public Configuration save(Configuration configuration) { public Configuration copyConfiguration(String id, String name) { Configuration configuration = find(id); - return Configuration.create(configuration.collectorId(), name, configuration.color(), configuration.template()); + // Tags are not copied on purpose + return Configuration.createWithoutId(configuration.collectorId(), name, configuration.color(), configuration.template(), Set.of()); } public Configuration fromRequest(Configuration request) { - return Configuration.create( + return Configuration.createWithoutId( request.collectorId(), request.name(), request.color(), - request.template()); + request.template(), + request.tags()); } public Configuration fromRequest(String id, Configuration request) { @@ -151,7 +164,8 @@ public Configuration fromRequest(String id, Configuration request) { request.collectorId(), request.name(), request.color(), - request.template()); + request.template(), + request.tags()); } public Configuration renderConfigurationForCollector(Sidecar sidecar, Configuration configuration) throws RenderTemplateException { @@ -161,13 +175,19 @@ public Configuration renderConfigurationForCollector(Sidecar sidecar, Configurat context.put("nodeName", sidecar.nodeName()); context.put("sidecarVersion", sidecar.sidecarVersion()); context.put("operatingSystem", sidecar.nodeDetails().operatingSystem()); + if (sidecar.nodeDetails().collectorConfigurationDirectory() != null) { + String pathDelim = sidecar.nodeDetails().operatingSystem().equalsIgnoreCase("windows") ? "\\" : "/"; + context.put("spoolDir", sidecar.nodeDetails().collectorConfigurationDirectory() + pathDelim + configuration.id()); + } + context.put("tags", sidecar.nodeDetails().tags().stream().collect(Collectors.toMap(t -> t, t -> true))); return Configuration.create( configuration.id(), configuration.collectorId(), configuration.name(), configuration.color(), - renderTemplate(configuration.id(), context) + renderTemplate(configuration.id(), context), + configuration.tags() ); } @@ -177,6 +197,8 @@ public String renderPreview(String template) throws RenderTemplateException { context.put("nodeName", ""); context.put("sidecarVersion", ""); context.put("operatingSystem", ""); + context.put("spoolDir", ""); + context.put("tags", Map.of()); String previewName = UUID.randomUUID().toString(); stringTemplateLoader.putTemplate(previewName, template); diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagCacheInvalidation.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagCacheInvalidation.java index 9d6123dd1dbe..5d249ff8a464 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagCacheInvalidation.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagCacheInvalidation.java @@ -23,12 +23,15 @@ @AutoValue public abstract class EtagCacheInvalidation { - @JsonProperty("etag") - public abstract String etag(); + @JsonProperty("cache_context") + public abstract EtagService.CacheContext cacheContext(); + + @JsonProperty("cache_key") + public abstract String cacheKey(); @JsonCreator - public static EtagCacheInvalidation etag(@JsonProperty("etag") String etag) { - return new AutoValue_EtagCacheInvalidation(etag); + public static EtagCacheInvalidation create(@JsonProperty("cache_context") EtagService.CacheContext context, @JsonProperty("cache_key") String cacheKey) { + return new AutoValue_EtagCacheInvalidation(context, cacheKey); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagService.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagService.java index 489df2c00bcf..8b8b4ecfc360 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/EtagService.java @@ -17,21 +17,29 @@ package org.graylog.plugins.sidecar.services; import com.codahale.metrics.MetricRegistry; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.joschi.jadconfig.util.Duration; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; +import com.google.common.hash.Hashing; import com.google.common.util.concurrent.AbstractIdleService; import org.graylog.plugins.sidecar.common.SidecarPluginConfiguration; import org.graylog2.events.ClusterEventBus; import org.graylog2.metrics.CacheStatsSet; +import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.graylog2.shared.metrics.MetricUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; import javax.inject.Singleton; +import javax.ws.rs.core.EntityTag; +import java.nio.charset.StandardCharsets; import static com.codahale.metrics.MetricRegistry.name; @@ -39,21 +47,48 @@ public class EtagService extends AbstractIdleService { private static final Logger LOG = LoggerFactory.getLogger(EtagService.class); - private final Cache cache; - private MetricRegistry metricRegistry; - private EventBus eventBus; - private ClusterEventBus clusterEventBus; + private final Cache collectorCache; + private final Cache configurationCache; + private final Cache registrationCache; + private final MetricRegistry metricRegistry; + private final EventBus eventBus; + private final ClusterEventBus clusterEventBus; + private final ObjectMapper objectMapper; + + @JsonAutoDetect + enum CacheContext { + @JsonProperty("collector") + COLLECTOR, + @JsonProperty("configuration") + CONFIGURATION, + @JsonProperty("registration") + REGISTRATION + } @Inject public EtagService(SidecarPluginConfiguration pluginConfiguration, MetricRegistry metricRegistry, EventBus eventBus, - ClusterEventBus clusterEventBus) { + ClusterEventBus clusterEventBus, + ObjectMapperProvider objectMapperProvider) { this.metricRegistry = metricRegistry; this.eventBus = eventBus; this.clusterEventBus = clusterEventBus; + this.objectMapper = objectMapperProvider.get(); Duration cacheTime = pluginConfiguration.getCacheTime(); - cache = CacheBuilder.newBuilder() + collectorCache = CacheBuilder.newBuilder() + .recordStats() + .expireAfterWrite(cacheTime.getQuantity(), cacheTime.getUnit()) + .maximumSize(pluginConfiguration.getCacheMaxSize()) + .build(); + + configurationCache = CacheBuilder.newBuilder() + .recordStats() + .expireAfterWrite(cacheTime.getQuantity(), cacheTime.getUnit()) + .maximumSize(pluginConfiguration.getCacheMaxSize()) + .build(); + + registrationCache = CacheBuilder.newBuilder() .recordStats() .expireAfterWrite(cacheTime.getQuantity(), cacheTime.getUnit()) .maximumSize(pluginConfiguration.getCacheMaxSize()) @@ -62,41 +97,84 @@ public EtagService(SidecarPluginConfiguration pluginConfiguration, @Subscribe public void handleEtagInvalidation(EtagCacheInvalidation event) { - if (event.etag().equals("")) { - LOG.trace("Invalidating all collector configuration etags"); + var cache = switch (event.cacheContext()) { + case COLLECTOR -> collectorCache; + case CONFIGURATION -> configurationCache; + case REGISTRATION -> registrationCache; + }; + + if (event.cacheKey().equals("")) { + LOG.trace("Invalidating {} cache for all keys", event.cacheContext()); cache.invalidateAll(); } else { - LOG.trace("Invalidating collector configuration etag {}", event.etag()); - cache.invalidate(event.etag()); + LOG.trace("Invalidating {} cache for cacheKey {}", event.cacheContext(), event.cacheKey()); + cache.invalidate(event.cacheKey()); } } - public boolean isPresent(String etag) { - return cache.getIfPresent(etag) != null; + public boolean collectorsAreCached(String etag) { + return collectorCache.getIfPresent(etag) != null; + } + + public boolean configurationsAreCached(String etag) { + return configurationCache.getIfPresent(etag) != null; + } + + public boolean registrationIsCached(String sidecarNodeId, String etag) { + return etag.equals(registrationCache.getIfPresent(sidecarNodeId)); + } + + public void registerCollector(String etag) { + collectorCache.put(etag, Boolean.TRUE); + } + + public void registerConfiguration(String etag) { + configurationCache.put(etag, Boolean.TRUE); + } + + public void addSidecarRegistration(String sidecarNodeId, String etag) { + registrationCache.put(sidecarNodeId, etag); + } + + + public void invalidateAllConfigurations() { + configurationCache.invalidateAll(); + clusterEventBus.post(EtagCacheInvalidation.create(CacheContext.CONFIGURATION, "")); + } + + public void invalidateAllCollectors() { + collectorCache.invalidateAll(); + clusterEventBus.post(EtagCacheInvalidation.create(CacheContext.COLLECTOR, "")); } - public void put(String etag) { - cache.put(etag, Boolean.TRUE); + public void invalidateAllRegistrations() { + registrationCache.invalidateAll(); + clusterEventBus.post(EtagCacheInvalidation.create(CacheContext.REGISTRATION, "")); } - public void invalidate(String etag) { - clusterEventBus.post(EtagCacheInvalidation.etag(etag)); + public void invalidateRegistration(String sidecarNodeId) { + registrationCache.invalidate(sidecarNodeId); + clusterEventBus.post(EtagCacheInvalidation.create(CacheContext.REGISTRATION, sidecarNodeId)); } - public void invalidateAll() { - cache.invalidateAll(); - clusterEventBus.post(EtagCacheInvalidation.etag("")); + public EntityTag buildEntityTagForResponse(Object o) throws JsonProcessingException { + final String json = objectMapper.writeValueAsString(o); + return new EntityTag(Hashing.murmur3_128().hashString(json, StandardCharsets.UTF_8).toString()); } @Override protected void startUp() throws Exception { eventBus.register(this); - MetricUtils.safelyRegisterAll(metricRegistry, new CacheStatsSet(name(ConfigurationService.class, "etag-cache"), cache)); + MetricUtils.safelyRegisterAll(metricRegistry, new CacheStatsSet(name(ConfigurationService.class, "etag-cache"), configurationCache)); + MetricUtils.safelyRegisterAll(metricRegistry, new CacheStatsSet(name(CollectorService.class, "etag-cache"), collectorCache)); + MetricUtils.safelyRegisterAll(metricRegistry, new CacheStatsSet(name(SidecarService.class, "etag-cache"), registrationCache)); } @Override protected void shutDown() throws Exception { eventBus.unregister(this); metricRegistry.removeMatching((name, metric) -> name.startsWith(name(ConfigurationService.class, "etag-cache"))); + metricRegistry.removeMatching((name, metric) -> name.startsWith(name(CollectorService.class, "etag-cache"))); + metricRegistry.removeMatching((name, metric) -> name.startsWith(name(SidecarService.class, "etag-cache"))); } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/SidecarService.java b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/SidecarService.java index 5646225fdbd3..6871c2aa2cea 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/SidecarService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/sidecar/services/SidecarService.java @@ -16,8 +16,10 @@ */ package org.graylog.plugins.sidecar.services; +import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableSet; import com.mongodb.BasicDBObject; +import org.apache.commons.collections4.CollectionUtils; import org.graylog.plugins.sidecar.rest.models.Collector; import org.graylog.plugins.sidecar.rest.models.CollectorStatus; import org.graylog.plugins.sidecar.rest.models.CollectorStatusList; @@ -42,9 +44,12 @@ import javax.inject.Inject; import javax.validation.ConstraintViolation; import javax.validation.Validator; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Set; import java.util.function.Predicate; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -75,22 +80,52 @@ public long count() { @Override public Sidecar save(Sidecar sidecar) { - if (sidecar != null) { - final Set> violations = validator.validate(sidecar); - if (violations.isEmpty()) { - return db.findAndModify( - DBQuery.is(Sidecar.FIELD_NODE_ID, sidecar.nodeId()), - new BasicDBObject(), - new BasicDBObject(), - false, - sidecar, - true, - true); - } else { - throw new IllegalArgumentException("Specified object failed validation: " + violations); + Preconditions.checkNotNull(sidecar, "sidecar was null"); + + final Set> violations = validator.validate(sidecar); + if (!violations.isEmpty()) { + throw new IllegalArgumentException("Specified object failed validation: " + violations); + } + + return db.findAndModify( + DBQuery.is(Sidecar.FIELD_NODE_ID, sidecar.nodeId()), + new BasicDBObject(), + new BasicDBObject(), + false, + sidecar, + true, + true); + } + + // Create new assignments based on tags and existing manual assignments' + public Sidecar updateTaggedConfigurationAssignments(Sidecar sidecar) { + final Set sidecarTags = sidecar.nodeDetails().tags(); + + // find all configurations that match the tags + final List taggedConfigs = configurationService.findByTags(sidecarTags); + final Set matchingOsCollectorIds = collectorService.all().stream() + .filter(c -> c.nodeOperatingSystem().equalsIgnoreCase(sidecar.nodeDetails().operatingSystem())) + .map(Collector::id).collect(Collectors.toSet()); + + final List tagAssigned = taggedConfigs.stream() + .filter(c -> matchingOsCollectorIds.contains(c.collectorId())).map(c -> { + // fill in ConfigurationAssignment.assignedFromTags() + // If we only support one tag on a configuration, this can be simplified + final Set matchedTags = c.tags().stream().filter(sidecarTags::contains).collect(Collectors.toSet()); + return ConfigurationAssignment.create(c.collectorId(), c.id(), matchedTags); + }).toList(); + + final List manuallyAssigned = sidecar.assignments().stream().filter(a -> { + // also overwrite manually assigned configs that would now be assigned through tags + if (tagAssigned.stream().anyMatch(tagAssignment -> tagAssignment.configurationId().equals(a.configurationId()))) { + return false; } - } else - throw new IllegalArgumentException("Specified object is not of correct implementation type (" + sidecar.getClass() + ")!"); + return a.assignedFromTags().isEmpty(); + }).toList(); + + // return a sidecar with updated assignments + final Collection union = CollectionUtils.union(manuallyAssigned, tagAssigned); + return sidecar.toBuilder().assignments(new ArrayList<>(union)).build(); } public List all() { @@ -156,7 +191,7 @@ public int markExpired(Period period, String message) { collectorStatuses.add(CollectorStatus.create( collectorStatus.collectorId(), Sidecar.Status.UNKNOWN.getStatusCode(), - message, "")); + message, "", collectorStatus.configurationId())); } CollectorStatusList statusListToSave = CollectorStatusList.create( Sidecar.Status.UNKNOWN.getStatusCode(), @@ -168,7 +203,9 @@ public int markExpired(Period period, String message) { nodeDetails.ip(), nodeDetails.metrics(), nodeDetails.logFileList(), - statusListToSave); + statusListToSave, + nodeDetails.tags(), + nodeDetails.collectorConfigurationDirectory()); Sidecar toSave = collector.toBuilder() .nodeDetails(nodeDetailsToSave) @@ -194,14 +231,16 @@ public Sidecar fromRequest(String nodeId, RegistrationRequest request, String co request.nodeDetails().ip(), request.nodeDetails().metrics(), request.nodeDetails().logFileList(), - request.nodeDetails().statusList()), + request.nodeDetails().statusList(), + request.nodeDetails().tags(), + request.nodeDetails().collectorConfigurationDirectory()), collectorVersion); } - public Sidecar assignConfiguration(String collectorNodeId, List assignments) throws NotFoundException{ - Sidecar sidecar = findByNodeId(collectorNodeId); + public Sidecar applyManualAssignments(String sidecarNodeId, List assignments) throws NotFoundException{ + Sidecar sidecar = findByNodeId(sidecarNodeId); if (sidecar == null) { - throw new NotFoundException("Couldn't find collector with ID " + collectorNodeId); + throw new NotFoundException("Couldn't find sidecar with nodeId " + sidecarNodeId); } for (ConfigurationAssignment assignment : assignments) { Collector collector = collectorService.find(assignment.collectorId()); @@ -217,8 +256,16 @@ public Sidecar assignConfiguration(String collectorNodeId, List taggedAssignments = sidecar.assignments().stream().filter(a -> !a.assignedFromTags().isEmpty()).toList(); + final List configIdsAssignedThroughTags = taggedAssignments.stream().map(ConfigurationAssignment::configurationId).toList(); + + final List filteredAssignments = assignments.stream().filter(a -> !configIdsAssignedThroughTags.contains(a.configurationId())).toList(); + final Collection union = CollectionUtils.union(filteredAssignments, taggedAssignments); + Sidecar toSave = sidecar.toBuilder() - .assignments(assignments) + .assignments(new ArrayList<>(union)) .build(); return save(toSave); } @@ -228,4 +275,11 @@ public List toSummaryList(List sidecars, Predicate collector.toSummary(isActiveFunction)) .collect(Collectors.toList()); } + + public Stream findByTagsAndOS(Collection tags, String os) { + return streamQuery(DBQuery.and( + DBQuery.in("node_details.tags", tags), + DBQuery.regex("node_details.operating_system", Pattern.compile("^" + Pattern.quote(os) + "$", Pattern.CASE_INSENSITIVE)) + )); + } } diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/SidecarCollectorConfigurationFacade.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/SidecarCollectorConfigurationFacade.java index c51aa3674e66..77ac28d95057 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/SidecarCollectorConfigurationFacade.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/SidecarCollectorConfigurationFacade.java @@ -101,11 +101,12 @@ private NativeEntity decode(EntityV1 entity, Map ((Collector) collector).id()) .findFirst() .orElseThrow(() -> new IllegalArgumentException(StringUtils.f("Unable to find database ID of Collector with logical ID [%s]", collectorEntityId))); - final Configuration configuration = Configuration.create( + final Configuration configuration = Configuration.createWithoutId( collectorDbId, configurationEntity.title().asString(parameters), configurationEntity.color().asString(parameters), - configurationEntity.template().asString(parameters)); + configurationEntity.template().asString(parameters), + Set.of()); final Configuration savedConfiguration = configurationService.save(configuration); return NativeEntity.create(entity.id(), savedConfiguration.id(), TYPE_V1, configuration.name(), savedConfiguration); diff --git a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/ConfigurationServiceTest.java b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/ConfigurationServiceTest.java index b02c69dd3974..d2c3fed73837 100644 --- a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/ConfigurationServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/ConfigurationServiceTest.java @@ -33,6 +33,8 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import java.util.Set; + import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.when; @@ -56,7 +58,7 @@ public class ConfigurationServiceTest { private Configuration buildTestConfig(String template) { - return Configuration.create(FILEBEAT_CONF_ID, "collId", "filebeat", "#ffffff", template); + return Configuration.create(FILEBEAT_CONF_ID, "collId", "filebeat", "#ffffff", template, Set.of()); } @Before diff --git a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceImplTest.java b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceImplTest.java deleted file mode 100644 index 26f54bca730a..000000000000 --- a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceImplTest.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.plugins.sidecar.collectors; - -import com.mongodb.client.MongoCollection; -import org.bson.Document; -import org.graylog.plugins.sidecar.rest.models.NodeDetails; -import org.graylog.plugins.sidecar.rest.models.Sidecar; -import org.graylog.plugins.sidecar.services.CollectorService; -import org.graylog.plugins.sidecar.services.ConfigurationService; -import org.graylog.plugins.sidecar.services.SidecarService; -import org.graylog.testing.inject.TestPasswordSecretModule; -import org.graylog.testing.mongodb.MongoDBFixtures; -import org.graylog.testing.mongodb.MongoDBInstance; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; -import org.graylog2.shared.bindings.ObjectMapperModule; -import org.graylog2.shared.bindings.ValidatorModule; -import org.jukito.JukitoRunner; -import org.jukito.UseModules; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; - -import javax.validation.Validator; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -@RunWith(JukitoRunner.class) -@UseModules({ObjectMapperModule.class, ValidatorModule.class, TestPasswordSecretModule.class}) -public class SidecarServiceImplTest { - private static final String collectionName = "sidecars"; - @Mock - private CollectorService collectorService; - - @Mock private ConfigurationService configurationService; - - @Rule - public final MongoDBInstance mongodb = MongoDBInstance.createForClass(); - - private SidecarService sidecarService; - - @Before - public void setUp(MongoJackObjectMapperProvider mapperProvider, - Validator validator) throws Exception { - this.sidecarService = new SidecarService(collectorService, configurationService, mongodb.mongoConnection(), mapperProvider, validator); - } - - @Test - public void testCountEmptyCollection() throws Exception { - final long result = this.sidecarService.count(); - - assertEquals(0, result); - } - - @Test - @MongoDBFixtures("collectorsMultipleDocuments.json") - public void testCountNonEmptyCollection() throws Exception { - final long result = this.sidecarService.count(); - - assertEquals(3, result); - } - - @Test - public void testSaveFirstRecord() throws Exception { - String nodeId = "nodeId"; - String nodeName = "nodeName"; - String version = "0.0.1"; - String os = "DummyOS 1.0"; - final Sidecar sidecar = Sidecar.create( - nodeId, - nodeName, - NodeDetails.create( - os, - null, - null, - null, - null), - version - ); - - final Sidecar result = this.sidecarService.save(sidecar); - MongoCollection collection = mongodb.mongoConnection().getMongoDatabase().getCollection(collectionName); - Document document = collection.find().first(); - Document nodeDetails = document.get("node_details", Document.class); - - assertNotNull(result); - assertEquals(nodeId, document.get("node_id")); - assertEquals(nodeName, document.get("node_name")); - assertEquals(version, document.get("sidecar_version")); - assertEquals(os, nodeDetails.get("operating_system")); - } - - @Test - @MongoDBFixtures("collectorsMultipleDocuments.json") - public void testAll() throws Exception { - final List sidecars = this.sidecarService.all(); - - assertNotNull(sidecars); - assertEquals(3, sidecars.size()); - } - - @Test - public void testAllEmptyCollection() throws Exception { - final List sidecars = this.sidecarService.all(); - - assertNotNull(sidecars); - assertEquals(0, sidecars.size()); - } - - @Test - @MongoDBFixtures("collectorsMultipleDocuments.json") - public void testFindById() throws Exception { - final String collector1id = "uniqueid1"; - - final Sidecar sidecar = this.sidecarService.findByNodeId(collector1id); - - assertNotNull(sidecar); - assertEquals(collector1id, sidecar.nodeId()); - } - - @Test - @MongoDBFixtures("collectorsMultipleDocuments.json") - public void testFindByIdNonexisting() throws Exception { - final String collector1id = "nonexisting"; - - final Sidecar sidecar = this.sidecarService.findByNodeId(collector1id); - - assertNull(sidecar); - } - - @Test - @MongoDBFixtures("collectorsMultipleDocuments.json") - public void testDestroy() throws Exception { - final Sidecar sidecar = mock(Sidecar.class); - when(sidecar.id()).thenReturn("581b3bff8e4dc4270055dfcb"); - - final int result = this.sidecarService.delete(sidecar.id()); - assertEquals(1, result); - assertEquals(2, mongodb.mongoConnection().getMongoDatabase().getCollection(collectionName).countDocuments()); - } -} diff --git a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceTest.java b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceTest.java new file mode 100644 index 000000000000..5240e1753ba5 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/SidecarServiceTest.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.plugins.sidecar.collectors; + +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.graylog.plugins.sidecar.rest.models.Collector; +import org.graylog.plugins.sidecar.rest.models.Configuration; +import org.graylog.plugins.sidecar.rest.models.NodeDetails; +import org.graylog.plugins.sidecar.rest.models.Sidecar; +import org.graylog.plugins.sidecar.rest.requests.ConfigurationAssignment; +import org.graylog.plugins.sidecar.services.CollectorService; +import org.graylog.plugins.sidecar.services.ConfigurationService; +import org.graylog.plugins.sidecar.services.SidecarService; +import org.graylog.testing.inject.TestPasswordSecretModule; +import org.graylog.testing.mongodb.MongoDBFixtures; +import org.graylog.testing.mongodb.MongoDBInstance; +import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; +import org.graylog2.database.NotFoundException; +import org.graylog2.shared.bindings.ObjectMapperModule; +import org.graylog2.shared.bindings.ValidatorModule; +import org.jukito.JukitoRunner; +import org.jukito.UseModules; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import javax.validation.Validator; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(JukitoRunner.class) +@UseModules({ObjectMapperModule.class, ValidatorModule.class, TestPasswordSecretModule.class}) +public class SidecarServiceTest { + private static final String collectionName = "sidecars"; + + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + @Mock + private CollectorService collectorService; + + @Mock private ConfigurationService configurationService; + + @Rule + public final MongoDBInstance mongodb = MongoDBInstance.createForClass(); + + private SidecarService sidecarService; + + @Before + public void setUp(MongoJackObjectMapperProvider mapperProvider, + Validator validator) throws Exception { + this.sidecarService = new SidecarService(collectorService, configurationService, mongodb.mongoConnection(), mapperProvider, validator); + } + + @Test + public void testCountEmptyCollection() throws Exception { + final long result = this.sidecarService.count(); + + assertEquals(0, result); + } + + @Test + @MongoDBFixtures("collectorsMultipleDocuments.json") + public void testCountNonEmptyCollection() throws Exception { + final long result = this.sidecarService.count(); + + assertEquals(3, result); + } + + @Test + public void testSaveFirstRecord() throws Exception { + String nodeId = "nodeId"; + String nodeName = "nodeName"; + String version = "0.0.1"; + String os = "DummyOS 1.0"; + final Sidecar sidecar = Sidecar.create( + nodeId, + nodeName, + NodeDetails.create( + os, + null, + null, + null, + null, + null, + null), + version + ); + + final Sidecar result = this.sidecarService.save(sidecar); + MongoCollection collection = mongodb.mongoConnection().getMongoDatabase().getCollection(collectionName); + Document document = collection.find().first(); + Document nodeDetails = document.get("node_details", Document.class); + + assertNotNull(result); + assertEquals(nodeId, document.get("node_id")); + assertEquals(nodeName, document.get("node_name")); + assertEquals(version, document.get("sidecar_version")); + assertEquals(os, nodeDetails.get("operating_system")); + } + + @Test + @MongoDBFixtures("collectorsMultipleDocuments.json") + public void testAll() throws Exception { + final List sidecars = this.sidecarService.all(); + + assertNotNull(sidecars); + assertEquals(3, sidecars.size()); + } + + @Test + public void testAllEmptyCollection() throws Exception { + final List sidecars = this.sidecarService.all(); + + assertNotNull(sidecars); + assertEquals(0, sidecars.size()); + } + + @Test + @MongoDBFixtures("collectorsMultipleDocuments.json") + public void testFindById() throws Exception { + final String collector1id = "uniqueid1"; + + final Sidecar sidecar = this.sidecarService.findByNodeId(collector1id); + + assertNotNull(sidecar); + assertEquals(collector1id, sidecar.nodeId()); + } + + @Test + @MongoDBFixtures("collectorsMultipleDocuments.json") + public void testFindByIdNonexisting() throws Exception { + final String collector1id = "nonexisting"; + + final Sidecar sidecar = this.sidecarService.findByNodeId(collector1id); + + assertNull(sidecar); + } + + @Test + @MongoDBFixtures("collectorsMultipleDocuments.json") + public void testDestroy() throws Exception { + final Sidecar sidecar = mock(Sidecar.class); + when(sidecar.id()).thenReturn("581b3bff8e4dc4270055dfcb"); + + final int result = this.sidecarService.delete(sidecar.id()); + assertEquals(1, result); + assertEquals(2, mongodb.mongoConnection().getMongoDatabase().getCollection(collectionName).countDocuments()); + } + + @Test + public void simpleTagAssignment() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("linux", Set.of("tag1"))).build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + assertThat(sidecar.assignments()).hasSize(1); + assertThat(sidecar.assignments().get(0).assignedFromTags()).isEqualTo(Set.of("tag1")); + } + + @Test + public void mergeWithManualAssignments() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("linux", Set.of("tag1"))) + .assignments(List.of(ConfigurationAssignment.create("some collector", "some config", null))) + .build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + assertThat(sidecar.assignments()).hasSize(2); + assertThat(sidecar.assignments()).satisfies(assignments -> { + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of())).findAny()).isPresent(); + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of("tag1"))).findAny()).isPresent(); + }); + } + + @Test + public void updateExistingAssignment() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + final ConfigurationAssignment existingAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), Set.of("tag1")); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("linux", Set.of("tag1"))) + .assignments(List.of(existingAssignment)) + .build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + assertThat(sidecar.assignments()).hasSize(1); + assertThat(sidecar.assignments()).first().isEqualTo(existingAssignment); + } + + @Test + public void updateExistingAssignments() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + final ConfigurationAssignment existingTag3Assignment = ConfigurationAssignment.create(collector.id(), configuration.id(), Set.of("tag3")); + final ConfigurationAssignment existingManualAssignment = ConfigurationAssignment.create("some-collector", "some-config", null); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("linux", Set.of("tag1"))) + .assignments(List.of(existingTag3Assignment, existingManualAssignment)) + .build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + assertThat(sidecar.assignments()).hasSize(2); + assertThat(sidecar.assignments()).satisfies(assignments -> { + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of())).findAny()).isPresent(); + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of("tag3"))).findAny()).isEmpty(); + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of("tag1"))).findAny()).isPresent(); + }); + } + + @Test + public void ignoresTagsWithWrongOS() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("windows", Set.of("tag1"))).build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + // The tagged config is linux only + assertThat(sidecar.assignments()).hasSize(0); + } + + @Test + public void replacesManualAssignmentsWithTaggedOnes() { + final Configuration configuration = getConfiguration(); + when(configurationService.findByTags(anySet())).thenReturn(List.of(configuration)); + final Collector collector = getCollector(); + + when(collectorService.all()).thenReturn(List.of(collector)); + Sidecar sidecar = getTestSidecar(); + final ConfigurationAssignment manualAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), null); + sidecar = sidecar.toBuilder().nodeDetails(getNodeDetails("linux", Set.of("tag1"))) + .assignments(List.of(manualAssignment)) + .build(); + + sidecar = sidecarService.updateTaggedConfigurationAssignments(sidecar); + + assertThat(sidecar.assignments()).hasSize(1); + assertThat(sidecar.assignments()).satisfies(assignments -> { + assertThat(assignments.stream().filter(a -> a.assignedFromTags().equals(Set.of("tag1"))).findAny()).isPresent(); + }); + } + + @Test + @MongoDBFixtures("sidecars.json") + public void applyManualAssignment() throws NotFoundException { + Sidecar sidecar = sidecarService.findByNodeId("node-id"); + + final Configuration configuration = getConfiguration(); + when(configurationService.find(anyString())).thenReturn(configuration); + final Collector collector = getCollector(); + when(collectorService.find(anyString())).thenReturn(collector); + + assertThat(sidecar.assignments()).isEmpty(); + final ConfigurationAssignment manualAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), null); + sidecar = sidecarService.applyManualAssignments(sidecar.nodeId(), List.of(manualAssignment)); + + assertThat(sidecar.assignments()).hasSize(1); + } + + @Test + @MongoDBFixtures("sidecars.json") + public void applyManualAssignmentKeepTagged() throws NotFoundException { + Sidecar sidecar = sidecarService.findByNodeId("node-id"); + final ConfigurationAssignment taggedAssignment = ConfigurationAssignment.create("some-collector", "some-config", Set.of("tag")); + sidecar = sidecarService.save(sidecar.toBuilder().assignments(List.of(taggedAssignment)).build()); + + final Configuration configuration = getConfiguration(); + when(configurationService.find(anyString())).thenReturn(configuration); + final Collector collector = getCollector(); + when(collectorService.find(anyString())).thenReturn(collector); + + assertThat(sidecar.assignments()).hasSize(1); + final ConfigurationAssignment manualAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), null); + sidecar = sidecarService.applyManualAssignments(sidecar.nodeId(), List.of(manualAssignment)); + + assertThat(sidecar.assignments()).hasSize(2); + } + + @Test + @MongoDBFixtures("sidecars.json") + public void ignoreModificationOfTaggedAssignments() throws NotFoundException { + Sidecar sidecar = sidecarService.findByNodeId("node-id"); + + final Configuration configuration = getConfiguration(); + when(configurationService.find(anyString())).thenReturn(configuration); + final Collector collector = getCollector(); + when(collectorService.find(anyString())).thenReturn(collector); + final ConfigurationAssignment taggedAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), Set.of("tag")); + sidecar = sidecarService.save(sidecar.toBuilder().assignments(List.of(taggedAssignment)).build()); + + + assertThat(sidecar.assignments()).hasSize(1); + final ConfigurationAssignment manualAssignment = ConfigurationAssignment.create(collector.id(), configuration.id(), null); + sidecar = sidecarService.applyManualAssignments(sidecar.nodeId(), List.of(manualAssignment)); + + assertThat(sidecar.assignments()).hasSize(1); + // Tagged assignment is kept intact + assertThat(sidecar.assignments().get(0).assignedFromTags()).isEqualTo(Set.of("tag")); + } + + private static Configuration getConfiguration() { + return Configuration.create("config-id", "collector-id", "config-name", "color", "template", Set.of("tag1")); + } + + private static Collector getCollector() { + return Collector.create("collector-id", "collector-name", "service", "linux", + "/path", "param", "valid param", ""); + } + + private Sidecar getTestSidecar() { + return Sidecar.create( + "node-id", + "node-name", + getNodeDetails("linux", null), + "1.3.0" + ); + } + + private NodeDetails getNodeDetails(String os, Set tags) { + return NodeDetails.create( + os, + null, + null, + null, + null, + tags, + null); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/rest/SidecarResourceTest.java b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/rest/SidecarResourceTest.java index 89d0ea731b45..d189da40dbe4 100644 --- a/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/rest/SidecarResourceTest.java +++ b/graylog2-server/src/test/java/org/graylog/plugins/sidecar/collectors/rest/SidecarResourceTest.java @@ -26,6 +26,7 @@ import org.graylog.plugins.sidecar.rest.requests.RegistrationRequest; import org.graylog.plugins.sidecar.rest.resources.SidecarResource; import org.graylog.plugins.sidecar.services.ActionService; +import org.graylog.plugins.sidecar.services.EtagService; import org.graylog.plugins.sidecar.services.SidecarService; import org.graylog.plugins.sidecar.system.SidecarConfiguration; import org.graylog2.plugin.cluster.ClusterConfigService; @@ -38,6 +39,7 @@ import org.mockito.runners.MockitoJUnitRunner; import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.EntityTag; import javax.ws.rs.core.Response; import java.util.List; @@ -45,6 +47,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -66,6 +69,9 @@ public class SidecarResourceTest extends RestResourceBaseTest { @Mock private ClusterConfigService clusterConfigService; + @Mock + private EtagService etagService; + @Mock private SidecarConfiguration sidecarConfiguration; @@ -74,11 +80,13 @@ public void setUp() throws Exception { this.sidecars = getDummyCollectorList(); when(clusterConfigService.getOrDefault(SidecarConfiguration.class, SidecarConfiguration.defaultConfiguration())).thenReturn(sidecarConfiguration); when(sidecarConfiguration.sidecarUpdateInterval()).thenReturn(Period.seconds(30)); + when(etagService.buildEntityTagForResponse(any())).thenReturn(new EntityTag("hash browns")); this.resource = new SidecarResource( sidecarService, actionService, clusterConfigService, - statusMapper); + statusMapper, + etagService); } @Test(expected = NotFoundException.class) @@ -118,18 +126,25 @@ private List getDummyCollectorList() { @Test public void testRegister() throws Exception { + final NodeDetails nodeDetails = NodeDetails.create( + "DummyOS 1.0", + null, + null, + null, + null, + null, + null + ); final RegistrationRequest input = RegistrationRequest.create( "nodeName", - NodeDetails.create( - "DummyOS 1.0", - null, - null, - null, - null - ) + nodeDetails + ); + when(sidecarService.fromRequest(any(), any(RegistrationRequest.class), anyString())).thenReturn( + Sidecar.create("nodeId", "name", nodeDetails, "0.0.1") ); + when(sidecarService.updateTaggedConfigurationAssignments(any(Sidecar.class))).thenAnswer(invocation -> invocation.getArgument(0)); - final Response response = this.resource.register("sidecarId", input, "0.0.1"); + final Response response = this.resource.register("sidecarId", input, null, "0.0.1"); assertThat(response).isSuccess(); } @@ -144,11 +159,13 @@ public void testRegisterInvalidCollectorId() throws Exception { null, null, null, + null, + null, null ) ); - final Response response = this.resource.register("", invalid, "0.0.1"); + final Response response = this.resource.register("", invalid, null, "0.0.1"); assertThat(response).isError(); assertThat(response).isStatus(Response.Status.BAD_REQUEST); @@ -164,11 +181,13 @@ public void testRegisterInvalidNodeId() throws Exception { null, null, null, + null, + null, null ) ); - final Response response = this.resource.register("sidecarId", invalid, "0.0.1"); + final Response response = this.resource.register("sidecarId", invalid, null, "0.0.1"); assertThat(response).isError(); assertThat(response).isStatus(Response.Status.BAD_REQUEST); @@ -182,7 +201,7 @@ public void testRegisterMissingNodeDetails() throws Exception { null ); - final Response response = this.resource.register("sidecarId", invalid, "0.0.1"); + final Response response = this.resource.register("sidecarId", invalid, null, "0.0.1"); assertThat(response).isError(); assertThat(response).isStatus(Response.Status.BAD_REQUEST); @@ -198,11 +217,13 @@ public void testRegisterMissingOperatingSystem() throws Exception { null, null, null, + null, + null, null ) ); - final Response response = this.resource.register("sidecarId", invalid, "0.0.1"); + final Response response = this.resource.register("sidecarId", invalid, null, "0.0.1"); assertThat(response).isError(); assertThat(response).isStatus(Response.Status.BAD_REQUEST); diff --git a/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/collectorsSingleDataset.json b/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/collectorsSingleDataset.json deleted file mode 100644 index 6b906729885d..000000000000 --- a/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/collectorsSingleDataset.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "sidecars": [ - { - "_id": "581b3bff8e4dc4270055dfcb", - "node_id": "nodeId", - "node_name": "nodeName", - "sidecar_version": "0.0.1", - "node_details": { - "operating_system": "DummyOS 1.0" - }, - "last_seen": "" - } - ] -} diff --git a/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/sidecars.json b/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/sidecars.json new file mode 100644 index 000000000000..d47fc9369ac3 --- /dev/null +++ b/graylog2-server/src/test/resources/org/graylog/plugins/sidecar/collectors/sidecars.json @@ -0,0 +1,16 @@ +{ + "sidecars": [ + { + "_id": { + "$oid": "581b3bff8e4dc4270055dfcb" + }, + "node_id": "node-id", + "node_name": "node-name", + "sidecar_version": "1.3.0", + "node_details": { + "operating_system": "linux" + }, + "last_seen": "2015-04-01T11:50:20.195Z" + } + ] +} diff --git a/graylog2-web-interface/src/components/common/SearchForm.jsx b/graylog2-web-interface/src/components/common/SearchForm.jsx index 468e0ef2df2f..c48033ec8759 100644 --- a/graylog2-web-interface/src/components/common/SearchForm.jsx +++ b/graylog2-web-interface/src/components/common/SearchForm.jsx @@ -44,6 +44,18 @@ const HelpFeedback = styled.span` } `; +const StyledContainer = styled.div(({ topMargin }) => ` + margin-top: ${topMargin}; +`); + +const StyledInput = styled.input(({ queryWidth }) => ` + width: ${queryWidth}; +`); + +const StyledInputContainer = styled.div(({ queryWidth }) => ` + width: ${queryWidth}; +`); + /** * Component that renders a customizable search form. The component * supports a loading state, adding children next to the form, and @@ -62,7 +74,7 @@ class SearchForm extends React.Component { * Callback when a search was submitted. The function receives the query * and a callback to reset the loading state of the form as arguments. */ - onSearch: PropTypes.func.isRequired, + onSearch: PropTypes.func, /** Callback when the input was reset. The function is called with no arguments. */ onReset: PropTypes.func, /** Search field label. */ @@ -120,6 +132,7 @@ class SearchForm extends React.Component { query: '', className: '', onQueryChange: () => {}, + onSearch: null, onReset: null, label: null, placeholder: 'Enter search query...', @@ -227,6 +240,7 @@ class SearchForm extends React.Component { buttonLeftMargin, label, onReset, + onSearch, wrapperClass, topMargin, searchButtonLabel, @@ -236,37 +250,39 @@ class SearchForm extends React.Component { const { query, isLoading } = this.state; return ( -
+
-
+ {label && ( )} - + {queryHelpComponent && ( {queryHelpComponent} )} -
+ - + {onSearch && ( + + )} {onReset && (
+ ); } } diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.test.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.test.tsx new file mode 100644 index 000000000000..9e215180291c --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { render, screen } from 'wrappedTestingLibrary'; + +import CollectorConfigurationModal from './CollectorConfigurationModal'; + +describe('CollectorConfigurationModal', () => { + const renderModal = ( + show: boolean = false, + collectorName: string = '', + sidecarNames : string[] = [], + assignedConfigs: string[] = [], + ) => ( + {}} + onSave={() => {}} + getRowData={() => ({ + configuration: { + id: 'id', + name: 'name', + color: 'black', + template: '', + collector_id: '', + tags: [], + }, + collector: { + id: 'id', + name: 'name', + node_operating_system: 'mac', + service_type: '', + validation_parameters: '', + executable_path: '', + execute_parameters: '', + default_template: '', + }, + sidecars: [], + autoAssignedTags: [], + })} /> + ); + + it('Should only open modal when show is true', () => { + render( + renderModal( + false, + 'collector1', + ), + ); + + const modalTitle = screen.queryByText(/collector1/i); + + expect(modalTitle).toBe(null); + }); + + it('Should display in the title the collector name and the selected sidecar names', () => { + render( + renderModal( + true, + 'collector1', + ['sidecar1', 'sidecar2'], + ), + ); + + const modalTitle = screen.queryByText(/collector1/i); + const modalSubTitle = screen.queryByText(/sidecar1, sidecar2/i); + + expect(modalTitle).not.toBe(null); + expect(modalSubTitle).not.toBe(null); + }); + + it('Should display empty list message and a possibility to create a new config', () => { + render( + renderModal( + true, + 'collector1', + ['sidecar1', 'sidecar2'], + ), + ); + + const emptyListMasg = screen.queryByText(/No configurations available for the selected log collector./i); + const addNewConfig = screen.queryByText(/Add a new configuration/i); + + expect(emptyListMasg).not.toBe(null); + expect(addNewConfig).not.toBe(null); + }); + + it('Should display assigned config names', () => { + render( + renderModal( + true, + 'collector1', + ['sidecar1', 'sidecar2'], + ['config1', 'config2', 'config3'], + ), + ); + + const config1 = screen.queryByText(/config1/i); + const config2 = screen.queryByText(/config2/i); + const config3 = screen.queryByText(/config3/i); + + expect(config1).not.toBe(null); + expect(config2).not.toBe(null); + expect(config3).not.toBe(null); + }); +}); diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.tsx new file mode 100644 index 000000000000..97ccf7d71450 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModal.tsx @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import lodash from 'lodash'; +import styled, { css } from 'styled-components'; + +import Routes from 'routing/Routes'; +import { Table, BootstrapModalWrapper, Button, Modal } from 'components/bootstrap'; +import { SearchForm, Icon } from 'components/common'; +import CollectorIndicator from 'components/sidecars/common/CollectorIndicator'; +import ColorLabel from 'components/sidecars/common/ColorLabel'; +import { Link } from 'components/common/router'; + +import type { Collector, Configuration, SidecarSummary } from '../types'; + +const ConfigurationContainer = styled.div` + overflow: auto; + height: 360px; + margin-top: 8px; +`; + +const ConfigurationTable = styled(Table)` + margin-bottom: 0; +`; + +const NoConfigurationMessage = styled.div` + display: flex; + justify-content: center; +`; + +const AddNewConfiguration = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +const SecondaryText = styled.div` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #aaaaaa; + margin-top: -4px; + margin-bottom: -2px; +`; + +const TableRow = styled.tr(({ disabled = false }: {disabled?: boolean}) => css` + cursor: ${disabled ? 'auto' : 'pointer'}; + background-color: ${disabled ? '#E8E8E8 !important' : 'initial'}; + border-bottom: 1px solid lightgray; + height: 49px; +`); + +const StickyTableRowFooter = styled.tr` + height: 32px; + position: sticky; + bottom: -1px; +`; + +const IconTableCell = styled.td` + width: 32px; +`; + +const CollectorTableCell = styled.td` + width: 140px; + text-align: right; +`; + +const ConfigurationTableCell = styled.td` + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 0; +`; + +const UnselectTableCell = styled.td` + width: 32px; + text-align: center; +`; + +const ModalTitle = styled(Modal.Title)` + font-size: 1.266rem !important; + line-height: 1.1; +`; + +const ModalSubTitle = styled.div` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const getFilterQuery = (_query: string) => { + try { + return new RegExp(_query, 'i'); + } catch (error) { + return ' '; + } +}; + +type Props = { + show: boolean, + onCancel: () => void, + onSave: (selectedConfigurations: string[], partiallySelectedConfigurations: string[]) => void, + selectedCollectorName: string, + selectedSidecarNames: string[], + initialAssignedConfigs: string[], + initialPartiallyAssignedConfigs: string[], + unassignedConfigs: string[], + getRowData: (configName: string) => { + configuration: Configuration, + collector: Collector, + sidecars: SidecarSummary[], + autoAssignedTags: string[], + } +}; + +const CollectorConfigurationModal = ({ + show, + onCancel, + onSave, + selectedCollectorName, + selectedSidecarNames, + initialAssignedConfigs, + initialPartiallyAssignedConfigs, + unassignedConfigs, + getRowData, +}: Props) => { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedConfigurations, setSelectedConfigurations] = useState(initialAssignedConfigs); + const [partiallySelectedConfigurations, setPartiallySelectedConfigurations] = useState(initialPartiallyAssignedConfigs); + + const onReset = () => { + setSelectedConfigurations(initialAssignedConfigs); + setPartiallySelectedConfigurations(initialPartiallyAssignedConfigs); + setSearchQuery(''); + }; + + const isNotDirty = lodash.isEqual(selectedConfigurations, initialAssignedConfigs) && lodash.isEqual(partiallySelectedConfigurations, initialPartiallyAssignedConfigs); + + const filteredOptions = [...initialAssignedConfigs, ...initialPartiallyAssignedConfigs, ...unassignedConfigs].filter((configuration) => configuration.match(getFilterQuery(searchQuery))); + + const rows = filteredOptions.map((configName) => { + const { configuration, collector, sidecars, autoAssignedTags } = getRowData(configName); + + const selected = selectedConfigurations.includes(configName); + const partiallySelected = !selected && partiallySelectedConfigurations.includes(configName); + const secondaryText = (selected && selectedSidecarNames.join(', ')) || (partiallySelected && sidecars.map((sidecar) => sidecar.node_name).join(', ')) || ''; + const isAssignedFromTags = autoAssignedTags.length > 0; + + return ( + { + if (!isAssignedFromTags) { + if (partiallySelected) { + setPartiallySelectedConfigurations(partiallySelectedConfigurations.filter((name) => name !== configName)); + } else { + setSelectedConfigurations(selected ? selectedConfigurations.filter((name) => name !== configName) : [...selectedConfigurations, configName]); + } + } + }}> + + {selected && } + {partiallySelected && } + + + + {configName} + + {secondaryText} + + + + {isAssignedFromTags && } + + + + {collector + ? + : Unknown collector} + + + {(selected || partiallySelected) && !isAssignedFromTags && } + + ); + }); + + return ( + + + + Edit {selectedCollectorName} Configurations + + + {`${selectedSidecarNames.length} sidecar${selectedSidecarNames.length > 1 ? 's' : ''}: `} + {selectedSidecarNames.join(', ')} + + + + + + setSearchQuery(q)} topMargin={0} queryWidth="100%" /> + + + + {(rows.length === 0) ? ( + + + No configurations available for the selected log collector. + + + ) : ( + rows + )} + + + +  Add a new configuration + + + + + + + + + + + + + + ); +}; + +CollectorConfigurationModal.propTypes = { + show: PropTypes.bool.isRequired, + selectedCollectorName: PropTypes.string.isRequired, + selectedSidecarNames: PropTypes.array.isRequired, + initialAssignedConfigs: PropTypes.array.isRequired, + initialPartiallyAssignedConfigs: PropTypes.array.isRequired, + unassignedConfigs: PropTypes.array.isRequired, + onCancel: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + getRowData: PropTypes.func.isRequired, +}; + +export default CollectorConfigurationModal; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModalContainer.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModalContainer.tsx new file mode 100644 index 000000000000..bbe87567537b --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationModalContainer.tsx @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useState, useRef, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import lodash from 'lodash'; +import styled from 'styled-components'; + +import { naturalSortIgnoreCase } from 'util/SortUtils'; +import { BootstrapModalConfirm } from 'components/bootstrap'; + +import CollectorConfigurationModal from './CollectorConfigurationModal'; + +import type { Collector, Configuration, SidecarCollectorPairType, SidecarSummary } from '../types'; + +const ConfigurationSummary = styled.div` + word-break: break-all; +`; + +type Props = { + collectors: Collector[], + configurations: Configuration[], + selectedSidecarCollectorPairs: SidecarCollectorPairType[], + onConfigurationSelectionChange: (pairs: SidecarCollectorPairType[], configs: Configuration[], callback: () => void) => void, + show: boolean, + onCancel: () => void, +}; + +const CollectorConfigurationModalContainer = ({ + collectors, + configurations, + selectedSidecarCollectorPairs, + onConfigurationSelectionChange, + show, + onCancel, +}: Props) => { + const [nextAssignedConfigurations, setNextAssignedConfigurations] = useState([]); + const [nextPartiallyAssignedConfigurations, setNextPartiallyAssignedConfigurations] = useState([]); + const modalConfirm = useRef(null); + + const getSelectedLogCollector = () => { + return (lodash.uniq(selectedSidecarCollectorPairs.map(({ collector }) => collector)))[0]; + }; + + const sortConfigurationNames = (configs: Configuration[]) => { + return configs.sort((config1, config2) => naturalSortIgnoreCase(config1.name, config2.name)) + .map((config) => config.name); + }; + + const getAssignedConfigurations = (_selectedSidecarCollectorPairs: SidecarCollectorPairType[], selectedCollector: Collector) => { + const assignments = _selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar).reduce((accumulator, sidecar) => accumulator.concat(sidecar.assignments), []); + + const filteredAssignments = assignments.map((assignment) => configurations.find((configuration) => configuration.id === assignment.configuration_id)) + .filter((configuration) => selectedCollector?.id === configuration.collector_id); + + return sortConfigurationNames(filteredAssignments); + }; + + const getUnassignedConfigurations = (assignedConfigurations: string[], selectedCollector: Collector) => { + const filteredConfigs = configurations.filter((config) => !assignedConfigurations.includes(config.name) && (selectedCollector?.id === config.collector_id)); + + return sortConfigurationNames(filteredConfigs); + }; + + const getFullyAndPartiallyAssignments = (_assignedConfigurations: string[]) => { + const occurrences = lodash.countBy(_assignedConfigurations); + + return [ + lodash.uniq(_assignedConfigurations.filter((config) => occurrences[config] === selectedSidecarCollectorPairs.length)), + lodash.uniq(_assignedConfigurations.filter((config) => occurrences[config] < selectedSidecarCollectorPairs.length)), + ]; + }; + + const onSave = (fullyAssignedConfigs: string[], partiallyAssignedConfigs: string[]) => { + setNextAssignedConfigurations(fullyAssignedConfigs); + setNextPartiallyAssignedConfigurations(partiallyAssignedConfigs); + modalConfirm.current.open(); + }; + + const confirmConfigurationChange = (doneCallback: () => void) => { + const assignedConfigurationsToSave = configurations.filter((config) => nextAssignedConfigurations.includes(config.name)); + + selectedSidecarCollectorPairs.forEach((sidecarCollectorPair) => { + let configs = assignedConfigurationsToSave; + + if (nextPartiallyAssignedConfigurations.length) { + const selectedLogCollector = getSelectedLogCollector(); + const assignments = getAssignedConfigurations([sidecarCollectorPair], selectedLogCollector); + const assignmentsToKeep = lodash.intersection(assignments, nextPartiallyAssignedConfigurations); + const assignedConfigurationsToKeep = configurations.filter((config) => assignmentsToKeep.includes(config.name)); + configs = [...assignedConfigurationsToSave, ...assignedConfigurationsToKeep]; + } + + onConfigurationSelectionChange([sidecarCollectorPair], configs, doneCallback); + }); + + onCancel(); + }; + + const cancelConfigurationChange = () => { + setNextAssignedConfigurations([]); + }; + + const getConfiguration = (configName: string) => { + return configurations.find((config) => config.name === configName); + }; + + const getCollector = (configName: string) => { + const configuration = getConfiguration(configName); + + return collectors.find((collector) => collector.id === configuration.collector_id); + }; + + const getSidecars = (configName: string) => { + const configuration = getConfiguration(configName); + + return selectedSidecarCollectorPairs.filter(({ sidecar }) => sidecar.assignments.map((assignment) => assignment.configuration_id).includes(configuration.id)).map((assignment) => assignment.sidecar); + }; + + const getAssignedFromTags = (configId: string, collectorId: string, sidecars: SidecarSummary[]) => { + const assigned_from_tags = sidecars.reduce((accumulator, sidecar) => { + return accumulator.concat( + sidecar.assignments.find((assignment) => (assignment.collector_id === collectorId) && (assignment.configuration_id === configId)).assigned_from_tags, + ); + }, []); + + return lodash.uniq(assigned_from_tags); + }; + + const getRowData = (configName: string) => { + const configuration = getConfiguration(configName); + const collector = getCollector(configName); + const sidecars = getSidecars(configName); + const autoAssignedTags = getAssignedFromTags(configuration.id, collector.id, sidecars); + + return { configuration, collector, sidecars, autoAssignedTags }; + }; + + const renderConfigurationSummary = () => { + const sidecarsSummary = selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar.node_name).join(', '); + const numberOfSidecarsSummary = `${selectedSidecarCollectorPairs.length} sidecars`; + const summary = selectedSidecarCollectorPairs.length <= 5 ? sidecarsSummary : numberOfSidecarsSummary; + + return ( + + +

Are you sure you want to proceed with this action for {summary}?

+
+
+ ); + }; + + const MemoizedConfigurationModal = useMemo(() => { + const renderConfigurationModal = () => { + const selectedCollector = getSelectedLogCollector(); + const assignedConfigurations = getAssignedConfigurations(selectedSidecarCollectorPairs, selectedCollector); + const unassignedConfigurations = getUnassignedConfigurations(assignedConfigurations, selectedCollector); + const [initialAssignedConfigs, initialPartiallyAssignedConfigs] = getFullyAndPartiallyAssignments(assignedConfigurations); + + return ( + sidecar.node_name)} + initialAssignedConfigs={initialAssignedConfigs} + initialPartiallyAssignedConfigs={initialPartiallyAssignedConfigs} + unassignedConfigs={unassignedConfigurations} + onCancel={onCancel} + onSave={onSave} + getRowData={getRowData} /> + ); + }; + + return renderConfigurationModal; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show]); + + return ( + <> + + {renderConfigurationSummary()} + + ); +}; + +CollectorConfigurationModalContainer.propTypes = { + collectors: PropTypes.array.isRequired, + configurations: PropTypes.array.isRequired, + selectedSidecarCollectorPairs: PropTypes.array.isRequired, + onConfigurationSelectionChange: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default CollectorConfigurationModalContainer; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationSelector.jsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationSelector.jsx deleted file mode 100644 index 0337b7883f5c..000000000000 --- a/graylog2-web-interface/src/components/sidecars/administration/CollectorConfigurationSelector.jsx +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import React from 'react'; -import PropTypes from 'prop-types'; -import lodash from 'lodash'; - -import { Button, BootstrapModalConfirm } from 'components/bootstrap'; -import { SelectPopover } from 'components/common'; -import { naturalSortIgnoreCase } from 'util/SortUtils'; -import CollectorIndicator from 'components/sidecars/common/CollectorIndicator'; -import ColorLabel from 'components/sidecars/common/ColorLabel'; - -const getAssignedConfigurations = (selectedSidecarCollectorPairs, configurations) => { - const assignments = selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar).reduce((accumulator, sidecar) => accumulator.concat(sidecar.assignments), []); - - return assignments.map((assignment) => configurations.find((configuration) => configuration.id === assignment.configuration_id)); -}; - -class CollectorConfigurationSelector extends React.Component { - static propTypes = { - collectors: PropTypes.array.isRequired, - configurations: PropTypes.array.isRequired, - selectedSidecarCollectorPairs: PropTypes.array.isRequired, - onConfigurationSelectionChange: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - nextAssignedConfigurations: [], - }; - } - - handleConfigurationSelect = (configurationNames, hideCallback) => { - const { configurations } = this.props; - - hideCallback(); - const nextAssignedConfigurations = configurations.filter((c) => configurationNames.includes(c.name)); - - this.setState({ nextAssignedConfigurations }, this.modal.open); - }; - - confirmConfigurationChange = (doneCallback) => { - const { onConfigurationSelectionChange } = this.props; - const { nextAssignedConfigurations } = this.state; - - onConfigurationSelectionChange(nextAssignedConfigurations, doneCallback); - }; - - cancelConfigurationChange = () => { - this.setState({ nextAssignedConfigurations: [] }); - }; - - configurationFormatter = (configurationName) => { - const { configurations, collectors } = this.props; - const configuration = configurations.find((c) => c.name === configurationName); - const collector = collectors.find((b) => b.id === configuration.collector_id); - - return ( - - {configuration.name}  - - {collector - ? ( - - ) - : Unknown collector} - - - ); - }; - - renderConfigurationSummary = (nextAssignedConfigurations, selectedSidecarCollectorPairs) => { - const exampleSidecarCollectorPair = selectedSidecarCollectorPairs[0]; - const collectorIndicator = ( - - - - ); - - let actionSummary; - - if (nextAssignedConfigurations.length === 0) { - actionSummary = You are going to remove the configuration for collector {collectorIndicator} from:; - } else { - actionSummary = You are going to apply the {nextAssignedConfigurations[0].name} configuration for collector {collectorIndicator} to:; - } - - const formattedSummary = selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar.node_name).join(', '); - - return ( - { this.modal = c; }} - title="Configuration summary" - onConfirm={this.confirmConfigurationChange} - onCancel={this.cancelConfigurationChange}> -
-

{actionSummary}

-

{formattedSummary}

-

Are you sure you want to proceed with this action?

-
-
- ); - }; - - render() { - const { nextAssignedConfigurations } = this.state; - const { configurations, selectedSidecarCollectorPairs } = this.props; - - // Do not allow configuration changes when more than one log collector type is selected - const selectedLogCollectors = lodash.uniq(selectedSidecarCollectorPairs.map(({ collector }) => collector)); - - if (selectedLogCollectors.length > 1) { - return ( - Configure } - items={[`Cannot change configurations of ${selectedLogCollectors.map((collector) => collector.name).join(', ')} collectors simultaneously`]} - displayDataFilter={false} - disabled /> - ); - } - - const configurationNames = configurations - .filter((configuration) => selectedLogCollectors[0].id === configuration.collector_id) - .sort((c1, c2) => naturalSortIgnoreCase(c1.name, c2.name)) - .map((c) => c.name); - - if (configurationNames.length === 0) { - return ( - Configure } - items={['No configurations available for the selected log collector']} - displayDataFilter={false} - disabled /> - ); - } - - const assignedConfigurations = getAssignedConfigurations(selectedSidecarCollectorPairs, configurations) - .filter((configuration) => selectedLogCollectors[0].id === configuration.collector_id); - - return ( - - Configure } - items={configurationNames} - itemFormatter={this.configurationFormatter} - onItemSelect={this.handleConfigurationSelect} - selectedItems={assignedConfigurations.map((config) => config.name)} - filterPlaceholder="Filter by configuration" /> - {this.renderConfigurationSummary(nextAssignedConfigurations, selectedSidecarCollectorPairs)} - - ); - } -} - -export default CollectorConfigurationSelector; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.jsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.jsx deleted file mode 100644 index 14c8718b22d9..000000000000 --- a/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.jsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import React from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import lodash from 'lodash'; - -import { Button, Panel, BootstrapModalConfirm } from 'components/bootstrap'; -import { Pluralize, SelectPopover } from 'components/common'; - -const PROCESS_ACTIONS = ['start', 'restart', 'stop']; - -const CollectorProcessControl = createReactClass({ - propTypes: { - selectedSidecarCollectorPairs: PropTypes.array.isRequired, - onProcessAction: PropTypes.func.isRequired, - }, - - getInitialState() { - return { - selectedAction: undefined, - isConfigurationWarningHidden: false, - }; - }, - - resetSelectedAction() { - this.setState({ selectedAction: undefined }); - }, - - handleProcessActionSelect(processAction, hideCallback) { - hideCallback(); - this.setState({ selectedAction: processAction ? processAction[0] : undefined }, this.modal.open); - }, - - confirmProcessAction(doneCallback) { - const { onProcessAction, selectedSidecarCollectorPairs } = this.props; - const { selectedAction } = this.state; - - const callback = () => { - doneCallback(); - this.resetSelectedAction(); - }; - - onProcessAction(selectedAction, selectedSidecarCollectorPairs, callback); - }, - - cancelProcessAction() { - this.resetSelectedAction(); - }, - - hideConfigurationWarning() { - this.setState({ isConfigurationWarningHidden: true }); - }, - - renderSummaryContent(selectedAction, selectedSidecars) { - return ( - <> -

- You are going to {selectedAction} log collectors in  - : -

-

{selectedSidecars.join(', ')}

-

Are you sure you want to proceed with this action?

- - ); - }, - - renderConfigurationWarning(selectedAction) { - return ( - -

- At least one selected Collector is not configured yet. To start a new Collector, assign a - Configuration to it and the Sidecar will start the process for you. -

-

- {lodash.capitalize(selectedAction)}ing a Collector without Configuration will have no effect. -

- -
- ); - }, - - renderProcessActionSummary(selectedSidecarCollectorPairs, selectedAction) { - const { isConfigurationWarningHidden } = this.state; - const selectedSidecars = lodash.uniq(selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar.node_name)); - - // Check if all selected collectors have assigned configurations - const allHaveConfigurationsAssigned = selectedSidecarCollectorPairs.every(({ collector, sidecar }) => { - return sidecar.assignments.some(({ collector_id }) => collector_id === collector.id); - }); - - const shouldShowConfigurationWarning = !isConfigurationWarningHidden && !allHaveConfigurationsAssigned; - - return ( - { this.modal = c; }} - title="Process action summary" - confirmButtonDisabled={shouldShowConfigurationWarning} - onConfirm={this.confirmProcessAction} - onCancel={this.cancelProcessAction}> -
- {shouldShowConfigurationWarning - ? this.renderConfigurationWarning(selectedAction) - : this.renderSummaryContent(selectedAction, selectedSidecars)} -
-
- ); - }, - - render() { - const { selectedSidecarCollectorPairs } = this.props; - const { selectedAction } = this.state; - - const actionFormatter = (action) => lodash.capitalize(action); - - return ( - - Process - -)} - items={PROCESS_ACTIONS} - itemFormatter={actionFormatter} - selectedItems={selectedAction ? [selectedAction] : []} - displayDataFilter={false} - onItemSelect={this.handleProcessActionSelect} /> - {this.renderProcessActionSummary(selectedSidecarCollectorPairs, selectedAction)} - - ); - }, -}); - -export default CollectorProcessControl; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.tsx new file mode 100644 index 000000000000..49364eb5ba27 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorProcessControl.tsx @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import lodash from 'lodash'; + +import { Button, Panel, BootstrapModalConfirm } from 'components/bootstrap'; +import { Pluralize, SelectPopover } from 'components/common'; + +import type { SidecarCollectorPairType } from '../types'; + +const PROCESS_ACTIONS = ['start', 'restart', 'stop']; + +type Props = { + selectedSidecarCollectorPairs: SidecarCollectorPairType[], + onProcessAction: (action: string, pairs: SidecarCollectorPairType[], callback: () => void) => void, +}; + +const CollectorProcessControl = ({ selectedSidecarCollectorPairs, onProcessAction }: Props) => { + const [selectedAction, setSelectedAction] = useState(''); + const [isConfigurationWarningHidden, setIsConfigurationWarningHidden] = useState(false); + const modalRef = useRef(null); + + const resetSelectedAction = () => { + setSelectedAction(undefined); + }; + + const handleProcessActionSelect = (processAction: string[], hideCallback: () => void) => { + hideCallback(); + setSelectedAction(processAction ? processAction[0] : undefined); + modalRef.current?.open(); + }; + + const confirmProcessAction = (doneCallback: () => void) => { + const callback = () => { + doneCallback(); + resetSelectedAction(); + }; + + onProcessAction(selectedAction, selectedSidecarCollectorPairs, callback); + }; + + const cancelProcessAction = () => { + resetSelectedAction(); + }; + + const hideConfigurationWarning = () => { + setIsConfigurationWarningHidden(true); + }; + + const renderSummaryContent = (selectedSidecars: string[]) => { + return ( + <> +

+ You are going to {selectedAction} log collectors in  + : +

+

{selectedSidecars.join(', ')}

+

Are you sure you want to proceed with this action?

+ + ); + }; + + const renderConfigurationWarning = () => { + return ( + +

+ At least one selected Collector is not configured yet. To start a new Collector, assign a + Configuration to it and the Sidecar will start the process for you. +

+

+ {lodash.capitalize(selectedAction)}ing a Collector without Configuration will have no effect. +

+ +
+ ); + }; + + const renderProcessActionSummary = () => { + const selectedSidecars = lodash.uniq(selectedSidecarCollectorPairs.map(({ sidecar }) => sidecar.node_name)); + + // Check if all selected collectors have assigned configurations + const allHaveConfigurationsAssigned = selectedSidecarCollectorPairs.every(({ collector, sidecar }) => { + return sidecar.assignments.some(({ collector_id }) => collector_id === collector.id); + }); + + const shouldShowConfigurationWarning = !isConfigurationWarningHidden && !allHaveConfigurationsAssigned; + + return ( + +
+ {shouldShowConfigurationWarning + ? renderConfigurationWarning() + : renderSummaryContent(selectedSidecars)} +
+
+ ); + }; + + const actionFormatter = (action: string) => lodash.capitalize(action); + + return ( + + Process + )} + items={PROCESS_ACTIONS} + itemFormatter={actionFormatter} + selectedItems={selectedAction ? [selectedAction] : []} + displayDataFilter={false} + onItemSelect={handleProcessActionSelect} /> + {renderProcessActionSummary()} + + ); +}; + +CollectorProcessControl.propTypes = { + selectedSidecarCollectorPairs: PropTypes.array.isRequired, + onProcessAction: PropTypes.func.isRequired, +}; + +export default CollectorProcessControl; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.css b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.css deleted file mode 100644 index e3c359aaa15a..000000000000 --- a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.css +++ /dev/null @@ -1,35 +0,0 @@ -:local(.collectorEntry) .row { - margin-bottom: 5px; -} - -:local(.collectorEntry) .form-group { - display: inline-block; - margin: 0 10px 0 0; -} - -:local(.collectorEntry) .checkbox { - margin-top: 5px; - margin-bottom: 5px; -} - -:local(.collectorEntry) .checkbox label { - font-size: 1rem; /* theme.fonts.size.body */ -} - -:local(.alignedInformation) { - margin-left: 20px; -} - -:local(.additionalContent) { - display: block; - height: 20px; - margin: 5px 0; -} - -:local(.paginatedList) .page-size { - padding-top: 4px; -} - -:local(.paginatedList) .search { - margin-bottom: 15px; -} diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.jsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.jsx deleted file mode 100644 index 6df5ce0008c9..000000000000 --- a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.jsx +++ /dev/null @@ -1,379 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -/* eslint-disable */ -import React from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import lodash from 'lodash'; -import styled, { css } from 'styled-components'; - -import { Link } from 'components/common/router'; -import { ControlledTableList, PaginatedList } from 'components/common'; -import Routes from 'routing/Routes'; -import { Col, Row, Input } from 'components/bootstrap'; -import ColorLabel from 'components/sidecars/common/ColorLabel'; -import OperatingSystemIcon from 'components/sidecars/common/OperatingSystemIcon'; -import SidecarSearchForm from 'components/sidecars/common/SidecarSearchForm'; -import StatusIndicator from 'components/sidecars/common/StatusIndicator'; -import commonStyle from 'components/sidecars/common/CommonSidecarStyles.css'; - -import CollectorsAdministrationActions from './CollectorsAdministrationActions'; -import CollectorsAdministrationFilters from './CollectorsAdministrationFilters'; -import FiltersSummary from './FiltersSummary'; -import style from './CollectorsAdministration.css'; - -const HeaderComponentsWrapper = styled.div(({ theme }) => css` - float: right; - margin: 5px 0; - - .btn-link { - color: ${theme.colors.variant.darker.default}; - } -`); - -const DisabledCollector = styled.div(({ theme }) => css` - color: ${theme.colors.variant.light.default}; -`); - -export const PAGE_SIZES = [10, 25, 50, 100]; - -const CollectorsAdministration = createReactClass({ - propTypes: { - sidecarCollectorPairs: PropTypes.array.isRequired, - collectors: PropTypes.array.isRequired, - configurations: PropTypes.array.isRequired, - pagination: PropTypes.object.isRequired, - query: PropTypes.string.isRequired, - filters: PropTypes.object.isRequired, - onPageChange: PropTypes.func.isRequired, - onFilter: PropTypes.func.isRequired, - onQueryChange: PropTypes.func.isRequired, - onConfigurationChange: PropTypes.func.isRequired, - onProcessAction: PropTypes.func.isRequired, - }, - - getInitialState() { - const { sidecarCollectorPairs } = this.props; - - return { - enabledCollectors: this.getEnabledCollectors(sidecarCollectorPairs), - selected: [], - }; - }, - - UNSAFE_componentWillReceiveProps(nextProps) { - const { sidecarCollectorPairs } = this.props; - - if (!lodash.isEqual(sidecarCollectorPairs, nextProps.sidecarCollectorPairs)) { - this.setState({ - enabledCollectors: this.getEnabledCollectors(nextProps.sidecarCollectorPairs), - selected: this.filterSelectedCollectors(nextProps.sidecarCollectorPairs), - }); - } - }, - - componentDidUpdate() { - const { enabledCollectors, selected } = this.state; - - this.setSelectAllCheckboxState(this.selectAllInput, enabledCollectors, selected); - }, - - // Filter out sidecars with no compatible collectors - getEnabledCollectors(collectors) { - return collectors.filter(({ collector }) => !lodash.isEmpty(collector)); - }, - - setSelectAllCheckboxState(selectAllInput, collectors, selected) { - const selectAllCheckbox = selectAllInput ? selectAllInput.getInputDOMNode() : undefined; - - if (!selectAllCheckbox) { - return; - } - - // Set the select all checkbox as indeterminate if some but not all items are selected. - selectAllCheckbox.indeterminate = selected.length > 0 && !this.isAllSelected(collectors, selected); - }, - - sidecarCollectorId(sidecar, collector) { - return `${sidecar.node_id}-${collector.name}`; - }, - - filterSelectedCollectors(collectors) { - const { selected } = this.state; - const filteredSidecarCollectorIds = collectors.map(({ collector, sidecar }) => this.sidecarCollectorId(sidecar, collector)); - - return selected.filter((sidecarCollectorId) => filteredSidecarCollectorIds.includes(sidecarCollectorId)); - }, - - handleConfigurationChange(selectedConfigurations, doneCallback) { - const { selected, enabledCollectors } = this.state; - const { onConfigurationChange } = this.props; - - const selectedSidecars = enabledCollectors - .filter(({ sidecar, collector }) => selected.includes(this.sidecarCollectorId(sidecar, collector))); - - onConfigurationChange(selectedSidecars, selectedConfigurations, doneCallback); - }, - - handleProcessAction(action, selectedSidecarCollectorPairs, doneCallback) { - const { onProcessAction } = this.props; - const selectedCollectors = {}; - - selectedSidecarCollectorPairs.forEach(({ sidecar, collector }) => { - if (selectedCollectors[sidecar.node_id]) { - selectedCollectors[sidecar.node_id].push(collector.id); - } else { - selectedCollectors[sidecar.node_id] = [collector.id]; - } - }); - - onProcessAction(action, selectedCollectors, doneCallback); - }, - - formatHeader() { - const { collectors, configurations, sidecarCollectorPairs } = this.props; - const { selected, enabledCollectors } = this.state; - const selectedItems = selected.length; - - const selectedSidecarCollectorPairs = selected.map((selectedSidecarCollectorId) => { - return sidecarCollectorPairs.find(({ sidecar, collector }) => this.sidecarCollectorId(sidecar, collector) === selectedSidecarCollectorId); - }); - - let headerMenu; - - if (selectedItems === 0) { - const { filters, onFilter } = this.props; - - headerMenu = ( - - ); - } else { - headerMenu = ( - - ); - } - - return ( - - {headerMenu} - - { this.selectAllInput = c; }} - id="select-all-checkbox" - type="checkbox" - label={selectedItems === 0 ? 'Select all' : `${selectedItems} selected`} - disabled={enabledCollectors.length === 0} - checked={this.isAllSelected(enabledCollectors, selected)} - onChange={this.toggleSelectAll} - wrapperClassName="form-group-inline" /> - - ); - }, - - handleSidecarCollectorSelect(sidecarCollectorId) { - return (event) => { - const { selected } = this.state; - - const newSelection = (event.target.checked - ? lodash.union(selected, [sidecarCollectorId]) - : lodash.without(selected, sidecarCollectorId)); - - this.setState({ selected: newSelection }); - }; - }, - - isAllSelected(collectors, selected) { - return collectors.length > 0 && collectors.length === selected.length; - }, - - toggleSelectAll(event) { - const { enabledCollectors } = this.state; - const newSelection = (event.target.checked - ? enabledCollectors.map(({ sidecar, collector }) => this.sidecarCollectorId(sidecar, collector)) - : []); - - this.setState({ selected: newSelection }); - }, - - formatSidecarNoCollectors(sidecar) { - return ( - - - - -

- {sidecar.node_name} -  {sidecar.node_id} -

- -
- - - - No collectors compatible with {sidecar.node_details.operating_system} - - - -
-
- ); - }, - - formatCollector(sidecar, collector, configurations) { - const sidecarCollectorId = this.sidecarCollectorId(sidecar, collector); - const configAssignment = sidecar.assignments.find((assignment) => assignment.collector_id === collector.id) || {}; - const configuration = configurations.find((config) => config.id === configAssignment.configuration_id); - const { selected } = this.state; - let collectorStatus = { status: null, message: null, id: null }; - - try { - const result = sidecar.node_details.status.collectors.find((c) => c.collector_id === collector.id); - - if (result) { - collectorStatus = { - status: result.status, - message: result.message, - id: result.collector_id, - }; - } - } catch (e) { - // Do nothing - } - - return ( - - - - - - - {configuration && ( - - )} - - - - - {configuration && } - - - - ); - }, - - formatSidecar(sidecar, collectors, configurations) { - if (collectors.length === 0) { - return this.formatSidecarNoCollectors(sidecar); - } - - return ( - -
- - -

- {sidecar.node_name} -  {sidecar.node_id} {!sidecar.active && — inactive} -

- -
- {collectors.map((collector) => this.formatCollector(sidecar, collector, configurations))} -
-
- ); - }, - - handleSearch(query, callback) { - const { onQueryChange } = this.props; - - onQueryChange(query, callback()); - }, - - handleReset() { - const { onQueryChange } = this.props; - - onQueryChange(); - }, - - handleResetFilters() { - const { onFilter } = this.props; - - onFilter(); - }, - - render() { - const { configurations, collectors, onPageChange, pagination, query, sidecarCollectorPairs, filters } = this.props; - - let formattedCollectors; - - if (sidecarCollectorPairs.length === 0) { - formattedCollectors = ( - - {sidecarCollectorPairs.length === 0 ? 'There are no collectors to display' : 'Filters do not match any collectors'} - - ); - } else { - const sidecars = lodash.uniq(sidecarCollectorPairs.map(({ sidecar }) => sidecar)); - - formattedCollectors = sidecars.map((sidecarToMap) => { - const sidecarCollectors = sidecarCollectorPairs - .filter(({ sidecar }) => sidecar.node_id === sidecarToMap.node_id) - .map(({ collector }) => collector) - .filter((collector) => !lodash.isEmpty(collector)); - - return this.formatSidecar(sidecarToMap, sidecarCollectors, configurations); - }); - } - - return ( -
- - - - - - - {this.formatHeader()} - {formattedCollectors} - - - - -
- ); - }, -}); - -export default CollectorsAdministration; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.tsx new file mode 100644 index 000000000000..dd11b23efe3a --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministration.tsx @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import lodash from 'lodash'; +import styled, { css } from 'styled-components'; + +import { naturalSortIgnoreCase } from 'util/SortUtils'; +import { Link } from 'components/common/router'; +import { ControlledTableList, PaginatedList, IconButton } from 'components/common'; +import Routes from 'routing/Routes'; +import { Col, Row, Input } from 'components/bootstrap'; +import ColorLabel from 'components/sidecars/common/ColorLabel'; +import OperatingSystemIcon from 'components/sidecars/common/OperatingSystemIcon'; +import SidecarSearchForm from 'components/sidecars/common/SidecarSearchForm'; +import StatusIndicator from 'components/sidecars/common/StatusIndicator'; +import commonStyle from 'components/sidecars/common/CommonSidecarStyles.css'; +import type { Pagination } from 'views/stores/DashboardsStore'; + +import CollectorsAdministrationActions from './CollectorsAdministrationActions'; +import CollectorsAdministrationFilters from './CollectorsAdministrationFilters'; +import CollectorConfigurationModalContainer from './CollectorConfigurationModalContainer'; +import FiltersSummary from './FiltersSummary'; + +import type { Collector, Configuration, SidecarCollectorPairType, SidecarSummary } from '../types'; + +const HeaderComponentsWrapper = styled.div(({ theme }) => css` + float: right; + margin: 5px 0; + + .btn-link { + color: ${theme.colors.variant.darker.default}; + } +`); + +const CollectorEntry = styled.div` + .row { + margin-bottom: 5px; + } + .form-group { + display: inline-block; + margin: 0 10px 0 0; + } + .checkbox { + margin-top: 5px; + margin-bottom: 5px; + } + .checkbox label { + font-size: 1rem; /* theme.fonts.size.body */ + } +`; + +const DisabledCollector = styled(CollectorEntry)(({ theme }) => css` + color: ${theme.colors.variant.light.default}; + margin-left: 20px; +`); + +const AdditionalContent = styled.span` + display: flex; + margin-top: 5px; + flex-wrap: wrap; +`; + +const AlignedInformation = styled.span` + margin-left: 20px; +`; + +const PaginatedListContainer = styled.div` + .page-size { + padding-top: 4px; + } + .search { + margin-bottom: 15px; + } +`; + +const StyledColorLabelContainer = styled.span` + .color-label-wrapper { + display: flex; + } +`; + +export const PAGE_SIZES = [10, 25, 50, 100]; + +type Props = { + collectors: Collector[], + configurations: Configuration[], + sidecarCollectorPairs: SidecarCollectorPairType[], + query: string, + filters: { [_key: string]: string }, + pagination: Pagination, + onPageChange: (currentPage: number, pageSize: number) => void, + onFilter: (collectorIds?: string[], callback?: () => void) => void, + onQueryChange: (query?: string, callback?: () => void) => void, + onConfigurationChange: (pairs: SidecarCollectorPairType[], configs: Configuration[], callback: () => void) => void, + onProcessAction: (action: string, collectorDict: { [sidecarId: string]: string[] }, callback: () => void) => void, +}; + +const CollectorsAdministration = ({ + configurations, + collectors, + onPageChange, + pagination, + query, + sidecarCollectorPairs, + filters, + onFilter, + onQueryChange, + onConfigurationChange, + onProcessAction, +}: Props) => { + const [showConfigurationModal, setShowConfigurationModal] = useState(false); + const [selected, setSelected] = useState([]); + const selectAllInput = useRef(null); + + // Filter out sidecars with no compatible collectors + const enabledCollectors = sidecarCollectorPairs.filter(({ collector }) => !lodash.isEmpty(collector)); + + const sidecarCollectorId = (sidecar: SidecarSummary, collector: Collector) => { + return `${sidecar.node_id}-${collector.name}`; + }; + + const isAllSelected = (_collectors: (SidecarCollectorPairType|Collector)[], _selected: string[]) => { + return _collectors.length > 0 && _collectors.length === _selected.length; + }; + + useEffect(() => { + const selectAllCheckbox = selectAllInput ? selectAllInput.current.getInputDOMNode() : undefined; + + if (selectAllCheckbox) { + // Set the select all checkbox as indeterminate if some but not all items are selected. + selectAllCheckbox.indeterminate = selected.length > 0 && !isAllSelected(collectors, selected); + } + }, [selectAllInput, collectors, selected]); + + const handleConfigurationChange = (selectedSidecars: SidecarCollectorPairType[], selectedConfigurations: Configuration[], doneCallback: () => void) => { + onConfigurationChange(selectedSidecars, selectedConfigurations, doneCallback); + }; + + const handleProcessAction = (action: string, selectedSidecarCollectorPairs: SidecarCollectorPairType[], doneCallback: () => void) => { + const selectedCollectors = {}; + + selectedSidecarCollectorPairs.forEach(({ sidecar, collector }) => { + if (selectedCollectors[sidecar.node_id]) { + selectedCollectors[sidecar.node_id].push(collector.id); + } else { + selectedCollectors[sidecar.node_id] = [collector.id]; + } + }); + + onProcessAction(action, selectedCollectors, doneCallback); + }; + + const toggleSelectAll = (event) => { + const newSelection = (event.target.checked + ? enabledCollectors.map(({ sidecar, collector }) => sidecarCollectorId(sidecar, collector)) + : []); + + setSelected(newSelection); + }; + + const formatHeader = (selectedSidecarCollectorPairs: SidecarCollectorPairType[]) => { + const selectedItems = selected.length; + + let headerMenu; + + if (selectedItems === 0) { + headerMenu = ( + + ); + } else { + headerMenu = ( + + ); + } + + return ( + + {headerMenu} + + + + ); + }; + + const handleSidecarCollectorSelect = (collectorId: string) => { + return (event) => { + const newSelection = (event.target.checked + ? lodash.union(selected, [collectorId]) + : lodash.without(selected, collectorId)); + + setSelected(newSelection); + }; + }; + + const formatSidecarNoCollectors = (sidecar: SidecarSummary) => { + return ( + + + + +

+ {sidecar.node_name} +  {sidecar.node_id} +

+ +
+ + + + No collectors compatible with {sidecar.node_details.operating_system} + + + +
+
+ ); + }; + + const formatCollector = (sidecar: SidecarSummary, collector: Collector, _configurations: Configuration[]) => { + const collectorId = sidecarCollectorId(sidecar, collector); + const configAssignmentIDs = sidecar.assignments.filter((assignment) => assignment.collector_id === collector.id).map((assignment) => assignment.configuration_id); + const configAssignments = _configurations.filter((config) => configAssignmentIDs.includes(config.id)).sort((c1, c2) => naturalSortIgnoreCase(c1.name, c2.name)); + let collectorStatus = { status: null, message: null, id: null }; + + try { + const result = sidecar.node_details.status.collectors.find((c) => c.collector_id === collector.id); + + if (result) { + collectorStatus = { + status: result.status, + message: result.message, + id: result.collector_id, + }; + } + } catch (e) { + // Do nothing + } + + return ( + + + + + + + {(configAssignments.length > 0) && ( + + )} + + + + + {(configAssignments.length > 0) + && ( + { + setSelected([collectorId]); + setShowConfigurationModal(true); + }} /> + )} + {configAssignments.map((configuration) => ( + + + + + + ), + )} + + + + ); + }; + + const formatSidecar = (sidecar: SidecarSummary, _collectors: Collector[], _configurations: Configuration[]) => { + if (_collectors.length === 0) { + return formatSidecarNoCollectors(sidecar); + } + + return ( + + + + + + {sidecar.node_name} +  {sidecar.node_id} {!sidecar.active && — inactive} + + + + {_collectors.map((collector) => formatCollector(sidecar, collector, _configurations))} + + + ); + }; + + const handleSearch = (_query: string, callback: () => void) => { + onQueryChange(_query, callback); + }; + + const handleReset = () => { + onQueryChange(); + }; + + const handleResetFilters = () => { + onFilter(); + }; + + const selectedSidecarCollectorPairs = selected.map((selectedSidecarCollectorId) => { + return sidecarCollectorPairs.find(({ sidecar, collector }) => sidecarCollectorId(sidecar, collector) === selectedSidecarCollectorId); + }); + + let formattedCollectors; + + if (sidecarCollectorPairs.length === 0) { + formattedCollectors = ( + + {sidecarCollectorPairs.length === 0 ? 'There are no collectors to display' : 'Filters do not match any collectors'} + + ); + } else { + const sidecars = lodash.uniq(sidecarCollectorPairs.map(({ sidecar }) => sidecar)); + + formattedCollectors = sidecars.map((sidecarToMap) => { + const sidecarCollectors = sidecarCollectorPairs + .filter(({ sidecar }) => sidecar.node_id === sidecarToMap.node_id) + .map(({ collector }) => collector) + .filter((collector) => !lodash.isEmpty(collector)); + + return formatSidecar(sidecarToMap, sidecarCollectors, configurations); + }); + } + + return ( + + + + + + + + {formatHeader(selectedSidecarCollectorPairs)} + {formattedCollectors} + + + + + { + setSelected([]); + setShowConfigurationModal(false); + }} /> + + ); +}; + +CollectorsAdministration.propTypes = { + sidecarCollectorPairs: PropTypes.array.isRequired, + collectors: PropTypes.array.isRequired, + configurations: PropTypes.array.isRequired, + pagination: PropTypes.object.isRequired, + query: PropTypes.string.isRequired, + filters: PropTypes.object.isRequired, + onPageChange: PropTypes.func.isRequired, + onFilter: PropTypes.func.isRequired, + onQueryChange: PropTypes.func.isRequired, + onConfigurationChange: PropTypes.func.isRequired, + onProcessAction: PropTypes.func.isRequired, +}; + +export default CollectorsAdministration; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.jsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.jsx deleted file mode 100644 index 20b41a1a937d..000000000000 --- a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.jsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import React from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; - -import { ButtonToolbar } from 'components/bootstrap'; - -import CollectorConfigurationSelector from './CollectorConfigurationSelector'; -import CollectorProcessControl from './CollectorProcessControl'; - -const CollectorsAdministrationActions = createReactClass({ - propTypes: { - collectors: PropTypes.array.isRequired, - configurations: PropTypes.array.isRequired, - selectedSidecarCollectorPairs: PropTypes.array.isRequired, - onConfigurationSelectionChange: PropTypes.func.isRequired, - onProcessAction: PropTypes.func.isRequired, - }, - - render() { - const { collectors, configurations, selectedSidecarCollectorPairs, onConfigurationSelectionChange, onProcessAction } = this.props; - - return ( - - - - - ); - }, -}); - -export default CollectorsAdministrationActions; diff --git a/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.tsx b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.tsx new file mode 100644 index 000000000000..c7d247195020 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/administration/CollectorsAdministrationActions.tsx @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import lodash from 'lodash'; +import styled from 'styled-components'; + +import { ButtonToolbar, Button } from 'components/bootstrap'; +import { Icon } from 'components/common'; + +import CollectorConfigurationModalContainer from './CollectorConfigurationModalContainer'; +import CollectorProcessControl from './CollectorProcessControl'; + +import type { Collector, Configuration, SidecarCollectorPairType } from '../types'; + +const ConfigurationButton = styled(Button)` + margin-right: 6px +`; + +type Props = { + collectors: Collector[], + configurations: Configuration[], + selectedSidecarCollectorPairs: SidecarCollectorPairType[], + onConfigurationSelectionChange: (pairs: SidecarCollectorPairType[], configs: Configuration[], callback: () => void) => void, + onProcessAction: (action: string, pairs: SidecarCollectorPairType[], callback: () => void) => void, +}; + +const CollectorsAdministrationActions = ({ + collectors, + configurations, + selectedSidecarCollectorPairs, + onConfigurationSelectionChange, + onProcessAction, +}: Props) => { + const [showConfigurationModal, setShowConfigurationModal] = useState(false); + const onCancelConfigurationModal = useCallback(() => setShowConfigurationModal(false), []); + + const selectedLogCollectorsNames = lodash.uniq(selectedSidecarCollectorPairs.map(({ collector }) => collector.name)); + const configButtonTooltip = `Cannot change configurations of ${selectedLogCollectorsNames.join(', ')} collectors simultaneously`; + + return ( + + 1) ? configButtonTooltip : undefined} + bsStyle="primary" + bsSize="small" + disabled={selectedLogCollectorsNames.length !== 1} + onClick={() => setShowConfigurationModal(true)}> + Assign Configurations + + + + + ); +}; + +CollectorsAdministrationActions.propTypes = { + collectors: PropTypes.array.isRequired, + configurations: PropTypes.array.isRequired, + selectedSidecarCollectorPairs: PropTypes.array.isRequired, + onConfigurationSelectionChange: PropTypes.func.isRequired, + onProcessAction: PropTypes.func.isRequired, +}; + +export default CollectorsAdministrationActions; diff --git a/graylog2-web-interface/src/components/sidecars/common/ColorLabel.tsx b/graylog2-web-interface/src/components/sidecars/common/ColorLabel.tsx index e8df1a35bd99..de8bb63acda4 100644 --- a/graylog2-web-interface/src/components/sidecars/common/ColorLabel.tsx +++ b/graylog2-web-interface/src/components/sidecars/common/ColorLabel.tsx @@ -33,7 +33,7 @@ interface ColorLabelProps { color: string, size?: Size, text?: string | React.ReactNode, - theme: DefaultTheme + theme: DefaultTheme, } const ColorLabelWrap = styled.span(({ size, theme }: ColorLabelWrapProps) => { @@ -51,11 +51,16 @@ const ColorLabel = ({ color, size, text, theme }: ColorLabelProps) => { const textColor = theme.utils.contrastingColor(color); return ( - + diff --git a/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.jsx b/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.jsx deleted file mode 100644 index c2b5a45a040e..000000000000 --- a/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.jsx +++ /dev/null @@ -1,378 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import PropTypes from 'prop-types'; -import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import createReactClass from 'create-react-class'; -import Reflux from 'reflux'; -import lodash from 'lodash'; - -import { ColorPickerPopover, FormSubmit, Select, SourceCodeEditor } from 'components/common'; -import { Button, Col, ControlLabel, FormControl, FormGroup, HelpBlock, Row, Input } from 'components/bootstrap'; -import history from 'util/History'; -import Routes from 'routing/Routes'; -import ColorLabel from 'components/sidecars/common/ColorLabel'; -import { CollectorConfigurationsActions } from 'stores/sidecars/CollectorConfigurationsStore'; -import { CollectorsActions, CollectorsStore } from 'stores/sidecars/CollectorsStore'; - -import SourceViewModal from './SourceViewModal'; -import ImportsViewModal from './ImportsViewModal'; - -const ConfigurationForm = createReactClass({ - // eslint-disable-next-line react/no-unused-class-component-methods - displayName: 'ConfigurationForm', - - // eslint-disable-next-line react/no-unused-class-component-methods - propTypes: { - action: PropTypes.oneOf(['create', 'edit']), - configuration: PropTypes.object, - configurationSidecars: PropTypes.object, - }, - - mixins: [Reflux.connect(CollectorsStore)], - - getDefaultProps() { - return { - action: 'edit', - configuration: { - color: '#FFFFFF', - template: '', - }, - configurationSidecars: {}, - }; - }, - - getInitialState() { - const { configuration } = this.props; - - return { - error: false, - validation_errors: {}, - formData: { - id: configuration.id, - name: configuration.name, - color: configuration.color, - collector_id: configuration.collector_id, - template: configuration.template || '', - }, - }; - }, - - UNSAFE_componentWillMount() { - this._debouncedValidateFormData = lodash.debounce(this._validateFormData, 200); - }, - - componentDidMount() { - CollectorsActions.all(); - }, - - defaultTemplates: {}, - - _isTemplateSet(template) { - return template !== undefined && template !== ''; - }, - - _hasErrors() { - const { error, formData } = this.state; - - return error || !this._isTemplateSet(formData.template); - }, - - _validateFormData(nextFormData, checkForRequiredFields) { - CollectorConfigurationsActions.validate(nextFormData).then((validation) => { - const nextValidation = lodash.clone(validation); - - if (checkForRequiredFields && !this._isTemplateSet(nextFormData.template)) { - nextValidation.errors.template = ['Please fill out the configuration field.']; - nextValidation.failed = true; - } - - this.setState({ validation_errors: nextValidation.errors, error: nextValidation.failed }); - }); - }, - - _save() { - const { action } = this.props; - const { formData } = this.state; - - if (this._hasErrors()) { - // Ensure we display an error on the template field, as this is not validated by the browser - this._validateFormData(formData, true); - - return; - } - - if (action === 'create') { - CollectorConfigurationsActions.createConfiguration(formData) - .then(() => history.push(Routes.SYSTEM.SIDECARS.CONFIGURATION)); - } else { - CollectorConfigurationsActions.updateConfiguration(formData); - } - }, - - _formDataUpdate(key) { - const { formData } = this.state; - - return (nextValue, _, hideCallback) => { - const nextFormData = lodash.cloneDeep(formData); - - nextFormData[key] = nextValue; - this._debouncedValidateFormData(nextFormData, false); - this.setState({ formData: nextFormData }, hideCallback); - }; - }, - - // eslint-disable-next-line react/no-unused-class-component-methods - replaceConfigurationVariableName(oldname, newname) { - const { formData } = this.state; - - if (oldname === '' || oldname === newname) { - return; - } - - // replaceAll without having to use a Regex - const updatedTemplate = formData.template.split(`\${user.${oldname}}`).join(`\${user.${newname}}`); - - this._onTemplateChange(updatedTemplate); - }, - - _onNameChange(event) { - const nextName = event.target.value; - - this._formDataUpdate('name')(nextName); - }, - - _getCollectorDefaultTemplate(collectorId) { - const storedTemplate = this.defaultTemplates[collectorId]; - - if (storedTemplate !== undefined) { - // eslint-disable-next-line no-promise-executor-return - return new Promise((resolve) => resolve(storedTemplate)); - } - - return CollectorsActions.getCollector(collectorId).then((collector) => { - this.defaultTemplates[collectorId] = collector.default_template; - - return collector.default_template; - }); - }, - - _onCollectorChange(nextId) { - const { formData } = this.state; - - // Start loading the request to get the default template, so it is available asap. - const defaultTemplatePromise = this._getCollectorDefaultTemplate(nextId); - - const nextFormData = lodash.cloneDeep(formData); - - nextFormData.collector_id = nextId; - - // eslint-disable-next-line no-alert - if (!nextFormData.template || window.confirm('Do you want to use the default template for the selected Configuration?')) { - // Wait for the promise to resolve and then update the whole formData state - defaultTemplatePromise.then((defaultTemplate) => { - this._onTemplateChange(defaultTemplate); - nextFormData.template = defaultTemplate; - }); - } - - this.setState({ formData: nextFormData }); - }, - - _onTemplateImport(nextTemplate) { - const { formData } = this.state; - - const nextFormData = lodash.cloneDeep(formData); - - // eslint-disable-next-line no-alert - if (!nextFormData.template || window.confirm('Do you want to overwrite your current work with this Configuration?')) { - this._onTemplateChange(nextTemplate); - } - }, - - _onTemplateChange(nextTemplate) { - this._formDataUpdate('template')(nextTemplate); - }, - - _onSubmit(event) { - event.preventDefault(); - this._save(); - }, - - _onCancel() { - history.goBack(); - }, - - _onShowSource() { - this.previewModal.open(); - }, - - _onShowImports() { - this.uploadsModal.open(); - }, - - _formatCollector(collector) { - return collector ? `${collector.name} on ${lodash.upperFirst(collector.node_operating_system)}` : 'Unknown collector'; - }, - - _formatCollectorOptions() { - const { collectors } = this.state; - - const options = []; - - if (collectors) { - collectors.forEach((collector) => { - options.push({ value: collector.id, label: this._formatCollector(collector) }); - }); - } else { - options.push({ value: 'none', label: 'Loading collector list...', disable: true }); - } - - return options; - }, - // eslint-disable-next-line react/no-unstable-nested-components - _formatValidationMessage(fieldName, defaultText) { - const { validation_errors: validationErrors } = this.state; - - if (validationErrors[fieldName]) { - return {validationErrors[fieldName][0]}; - } - - return {defaultText}; - }, - - _validationState(fieldName) { - const { validation_errors: validationErrors } = this.state; - - if (validationErrors[fieldName]) { - return 'error'; - } - - return null; - }, - // eslint-disable-next-line react/no-unstable-nested-components - _renderCollectorTypeField(collectorId, collectors, configurationSidecars) { - const isConfigurationInUse = configurationSidecars.sidecar_ids && configurationSidecars.sidecar_ids.length > 0; - - if (isConfigurationInUse) { - const collector = collectors ? collectors.find((c) => c.id === collectorId) : undefined; - - return ( - - {this._formatCollector(collector)} - - Note: Log Collector cannot change while the Configuration is in use. Clone the Configuration - to test it using another Collector. - - - ); - } - - return ( - - - - - Configuration color -
- -
- Change color} - onChange={this._formDataUpdate('color')} /> -
-
- Choose a color to use for this configuration. -
- - - Collector - {this._renderCollectorTypeField(formData.collector_id, collectors, configurationSidecars)} - - - - Configuration - - - - - {this._formatValidationMessage('template', 'Required. Collector configuration, see quick reference for more information.')} - - - - - - - - - - - { this.previewModal = c; }} - templateString={formData.template} /> - { this.uploadsModal = c; }} - onApply={this._onTemplateImport} /> - - ); - }, -}); - -export default ConfigurationForm; diff --git a/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.tsx b/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.tsx new file mode 100644 index 000000000000..7025dbb693d9 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationForm.tsx @@ -0,0 +1,386 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import PropTypes from 'prop-types'; +import React, { useState, useRef, useEffect } from 'react'; +import lodash from 'lodash'; + +import history from 'util/History'; +import { ColorPickerPopover, FormSubmit, Select, SourceCodeEditor } from 'components/common'; +import { Button, Col, ControlLabel, FormControl, FormGroup, HelpBlock, Row, Input } from 'components/bootstrap'; +import Routes from 'routing/Routes'; +import ColorLabel from 'components/sidecars/common/ColorLabel'; +import { CollectorConfigurationsActions } from 'stores/sidecars/CollectorConfigurationsStore'; +import { CollectorsActions } from 'stores/sidecars/CollectorsStore'; +import ConfigurationHelper from 'components/sidecars/configuration-forms/ConfigurationHelper'; + +import SourceViewModal from './SourceViewModal'; +import ImportsViewModal from './ImportsViewModal'; +import ConfigurationTagsSelect from './ConfigurationTagsSelect'; + +import type { Collector, Configuration, ConfigurationSidecarsResponse } from '../types'; + +type Props = { + action: string, + configuration: Configuration, + configurationSidecars: ConfigurationSidecarsResponse, +}; + +const ConfigurationForm = ({ + action, + configuration, + configurationSidecars, +}: Props) => { + const initFormData = { + id: configuration.id, + name: configuration.name, + color: configuration.color, + collector_id: configuration.collector_id, + template: configuration.template || '', + tags: configuration.tags || [], + }; + + const [collectors, setCollectors] = useState([]); + const [formData, setFormData] = useState(initFormData); + const [error, setError] = useState(false); + const [validationErrors, setValidationErrors] = useState({}); + const previewModal = useRef(null); + const uploadsModal = useRef(null); + const defaultTemplates = useRef({}); + + useEffect(() => { + CollectorsActions.all().then((response) => setCollectors(response.collectors)); + }, []); + + const _isTemplateSet = (template) => { + return template !== undefined && template !== ''; + }; + + const _hasErrors = () => { + return error || !_isTemplateSet(formData.template); + }; + + const _validateFormData = (nextFormData, checkForRequiredFields) => { + CollectorConfigurationsActions.validate(nextFormData).then((validation) => { + const nextValidation = lodash.clone(validation); + + if (checkForRequiredFields && !_isTemplateSet(nextFormData.template)) { + nextValidation.errors.template = ['Please fill out the configuration field.']; + nextValidation.failed = true; + } + + setValidationErrors(nextValidation.errors); + setError(nextValidation.failed); + }); + }; + + const _save = () => { + if (_hasErrors()) { + // Ensure we display an error on the template field, as this is not validated by the browser + _validateFormData(formData, true); + + return; + } + + if (action === 'create') { + CollectorConfigurationsActions.createConfiguration(formData) + .then(() => history.push(Routes.SYSTEM.SIDECARS.CONFIGURATION)); + } else { + CollectorConfigurationsActions.updateConfiguration(formData); + } + }; + + const _debouncedValidateFormData = lodash.debounce(_validateFormData, 200); + + const _formDataUpdate = (key) => { + return (nextValue, _?: React.ChangeEvent, hideCallback?: () => void) => { + const nextFormData = lodash.cloneDeep(formData); + + nextFormData[key] = nextValue; + _debouncedValidateFormData(nextFormData, false); + setFormData(nextFormData); + + if (hideCallback) { + hideCallback(); + } + }; + }; + + const _onTemplateChange = (nextTemplate) => { + _formDataUpdate('template')(nextTemplate); + }; + + const replaceConfigurationVariableName = (oldname: string, newname: string) => { + if (oldname === '' || oldname === newname) { + return; + } + + // replaceAll without having to use a Regex + const updatedTemplate = formData.template.split(`\${user.${oldname}}`).join(`\${user.${newname}}`); + + _onTemplateChange(updatedTemplate); + }; + + const _onNameChange = (event) => { + const nextName = event.target.value; + + _formDataUpdate('name')(nextName); + }; + + const _onTagsChange = (nextTags) => { + const nextTagsArray = nextTags.split(','); + + _formDataUpdate('tags')(nextTagsArray); + }; + + const _getCollectorDefaultTemplate = (collectorId) => { + const storedTemplate = defaultTemplates.current[collectorId]; + + if (storedTemplate !== undefined) { + // eslint-disable-next-line no-promise-executor-return + return new Promise((resolve) => resolve(storedTemplate)); + } + + return CollectorsActions.getCollector(collectorId).then((collector) => { + defaultTemplates.current[collectorId] = collector.default_template; + + return collector.default_template; + }); + }; + + const _onCollectorChange = async (nextId) => { + // Start loading the request to get the default template, so it is available asap. + const defaultTemplate = await _getCollectorDefaultTemplate(nextId); + + const nextFormData = lodash.cloneDeep(formData); + + nextFormData.collector_id = nextId; + + // eslint-disable-next-line no-alert + if (!nextFormData.template || window.confirm('Do you want to use the default template for the selected Configuration?')) { + _onTemplateChange(defaultTemplate); + nextFormData.template = defaultTemplate; + } + + setFormData(nextFormData); + }; + + const _onTemplateImport = (nextTemplate) => { + const nextFormData = lodash.cloneDeep(formData); + + // eslint-disable-next-line no-alert + if (!nextFormData.template || window.confirm('Do you want to overwrite your current work with this Configuration?')) { + _onTemplateChange(nextTemplate); + } + }; + + const _onSubmit = (event) => { + event.preventDefault(); + _save(); + }; + + const _onCancel = () => { + history.goBack(); + }; + + const _onShowSource = () => { + previewModal.current.open(); + }; + + const _onShowImports = () => { + uploadsModal.current.open(); + }; + + const _formatCollector = (collector) => { + return collector ? `${collector.name} on ${lodash.upperFirst(collector.node_operating_system)}` : 'Unknown collector'; + }; + + const _formatCollectorOptions = () => { + const options = []; + + if (collectors) { + collectors.forEach((collector) => { + options.push({ value: collector.id, label: _formatCollector(collector) }); + }); + } else { + options.push({ value: 'none', label: 'Loading collector list...', disable: true }); + } + + return options; + }; + + const _formatValidationMessage = (fieldName, defaultText) => { + if (validationErrors[fieldName]) { + return {validationErrors[fieldName][0]}; + } + + return {defaultText}; + }; + + const _validationState = (fieldName) => { + if (validationErrors[fieldName]) { + return 'error'; + } + + return null; + }; + + const _renderCollectorTypeField = (collectorId, _collectors, _configurationSidecars) => { + const isConfigurationInUse = _configurationSidecars.sidecar_ids && _configurationSidecars.sidecar_ids.length > 0; + + if (isConfigurationInUse) { + const collector = _collectors ? _collectors.find((c) => c.id === collectorId) : undefined; + + return ( + + {_formatCollector(collector)} + + Note: Log Collector cannot change while the Configuration is in use. Clone the Configuration + to test it using another Collector. + + + ); + } + + return ( + + + + Configuration color +
+ +
+ Change color} + onChange={_formDataUpdate('color')} /> +
+
+ Choose a color to use for this configuration. +
+ + + Configuration Tags +
+ ({ name: tag }))} + tags={formData.tags} + onChange={_onTagsChange} /> +
+ Choose tags to use for this configuration. +
+ + + Collector + {_renderCollectorTypeField(formData.collector_id, collectors, configurationSidecars)} + + + + Configuration + {/* TODO: Figure out issue with props */} + {/* @ts-ignore */} + + + + + {_formatValidationMessage('template', 'Required. Collector configuration, see quick reference for more information.')} + + + + + + + + + + + + + + + + + + + ); +}; + +ConfigurationForm.propTypes = { + action: PropTypes.oneOf(['create', 'edit']), + configuration: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, + collector_id: PropTypes.string.isRequired, + template: PropTypes.string.isRequired, + tags: PropTypes.array.isRequired, + }), + configurationSidecars: PropTypes.object, +}; + +ConfigurationForm.defaultProps = { + action: 'edit', + configuration: { + color: '#FFFFFF', + template: '', + }, + configurationSidecars: {}, +}; + +export default ConfigurationForm; diff --git a/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationTagsSelect.tsx b/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationTagsSelect.tsx new file mode 100644 index 000000000000..0f3081f9ec4e --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/configuration-forms/ConfigurationTagsSelect.tsx @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import PropTypes from 'prop-types'; +import React from 'react'; + +import MultiSelect from 'components/common/MultiSelect'; + +type Props = { + tags: string[], + availableTags: { name: string }[], + onChange: (tagsAsString: string) => void, +}; + +const ConfigurationTagsSelect = ({ + tags, + availableTags, + onChange, +}: Props) => { + const tagsValue = tags.join(','); + const tagsOptions = availableTags.map((tag) => { + return { value: tag.name, label: tag.name }; + }); + + return ( + + ); +}; + +ConfigurationTagsSelect.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired, + availableTags: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default ConfigurationTagsSelect; diff --git a/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.jsx b/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.jsx deleted file mode 100644 index cbba698bb92b..000000000000 --- a/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import React from 'react'; - -import { Table } from 'components/bootstrap'; - -class TemplatesHelper extends React.Component { - _buildVariableName = (name) => { - return `\${sidecar.${name}}`; - }; - - render() { - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - -
NameDescription
{this._buildVariableName('operatingSystem')}Name of the operating system the sidecar is running on, e.g. "Linux", "Windows"
{this._buildVariableName('nodeName')}The name of the sidecar, defaults to hostname if not set.
{this._buildVariableName('nodeId')}UUID of the sidecar.
{this._buildVariableName('sidecarVersion')}Version string of the running sidecar.
-
- ); - } -} - -export default TemplatesHelper; diff --git a/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.tsx b/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.tsx new file mode 100644 index 000000000000..e6c7248061e9 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/configuration-forms/TemplatesHelper.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; + +import { Table } from 'components/bootstrap'; + +const TemplatesHelper = () => { + const _buildVariableName = (name) => { + return `\${sidecar.${name}}`; + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameDescription
{_buildVariableName('operatingSystem')}Name of the operating system the sidecar is running on, e.g. "Linux", "Windows"
{_buildVariableName('nodeName')}The name of the sidecar, defaults to hostname if not set.
{_buildVariableName('nodeId')}UUID of the sidecar.
{_buildVariableName('sidecarVersion')}Version string of the running sidecar.
{_buildVariableName('spoolDir')}A directory that is unique per configuration and can be used to store collector data.
{_buildVariableName('tags.')}A map of tags that are set for the sidecar. This can be used to render conditional configuration snippets. e.g.:
+ <#if sidecar.tags.webserver??>
  - /var/log/apache/*.log
</#if>
+
+
+ ); +}; + +export default TemplatesHelper; diff --git a/graylog2-web-interface/src/components/sidecars/types.d.ts b/graylog2-web-interface/src/components/sidecars/types.d.ts new file mode 100644 index 000000000000..a26bb9745aa7 --- /dev/null +++ b/graylog2-web-interface/src/components/sidecars/types.d.ts @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +export type Configuration = { + template: string; + color: string; + collector_id: string; + name: string; + id: string; + tags: string[]; +} + +export type Collector = { + service_type: string; + node_operating_system: string; + name: string; + validation_parameters: string; + executable_path: string; + execute_parameters: string; + default_template: string; + id: string; +} + +export type CollectorStatus = { + verbose_message: string; + collector_id: string; + message: string; + configuration_id: string; + status: number; +} + +export type ConfigurationAssignment = { + assigned_from_tags: string[]; + collector_id: string; + configuration_id: string; +} + +export type NodeLogFile = { + path: string; + mod_time: string; + size: number; + is_dir: boolean; +} + +export type SidecarSummary = { + node_details: NodeDetails; + assignments: ConfigurationAssignment[]; + collectors: string[]; + last_seen: string; + sidecar_version: string; + node_name: string; + active: boolean; + node_id: string; +} + +export type NodeMetrics = { + cpu_idle: number; + disks_75: string[]; + load_1: number; +} + +export type CollectorStatusList = { + collectors: CollectorStatus[]; + message: string; + status: number; +} + +export type NodeDetails = { + ip: string; + collector_configuration_directory: string; + operating_system: string; + metrics: NodeMetrics; + log_file_list: NodeLogFile[]; + status: CollectorStatusList; + tags: string[]; +} + +export type ConfigurationSidecarsResponse = { + sidecar_ids: string[]; + configuration_id: string; +} + +export type SidecarCollectorPairType = { + collector: Collector; + sidecar: SidecarSummary; +} diff --git a/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.jsx b/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.jsx deleted file mode 100644 index 4b160bc27ed8..000000000000 --- a/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.jsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import PropTypes from 'prop-types'; -import React from 'react'; -import createReactClass from 'create-react-class'; - -import { LinkContainer } from 'components/common/router'; -import { ButtonToolbar, Col, Row, Button } from 'components/bootstrap'; -import { DocumentTitle, PageHeader, Spinner } from 'components/common'; -import Routes from 'routing/Routes'; -import ConfigurationForm from 'components/sidecars/configuration-forms/ConfigurationForm'; -import ConfigurationHelper from 'components/sidecars/configuration-forms/ConfigurationHelper'; -import history from 'util/History'; -import withParams from 'routing/withParams'; -import { CollectorConfigurationsActions } from 'stores/sidecars/CollectorConfigurationsStore'; - -const SidecarEditConfigurationPage = createReactClass({ - displayName: 'SidecarEditConfigurationPage', - - propTypes: { - params: PropTypes.object.isRequired, - }, - - getInitialState() { - return { - configuration: undefined, - }; - }, - - componentDidMount() { - this._reloadConfiguration(); - }, - - _reloadConfiguration() { - const { configurationId } = this.props.params; - - CollectorConfigurationsActions.getConfiguration(configurationId).then( - (configuration) => { - this.setState({ configuration: configuration }); - - CollectorConfigurationsActions.getConfigurationSidecars(configurationId) - .then((configurationSidecars) => this.setState({ configurationSidecars: configurationSidecars })); - }, - (error) => { - if (error.status === 404) { - history.push(Routes.SYSTEM.SIDECARS.CONFIGURATION); - } - }, - ); - }, - - _isLoading() { - return !this.state.configuration || !this.state.configurationSidecars; - }, - - _variableRenameHandler(oldname, newname) { - this.configurationForm.replaceConfigurationVariableName(oldname, newname); - }, - - render() { - if (this._isLoading()) { - return ; - } - - return ( - - - - - Some words about collector configurations. - - - - Read more about the Graylog Sidecar in the documentation. - - - - - - - - - - - - - - - - - - { this.configurationForm = c; }} - configuration={this.state.configuration} - configurationSidecars={this.state.configurationSidecars} /> - - - - - - - - ); - }, -}); - -export default withParams(SidecarEditConfigurationPage); diff --git a/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.tsx b/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.tsx new file mode 100644 index 000000000000..1a4c502d2c00 --- /dev/null +++ b/graylog2-web-interface/src/pages/SidecarEditConfigurationPage.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import PropTypes from 'prop-types'; +import React, { useState, useEffect } from 'react'; + +import history from 'util/History'; +import { LinkContainer } from 'components/common/router'; +import { ButtonToolbar, Button } from 'components/bootstrap'; +import { DocumentTitle, PageHeader, Spinner } from 'components/common'; +import Routes from 'routing/Routes'; +import ConfigurationForm from 'components/sidecars/configuration-forms/ConfigurationForm'; +import withParams from 'routing/withParams'; +import { CollectorConfigurationsActions } from 'stores/sidecars/CollectorConfigurationsStore'; +import type { Configuration, ConfigurationSidecarsResponse } from 'components/sidecars/types'; + +const SidecarEditConfigurationPage = ({ params }) => { + const [configuration, setConfiguration] = useState(null); + const [configurationSidecars, setConfigurationSidecars] = useState(null); + + useEffect(() => { + const _reloadConfiguration = () => { + const { configurationId } = params; + + CollectorConfigurationsActions.getConfiguration(configurationId).then( + (_configuration) => { + setConfiguration(_configuration); + + CollectorConfigurationsActions.getConfigurationSidecars(configurationId) + .then((_configurationSidecars) => setConfigurationSidecars(_configurationSidecars)); + }, + (error) => { + if (error.status === 404) { + history.push(Routes.SYSTEM.SIDECARS.CONFIGURATION); + } + }, + ); + }; + + _reloadConfiguration(); + }, [params]); + + const _isLoading = () => { + return !configuration || !configurationSidecars; + }; + + if (_isLoading()) { + return ; + } + + return ( + + + + + Some words about collector configurations. + + + + Read more about the Graylog Sidecar in the documentation. + + + + + + + + + + + + + + + + + + ); +}; + +SidecarEditConfigurationPage.propTypes = { + params: PropTypes.object.isRequired, +}; + +export default withParams(SidecarEditConfigurationPage); diff --git a/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.jsx b/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.jsx deleted file mode 100644 index a193e946d97a..000000000000 --- a/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.jsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import React from 'react'; -import createReactClass from 'create-react-class'; - -import { LinkContainer } from 'components/common/router'; -import { ButtonToolbar, Col, Row, Button } from 'components/bootstrap'; -import { DocumentTitle, PageHeader } from 'components/common'; -import Routes from 'routing/Routes'; -import ConfigurationForm from 'components/sidecars/configuration-forms/ConfigurationForm'; -import ConfigurationHelper from 'components/sidecars/configuration-forms/ConfigurationHelper'; - -const SidecarNewConfigurationPage = createReactClass({ - displayName: 'SidecarNewConfigurationPage', - - _variableRenameHandler(oldname, newname) { - this.configurationForm.replaceConfigurationVariableName(oldname, newname); - }, - - render() { - return ( - - - - - Some words about collector configurations. - - - - Read more about the Graylog Sidecar in the documentation. - - - - - - - - - - - - - - - - - - { this.configurationForm = c; }} - action="create" /> - - - - - - - - ); - }, -}); - -export default SidecarNewConfigurationPage; diff --git a/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.tsx b/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.tsx new file mode 100644 index 000000000000..a3b97a72ada3 --- /dev/null +++ b/graylog2-web-interface/src/pages/SidecarNewConfigurationPage.tsx @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; + +import { LinkContainer } from 'components/common/router'; +import { ButtonToolbar, Button } from 'components/bootstrap'; +import { DocumentTitle, PageHeader } from 'components/common'; +import Routes from 'routing/Routes'; +import ConfigurationForm from 'components/sidecars/configuration-forms/ConfigurationForm'; + +const SidecarNewConfigurationPage = () => { + return ( + + + + + Some words about collector configurations. + + + + Read more about the Graylog Sidecar in the documentation. + + + + + + + + + + + + + + + + + + ); +}; + +export default SidecarNewConfigurationPage;