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;