diff --git a/changelog/unreleased/pr-26840.toml b/changelog/unreleased/pr-26840.toml new file mode 100644 index 000000000000..2411a6ee61a2 --- /dev/null +++ b/changelog/unreleased/pr-26840.toml @@ -0,0 +1,5 @@ +type = "a" +message = "Expose Collector health in the user interface." + +issues = ["26800"] +pulls = ["26840"] diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java index a59526ff1baa..f7a2fc836bcb 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java @@ -35,6 +35,7 @@ import com.mongodb.client.model.Updates; import com.mongodb.client.result.InsertOneResult; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.apache.commons.lang3.StringUtils; @@ -42,8 +43,10 @@ import org.bson.Document; import org.bson.conversions.Bson; import org.graylog.collectors.db.Attribute; +import org.graylog.collectors.db.CollectorHealthDTO; import org.graylog.collectors.db.CollectorInstanceDTO; import org.graylog.collectors.db.CollectorInstanceReport; +import org.graylog.collectors.db.ComponentHealthDTO; import org.graylog.collectors.events.CollectorInstanceCertsChangedEvent; import org.graylog.collectors.opamp.IssuedCertificate; import org.graylog2.database.MongoCollection; @@ -85,6 +88,7 @@ import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_CERTIFICATES_ROTATED_AT; import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_ENROLLMENT_TOKEN_ID; import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_FLEET_ID; +import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_HEALTH; import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_IDENTIFYING_ATTRIBUTES; import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_INSTANCE_UID; import static org.graylog.collectors.db.CollectorInstanceDTO.FIELD_ISSUING_CA_ID; @@ -113,7 +117,7 @@ public class CollectorInstanceService { private final MongoCollection collection; private final MongoPaginationHelper paginationHelper; - private final com.mongodb.client.MongoCollection projectedCollection; + private final com.mongodb.client.MongoCollection reportUpdateCollection; private final MongoCollections mongoCollections; private final ClusterEventBus clusterEventBus; @@ -122,7 +126,7 @@ public class CollectorInstanceService { @Inject public CollectorInstanceService(MongoCollections mongoCollections, ClusterEventBus clusterEventBus, Clock clock) { collection = mongoCollections.collection(COLLECTION_NAME, CollectorInstanceDTO.class); - projectedCollection = mongoCollections.nonEntityCollection(COLLECTION_NAME, MinimalCollectorInstanceDTO.class); + reportUpdateCollection = mongoCollections.nonEntityCollection(COLLECTION_NAME, ReportUpdateState.class); paginationHelper = mongoCollections.paginationHelper(collection); this.mongoCollections = mongoCollections; this.clusterEventBus = clusterEventBus; @@ -189,25 +193,61 @@ public MinimalCollectorInstanceDTO updateFromReport(CollectorInstanceReport upda updateOps.add(set(FIELD_NON_IDENTIFYING_ATTRIBUTES, update.nonIdentifyingAttributes().get())); } + final var projectedFields = new ArrayList(); + projectedFields.add(Projections.include(FIELD_MESSAGE_SEQ_NUM, FIELD_LAST_PROCESSED_TXN_SEQ, FIELD_FLEET_ID)); + projectedFields.add( + Projections.elemMatch(FIELD_NON_IDENTIFYING_ATTRIBUTES, Filters.eq(Attribute.FIELD_KEY, OS_TYPE_KEY))); + // on an incoming health report, request the original health field, to determine if it needs to be updated + if (update.health().isPresent()) { + projectedFields.add(Projections.include(FIELD_HEALTH)); + } + // we request the ReturnDocument.BEFORE here to avoid having to load the previous document in full just // to retrieve the previous `message_seq_num`, which we need to determine what to do next. // the result is not the full CollectorInstanceDTO as we have it, but the minimal set of fields necessary to // determine next steps - final var previousInstanceDto = projectedCollection.findOneAndUpdate(Filters.eq(FIELD_INSTANCE_UID, update.instanceUid()), + final var previousInstanceDto = reportUpdateCollection.findOneAndUpdate(Filters.eq(FIELD_INSTANCE_UID, update.instanceUid()), combine(updateOps), new FindOneAndUpdateOptions() .returnDocument(ReturnDocument.BEFORE) - .projection(Projections.fields( - Projections.include(FIELD_MESSAGE_SEQ_NUM, FIELD_LAST_PROCESSED_TXN_SEQ, FIELD_FLEET_ID), - Projections.elemMatch(FIELD_NON_IDENTIFYING_ATTRIBUTES, Filters.eq(Attribute.FIELD_KEY, OS_TYPE_KEY)) - ))); + .projection(Projections.fields(projectedFields)) + ); if (previousInstanceDto == null) { // If there was no existing document, the instance was not enrolled. throw new IllegalArgumentException("Instance not enrolled: " + update.instanceUid()); } - return previousInstanceDto; + if (update.health().isPresent()) { + final var previousComponentHealth = Optional.ofNullable(previousInstanceDto.health()) + .map(CollectorHealthDTO::componentHealth); + + // If health changed, update the document and set the changed_at timestamp correctly + if (!update.health().equals(previousComponentHealth)) { + final var healthUpdates = new ArrayList(); + healthUpdates.add(set(f("%s.%s", FIELD_HEALTH, CollectorHealthDTO.FIELD_COMPONENT_HEALTH), + update.health().get())); + + final var healthyChanged = !update.health().map(ComponentHealthDTO::healthy) + .equals(previousComponentHealth.map(ComponentHealthDTO::healthy)); + if (healthyChanged) { + healthUpdates.add( + set(f("%s.%s", FIELD_HEALTH, CollectorHealthDTO.FIELD_HEALTHY_CHANGED_AT), + Date.from(clock.instant()))); + } + + collection.updateOne(Filters.eq(FIELD_INSTANCE_UID, update.instanceUid()), + Updates.combine(healthUpdates)); + } + } + + return new MinimalCollectorInstanceDTO( + previousInstanceDto.id(), + previousInstanceDto.fleetId(), + previousInstanceDto.messageSeqNum(), + previousInstanceDto.lastProcessTxnSeq(), + previousInstanceDto.nonIdentifyingAttributes() + ); } /** @@ -656,6 +696,14 @@ private static CollectorOSType extractOSType(Stream attributes) { .orElse(CollectorOSType.UNKNOWN); } + record ReportUpdateState(@Id @JsonProperty(FIELD_ID) String id, + @JsonProperty(FIELD_FLEET_ID) String fleetId, + @JsonProperty(FIELD_MESSAGE_SEQ_NUM) long messageSeqNum, + @JsonProperty(FIELD_LAST_PROCESSED_TXN_SEQ) long lastProcessTxnSeq, + @JsonProperty(FIELD_NON_IDENTIFYING_ATTRIBUTES) List nonIdentifyingAttributes, + // health will only be populated by our projection if the incoming report included health + @JsonProperty(FIELD_HEALTH) @Nullable CollectorHealthDTO health) {} + public record MinimalCollectorInstanceDTO(@Id @JsonProperty(FIELD_ID) String id, @JsonProperty(FIELD_FLEET_ID) String fleetId, @JsonProperty(FIELD_MESSAGE_SEQ_NUM) long messageSeqNum, diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorHealthDTO.java b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorHealthDTO.java new file mode 100644 index 000000000000..0fda818f7aac --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorHealthDTO.java @@ -0,0 +1,64 @@ +/* + * 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.collectors.db; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.auto.value.AutoValue; +import org.graylog2.jackson.MongoInstantDeserializer; +import org.graylog2.jackson.MongoInstantSerializer; + +import java.time.Instant; + +@AutoValue +@JsonDeserialize(builder = CollectorHealthDTO.Builder.class) +public abstract class CollectorHealthDTO { + + public static final String FIELD_COMPONENT_HEALTH = "component_health"; + public static final String FIELD_HEALTHY_CHANGED_AT = "healthy_changed_at"; + + @JsonProperty(FIELD_HEALTHY_CHANGED_AT) + @JsonSerialize(using = MongoInstantSerializer.class) + public abstract Instant healthyChangedAt(); + + @JsonProperty(FIELD_COMPONENT_HEALTH) + public abstract ComponentHealthDTO componentHealth(); + + public static Builder builder() { + return Builder.create(); + } + + @AutoValue.Builder + public abstract static class Builder { + + @JsonCreator + public static Builder create() { + return new AutoValue_CollectorHealthDTO.Builder(); + } + + @JsonProperty(FIELD_HEALTHY_CHANGED_AT) + @JsonDeserialize(using = MongoInstantDeserializer.class) + public abstract Builder healthyChangedAt(Instant healthyChangedAt); + + @JsonProperty(FIELD_COMPONENT_HEALTH) + public abstract Builder componentHealth(ComponentHealthDTO componentHealth); + + public abstract CollectorHealthDTO build(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceDTO.java b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceDTO.java index bb876d03f1b4..8adff0b26149 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceDTO.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceDTO.java @@ -59,6 +59,7 @@ public abstract class CollectorInstanceDTO implements BuildableMongoEntity health(); + public static Builder builder() { return AutoValue_CollectorInstanceDTO.Builder.create(); } @@ -222,6 +226,9 @@ public static Builder create() { @JsonProperty(FIELD_ENROLLMENT_TOKEN_ID) public abstract Builder enrollmentTokenId(String enrollmentTokenId); + @JsonProperty(FIELD_HEALTH) + public abstract Builder health(@Nullable CollectorHealthDTO health); + public abstract CollectorInstanceDTO build(); } } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceReport.java b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceReport.java index d4577327e0e1..47249d45309e 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceReport.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/CollectorInstanceReport.java @@ -31,7 +31,8 @@ public record CollectorInstanceReport( OptionalLong lastProcessedTxnSeq, Instant lastSeen, Optional> identifyingAttributes, - Optional> nonIdentifyingAttributes + Optional> nonIdentifyingAttributes, + Optional health ) { @AutoBuilder public interface Builder { @@ -49,6 +50,8 @@ public interface Builder { Builder nonIdentifyingAttributes(List nonIdentifyingAttributes); + Builder health(ComponentHealthDTO health); + CollectorInstanceReport build(); } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/ComponentHealthDTO.java b/graylog2-server/src/main/java/org/graylog/collectors/db/ComponentHealthDTO.java new file mode 100644 index 000000000000..2476527251d5 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/ComponentHealthDTO.java @@ -0,0 +1,94 @@ +/* + * 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.collectors.db; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.auto.value.AutoValue; +import jakarta.annotation.Nullable; +import org.graylog2.jackson.MongoInstantDeserializer; +import org.graylog2.jackson.MongoInstantSerializer; + +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +@AutoValue +@JsonDeserialize(builder = ComponentHealthDTO.Builder.class) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public abstract class ComponentHealthDTO { + + @JsonProperty("healthy") + public abstract boolean healthy(); + + @JsonProperty("start_time") + @JsonSerialize(contentUsing = MongoInstantSerializer.class) + public abstract Optional startTime(); + + @JsonProperty("last_error") + public abstract Optional lastError(); + + @JsonProperty("status") + public abstract Optional status(); + + @JsonProperty("status_time") + @JsonSerialize(contentUsing = MongoInstantSerializer.class) + public abstract Optional statusTime(); + + @JsonProperty("components") + public abstract Map components(); + + public static Builder builder() { + return Builder.create(); + } + + @AutoValue.Builder + public abstract static class Builder { + + @JsonCreator + public static Builder create() { + return new AutoValue_ComponentHealthDTO.Builder() + .components(Collections.emptyMap()); + } + + @JsonProperty("healthy") + public abstract Builder healthy(boolean healthy); + + @JsonProperty("start_time") + @JsonDeserialize(using = MongoInstantDeserializer.class) + public abstract Builder startTime(@Nullable Instant startTime); + + @JsonProperty("last_error") + public abstract Builder lastError(@Nullable String lastError); + + @JsonProperty("status") + public abstract Builder status(@Nullable String status); + + @JsonProperty("status_time") + @JsonDeserialize(using = MongoInstantDeserializer.class) + public abstract Builder statusTime(@Nullable Instant statusTime); + + @JsonProperty("components") + public abstract Builder components(Map components); + + public abstract ComponentHealthDTO build(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java b/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java index 34872e615d48..1a18889abe4a 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java @@ -44,6 +44,7 @@ import opamp.proto.Opamp.ServerErrorResponse; import opamp.proto.Opamp.ServerToAgent; import opamp.proto.Opamp.TLSCertificate; +import org.apache.commons.lang3.StringUtils; import org.graylog.collectors.CollectorCaService; import org.graylog.collectors.CollectorInstanceService; import org.graylog.collectors.CollectorOSType; @@ -65,6 +66,7 @@ import org.graylog.collectors.config.receiver.NoopReceiverConfig; import org.graylog.collectors.db.Attribute; import org.graylog.collectors.db.CollectorInstanceReport; +import org.graylog.collectors.db.ComponentHealthDTO; import org.graylog.collectors.db.SourceDTO; import org.graylog.collectors.db.TransactionMarker; import org.graylog.collectors.opamp.auth.AgentTokenService; @@ -81,6 +83,7 @@ import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Base64; import java.util.EnumSet; @@ -91,6 +94,7 @@ import java.util.OptionalLong; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toCollection; @@ -499,6 +503,8 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo .addArgument(instanceUid).addArgument(sequenceNum) .addArgument(() -> toProtoString(message.getHealth())) .log(); + + updateBuilder.health(extractHealth(message.getHealth())); } } case AgentCapabilities_ReportsHeartbeat -> { @@ -613,6 +619,29 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo return responseBuilder.build(); } + private ComponentHealthDTO extractHealth(Opamp.ComponentHealth health) { + final var builder = ComponentHealthDTO.builder(); + + builder.healthy(health.getHealthy()); + if (health.getStartTimeUnixNano() > 0) { + builder.startTime(Instant.ofEpochSecond(0, health.getStartTimeUnixNano())); + } + if (StringUtils.isNotBlank(health.getLastError())) { + builder.lastError(health.getLastError()); + } + if (StringUtils.isNotBlank(health.getStatus())) { + builder.status(health.getStatus()); + } + if (health.getStatusTimeUnixNano() > 0) { + builder.statusTime(Instant.ofEpochSecond(0, health.getStatusTimeUnixNano())); + } + + builder.components(health.getComponentHealthMapMap().entrySet().stream().collect( + Collectors.toMap(Map.Entry::getKey, e -> extractHealth(e.getValue())))); + + return builder.build(); + } + /** * Handles a certificate renewal request sent by an already-authenticated collector inside the * Identified channel. diff --git a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstanceResponse.java b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstanceResponse.java index 7b3fb87e3848..a6689eaf7aee 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstanceResponse.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstanceResponse.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.Nullable; +import org.graylog.collectors.db.CollectorHealthDTO; import java.time.Instant; import java.util.Map; @@ -35,5 +36,6 @@ public record CollectorInstanceResponse( @JsonProperty("next_certificate_expires_at") @Nullable Instant nextCertificateExpiresAt, @JsonProperty("identifying_attributes") Map identifyingAttributes, @JsonProperty("non_identifying_attributes") Map nonIdentifyingAttributes, - @JsonProperty("has_pending_changes") boolean hasPendingChanges) { + @JsonProperty("has_pending_changes") boolean hasPendingChanges, + @JsonProperty("health") @Nullable CollectorHealthDTO health) { } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstancesResource.java b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstancesResource.java index c7628e298119..d3a2da577bb7 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstancesResource.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInstancesResource.java @@ -395,7 +395,8 @@ public Response reassignInstances(@Valid @NotNull @RequestBody(required = true, dto.nextCertificateExpiresAt().orElse(null), attributesToMap(dto.identifyingAttributes()), attributesToMap(dto.nonIdentifyingAttributes()), - hasPendingChanges + hasPendingChanges, + dto.health().orElse(null) ); } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/CollectorInstanceServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/CollectorInstanceServiceTest.java index 286f296625e9..8033c72c1e4e 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/CollectorInstanceServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/CollectorInstanceServiceTest.java @@ -27,6 +27,7 @@ import org.graylog.collectors.db.Attribute; import org.graylog.collectors.db.CollectorInstanceDTO; import org.graylog.collectors.db.CollectorInstanceReport; +import org.graylog.collectors.db.ComponentHealthDTO; import org.graylog.collectors.events.CollectorInstanceCertsChangedEvent; import org.graylog.security.pki.Algorithm; import org.graylog.security.pki.CertificateBuilder; @@ -1004,6 +1005,185 @@ void updateFromReportStoresLastSeenAsDate() throws Exception { assertFieldIsDate("date-uid", CollectorInstanceDTO.FIELD_LAST_SEEN); } + // ----- health snapshot + healthy_changed_at handling in updateFromReport ----- + + @Test + void updateFromReportFirstHealthReportCreatesEnvelopeWithTimestamp() throws Exception { + final var uid = "health-first"; + enroll(uid); + + final var t0 = Instant.parse("2025-06-01T10:00:00Z"); + clock.setInstant(t0); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(true, null))); + + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> { + assertThat(stored.healthyChangedAt()).isEqualTo(t0); + assertThat(stored.componentHealth()).isEqualTo(health(true, null)); + })); + + // The timestamp must be stored as a BSON Date, not a String. + final var healthDoc = findRawDocument(uid).orElseThrow() + .get(CollectorInstanceDTO.FIELD_HEALTH, Document.class); + assertThat(healthDoc.get("healthy_changed_at")).isInstanceOf(Date.class); + } + + @Test + void updateFromReportIdenticalHealthResendKeepsTimestamp() throws Exception { + // Reconnects and ReportFullState re-deliver unchanged health; the transition timestamp + // must not move ("unhealthy since X" would otherwise reset on every reconnect). + final var uid = "health-resend"; + enroll(uid); + + final var t0 = Instant.parse("2025-06-01T10:00:00Z"); + clock.setInstant(t0); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(false, "connection refused"))); + + clock.setInstant(t0.plus(Duration.ofHours(1))); + collectorInstanceService.updateFromReport(healthReport(uid, 2L, health(false, "connection refused"))); + + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> { + assertThat(stored.healthyChangedAt()).isEqualTo(t0); + assertThat(stored.componentHealth()).isEqualTo(health(false, "connection refused")); + })); + } + + @Test + void updateFromReportHealthyFlipUpdatesTreeAndTimestamp() throws Exception { + final var uid = "health-flip"; + enroll(uid); + + final var t0 = Instant.parse("2025-06-01T10:00:00Z"); + final var t1 = t0.plus(Duration.ofHours(1)); + clock.setInstant(t0); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(true, null))); + + clock.setInstant(t1); + collectorInstanceService.updateFromReport(healthReport(uid, 2L, health(false, "connection refused"))); + + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> { + assertThat(stored.healthyChangedAt()).isEqualTo(t1); + assertThat(stored.componentHealth()).isEqualTo(health(false, "connection refused")); + })); + } + + @Test + void updateFromReportTreeChangeWithoutFlipUpdatesTreeButKeepsTimestamp() throws Exception { + // An unhealthy collector whose error text changes is still "unhealthy since t0" — the + // snapshot must refresh, the transition timestamp must not. + final var uid = "health-no-flip"; + enroll(uid); + + final var t0 = Instant.parse("2025-06-01T10:00:00Z"); + clock.setInstant(t0); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(false, "error A"))); + + clock.setInstant(t0.plus(Duration.ofHours(1))); + collectorInstanceService.updateFromReport(healthReport(uid, 2L, health(false, "error B"))); + + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> { + assertThat(stored.healthyChangedAt()).isEqualTo(t0); + assertThat(stored.componentHealth()).isEqualTo(health(false, "error B")); + })); + } + + @Test + void updateFromReportWithoutHealthLeavesStoredHealthUntouched() throws Exception { + // Health is compression-eligible in OpAMP: a message without the field means "unchanged", + // never "no health". + final var uid = "health-absent"; + enroll(uid); + + final var t0 = Instant.parse("2025-06-01T10:00:00Z"); + clock.setInstant(t0); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(false, "connection refused"))); + + clock.setInstant(t0.plus(Duration.ofHours(1))); + collectorInstanceService.updateFromReport(healthReport(uid, 2L, null)); + + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> { + assertThat(stored.healthyChangedAt()).isEqualTo(t0); + assertThat(stored.componentHealth()).isEqualTo(health(false, "connection refused")); + })); + } + + @Test + void updateFromReportRoundTripsRecursiveHealthTree() throws Exception { + final var uid = "health-tree"; + enroll(uid); + + final var pipeline = ComponentHealthDTO.builder() + .healthy(false) + .status("StatusPermanentError") + .lastError("exporter failed") + .statusTime(Instant.parse("2025-06-01T09:59:00Z")) + .build(); + final var collector = ComponentHealthDTO.builder() + .healthy(false) + .status("StatusRecoverableError") + .startTime(Instant.parse("2025-06-01T08:00:00Z")) + .components(Map.of("pipeline:logs/abc", pipeline)) + .build(); + final var root = ComponentHealthDTO.builder() + .healthy(true) + .status("StatusOK") + .components(Map.of("collector", collector)) + .build(); + + clock.setInstant(Instant.parse("2025-06-01T10:00:00Z")); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, root)); + + // Fresh read exercises the full serialize/deserialize path including the recursion. + assertThat(collectorInstanceService.findByInstanceUid(uid)).hasValueSatisfying(instance -> + assertThat(instance.health()).hasValueSatisfying(stored -> + assertThat(stored.componentHealth()).isEqualTo(root))); + } + + @Test + void healthDocumentOmitsAbsentFieldsAndEmptyComponents() throws Exception { + // Guards the @JsonInclude(NON_EMPTY) convention on ComponentHealthDTO: absent optionals + // and empty components maps are omitted from the stored document — while healthy=false, + // being a boolean, must NOT be treated as "empty" (NON_DEFAULT would break this). + final var uid = "health-shape"; + enroll(uid); + + clock.setInstant(Instant.parse("2025-06-01T10:00:00Z")); + collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(false, "connection refused"))); + + final var componentHealthDoc = findRawDocument(uid).orElseThrow() + .get(CollectorInstanceDTO.FIELD_HEALTH, Document.class) + .get("component_health", Document.class); + + assertThat(componentHealthDoc.getBoolean("healthy")).isFalse(); + assertThat(componentHealthDoc.getString("last_error")).isEqualTo("connection refused"); + assertThat(componentHealthDoc.containsKey("status")).isFalse(); + assertThat(componentHealthDoc.containsKey("start_time")).isFalse(); + assertThat(componentHealthDoc.containsKey("status_time")).isFalse(); + assertThat(componentHealthDoc.containsKey("components")).isFalse(); + } + + private static CollectorInstanceReport healthReport(String uid, long seqNum, @jakarta.annotation.Nullable ComponentHealthDTO health) { + final var builder = CollectorInstanceReport.builder() + .instanceUid(uid) + .messageSeqNum(seqNum) + .capabilities(0L); + if (health != null) { + builder.health(health); + } + return builder.build(); + } + + private static ComponentHealthDTO health(boolean healthy, @jakarta.annotation.Nullable String lastError) { + return ComponentHealthDTO.builder() + .healthy(healthy) + .lastError(lastError) + .build(); + } + @Test void extractOsTypeFromReportReturnsCorrectOs() { final var report = reportWithAttributes(List.of( diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java new file mode 100644 index 000000000000..e17b273d904a --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java @@ -0,0 +1,241 @@ +/* + * 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.collectors.opamp; + +import com.google.protobuf.ByteString; +import opamp.proto.Opamp; +import opamp.proto.Opamp.AgentToServer; +import org.graylog.collectors.CollectorCaService; +import org.graylog.collectors.CollectorInstanceService; +import org.graylog.collectors.CollectorInstanceService.MinimalCollectorInstanceDTO; +import org.graylog.collectors.CollectorsConfigService; +import org.graylog.collectors.FleetTransactionLogService; +import org.graylog.collectors.SourceService; +import org.graylog.collectors.db.CollectorInstanceReport; +import org.graylog.collectors.opamp.auth.AgentTokenService; +import org.graylog.collectors.opamp.auth.EnrollmentTokenService; +import org.graylog.collectors.opamp.transport.OpAmpAuthContext; +import org.graylog.security.pki.CertificateService; +import org.graylog2.plugin.cluster.ClusterIdService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for how {@link OpAmpService#handleMessage} maps the OpAMP {@code ComponentHealth} payload + * into the {@link CollectorInstanceReport} that gets persisted. The message uses a deliberately + * non-consecutive sequence number, so the handler takes the ReportFullState short path and the + * test needs no transaction-log or config stubbing — the health extraction under test happens + * before that branch either way. + */ +@ExtendWith(MockitoExtension.class) +class OpAmpServiceHealthReportTest { + + private static final UUID INSTANCE_UUID = UUID.randomUUID(); + private static final String INSTANCE_UID = INSTANCE_UUID.toString(); + private static final long SEQUENCE_NUM = 10L; + + private static final OpAmpAuthContext.Identified AUTH = + new OpAmpAuthContext.Identified(INSTANCE_UID, OpAmpAuthContext.Transport.HTTP); + + @Mock + private EnrollmentTokenService enrollmentTokenService; + @Mock + private AgentTokenService agentTokenService; + @Mock + private CollectorCaService collectorCaService; + @Mock + private CertificateService certificateService; + @Mock + private CollectorInstanceService collectorInstanceService; + @Mock + private CollectorsConfigService collectorsConfigService; + @Mock + private ClusterIdService clusterIdService; + @Mock + private FleetTransactionLogService fleetTransactionLogService; + @Mock + private SourceService sourceService; + + private OpAmpService opAmpService; + + @BeforeEach + void setUp() { + lenient().when(clusterIdService.getString()).thenReturn("clusterId"); + // Previous seq 0 vs message seq 10: non-consecutive on purpose, see class javadoc. + when(collectorInstanceService.updateFromReport(any())).thenReturn( + new MinimalCollectorInstanceDTO("id-1", "fleet-A", 0L, 0L, null)); + opAmpService = new OpAmpService(enrollmentTokenService, agentTokenService, collectorCaService, + certificateService, collectorInstanceService, collectorsConfigService, clusterIdService, + fleetTransactionLogService, sourceService); + } + + @Test + void mapsTopLevelHealthFieldsIntoInstanceReport() { + final long startNanos = 1_700_000_000_000_000_000L; + final long statusNanos = 1_700_000_100_123_456_789L; + final var health = Opamp.ComponentHealth.newBuilder() + .setHealthy(false) + .setLastError("connection refused") + .setStatus("StatusRecoverableError") + .setStartTimeUnixNano(startNanos) + .setStatusTimeUnixNano(statusNanos) + .build(); + + opAmpService.handleMessage(messageWithHealth(health), AUTH); + + assertThat(capturedReport().health()).hasValueSatisfying(mapped -> { + assertThat(mapped.healthy()).isFalse(); + assertThat(mapped.lastError()).contains("connection refused"); + assertThat(mapped.status()).contains("StatusRecoverableError"); + assertThat(mapped.startTime()).contains(Instant.ofEpochSecond(0, startNanos)); + assertThat(mapped.statusTime()).contains(Instant.ofEpochSecond(0, statusNanos)); + assertThat(mapped.components()).isEmpty(); + }); + } + + @Test + void mapsComponentTreeRecursively() { + final var pipeline = Opamp.ComponentHealth.newBuilder() + .setHealthy(false) + .setStatus("StatusPermanentError") + .setLastError("exporter failed") + .build(); + final var collector = Opamp.ComponentHealth.newBuilder() + .setHealthy(false) + .setStatus("StatusRecoverableError") + .putComponentHealthMap("pipeline:logs/abc", pipeline) + .build(); + final var root = Opamp.ComponentHealth.newBuilder() + .setHealthy(true) + .putComponentHealthMap("collector", collector) + .build(); + + opAmpService.handleMessage(messageWithHealth(root), AUTH); + + assertThat(capturedReport().health()).hasValueSatisfying(mapped -> { + assertThat(mapped.healthy()).isTrue(); + assertThat(mapped.components()).containsOnlyKeys("collector"); + + final var mappedCollector = mapped.components().get("collector"); + assertThat(mappedCollector.healthy()).isFalse(); + assertThat(mappedCollector.status()).contains("StatusRecoverableError"); + assertThat(mappedCollector.components()).containsOnlyKeys("pipeline:logs/abc"); + + final var mappedPipeline = mappedCollector.components().get("pipeline:logs/abc"); + assertThat(mappedPipeline.healthy()).isFalse(); + assertThat(mappedPipeline.status()).contains("StatusPermanentError"); + assertThat(mappedPipeline.lastError()).contains("exporter failed"); + assertThat(mappedPipeline.components()).isEmpty(); + }); + } + + @Test + void normalizesProto3DefaultsToAbsent() { + // Proto3 defaults: empty strings and 0 timestamps. Per the OpAMP spec, a start time of 0 + // means "not running" — all of these must map to absent, not to empty/zero values. + final var health = Opamp.ComponentHealth.newBuilder() + .setHealthy(true) + .build(); + + opAmpService.handleMessage(messageWithHealth(health), AUTH); + + assertThat(capturedReport().health()).hasValueSatisfying(mapped -> { + assertThat(mapped.healthy()).isTrue(); + assertThat(mapped.lastError()).isEmpty(); + assertThat(mapped.status()).isEmpty(); + assertThat(mapped.startTime()).isEmpty(); + assertThat(mapped.statusTime()).isEmpty(); + assertThat(mapped.components()).isEmpty(); + }); + } + + @Test + void normalizesBlankStringsToAbsent() { + final var health = Opamp.ComponentHealth.newBuilder() + .setHealthy(true) + .setLastError(" ") + .setStatus(" ") + .build(); + + opAmpService.handleMessage(messageWithHealth(health), AUTH); + + assertThat(capturedReport().health()).hasValueSatisfying(mapped -> { + assertThat(mapped.lastError()).isEmpty(); + assertThat(mapped.status()).isEmpty(); + }); + } + + @Test + void leavesHealthEmptyWhenMessageCarriesNone() { + // ReportsHealth capability set, but no health field: OpAMP compression — the agent omits + // unchanged health. The report must not carry a health value (the stored one is kept). + final var message = message(Opamp.AgentCapabilities.AgentCapabilities_ReportsHealth_VALUE).build(); + + opAmpService.handleMessage(message, AUTH); + + assertThat(capturedReport().health()).isEmpty(); + } + + @Test + void ignoresHealthWithoutReportsHealthCapability() { + // Health is gated by the ReportsHealth capability; a payload without the capability bit + // must be ignored. + final var message = message(0L) + .setHealth(Opamp.ComponentHealth.newBuilder().setHealthy(true)) + .build(); + + opAmpService.handleMessage(message, AUTH); + + assertThat(capturedReport().health()).isEmpty(); + } + + private CollectorInstanceReport capturedReport() { + final var captor = ArgumentCaptor.forClass(CollectorInstanceReport.class); + verify(collectorInstanceService).updateFromReport(captor.capture()); + return captor.getValue(); + } + + private static AgentToServer messageWithHealth(Opamp.ComponentHealth health) { + return message(Opamp.AgentCapabilities.AgentCapabilities_ReportsHealth_VALUE) + .setHealth(health) + .build(); + } + + private static AgentToServer.Builder message(long capabilities) { + final ByteBuffer uid = ByteBuffer.allocate(16) + .putLong(INSTANCE_UUID.getMostSignificantBits()) + .putLong(INSTANCE_UUID.getLeastSignificantBits()); + return AgentToServer.newBuilder() + .setInstanceUid(ByteString.copyFrom(uid.array())) + .setSequenceNum(SEQUENCE_NUM) + .setCapabilities(capabilities); + } +} diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts index 153c4c9e38c1..edc8ed127646 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts @@ -91,6 +91,45 @@ describe('useInstance', () => { expect(Collectors.getInstance).not.toHaveBeenCalled(); }); + + // Polling is a background refresh and must not keep the session alive; the + // one-shot form above deliberately has no request options (extends as usual). + it('does not extend the session when polling', async () => { + asMock(Collectors.getInstance).mockResolvedValue(asInstanceResponse('uid-42')); + + const { result } = renderHook(() => useInstance('uid-42', { refetchInterval: 5000 })); + + await waitFor(() => expect(result.current.data).toBeTruthy()); + + expect(Collectors.getInstance).toHaveBeenCalledWith('uid-42', { requestShouldExtendSession: false }); + }); + + it('maps health to the view', async () => { + const health = { + healthy_changed_at: '2026-07-31T10:00:00.000+0000', + component_health: { healthy: false, last_error: 'connection refused' }, + }; + asMock(Collectors.getInstance).mockResolvedValue({ + ...dto('uid-42'), + health, + } as unknown as Awaited>); + + const { result } = renderHook(() => useInstance('uid-42')); + + await waitFor(() => expect(result.current.data).toBeTruthy()); + + expect(result.current.data.health).toEqual(health); + }); + + it('normalizes absent health to null', async () => { + asMock(Collectors.getInstance).mockResolvedValue(asInstanceResponse('uid-42')); + + const { result } = renderHook(() => useInstance('uid-42')); + + await waitFor(() => expect(result.current.data).toBeTruthy()); + + expect(result.current.data.health).toBeNull(); + }); }); describe('fetchPaginatedInstances session extension', () => { diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index 1c4fc842b35b..b7cd44634db0 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -53,6 +53,7 @@ const toView = (dto: ApiInstanceResponse): CollectorInstanceView => { os: (allAttributes?.['os.type'] as string) ?? null, version: (allAttributes?.['service.version'] as string) ?? null, has_pending_changes: dto.has_pending_changes, + health: dto.health ?? null, }; }; @@ -118,16 +119,33 @@ export const useInstances = (fleetId?: string, options: { refetchInterval?: numb refetchInterval: options.refetchInterval, }); -export const useInstance = (instanceUid: string | undefined) => { +export const useInstance = ( + instanceUid: string | undefined, + options: { refetchInterval?: number; silent?: boolean } = {}, +) => { const { data, isLoading, error, isError } = useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- silent only affects the error-reporting wrapper, not the cached data; callers deliberately share one cache entry regardless of the flag queryKey: [...INSTANCES_KEY_PREFIX, 'single', instanceUid], - queryFn: () => - defaultOnError( - Collectors.getInstance(instanceUid).then((response) => toView(response)), - 'Loading Collector instance failed with status', - 'Could not load Collector instance', - ), + queryFn: () => { + // Polling callers are background refreshes and must not keep the session + // alive (module convention, see NO_SESSION_EXT above); one-shot callers + // are user-initiated and extend it as usual. + const request = + options.refetchInterval !== undefined + ? Collectors.getInstance(instanceUid, NO_SESSION_EXT) + : Collectors.getInstance(instanceUid); + const promise = request.then((response) => toView(response)); + + return options.silent + ? promise + : defaultOnError( + promise, + 'Loading Collector instance failed with status', + 'Could not load Collector instance', + ); + }, enabled: !!instanceUid, + refetchInterval: options.refetchInterval, }); return { data, isLoading, error, isError }; diff --git a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.test.tsx b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.test.tsx index debfd468c241..d758118ed09d 100644 --- a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.test.tsx @@ -39,6 +39,7 @@ const baseInstance: CollectorInstanceView = { version: '1.2.0', status: 'online', has_pending_changes: false, + health: null, }; const fleetNames: Record = { diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceActions.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceActions.test.tsx index 346e513035e1..92f4f224b0f6 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceActions.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceActions.test.tsx @@ -55,6 +55,7 @@ const mockInstance: CollectorInstanceView = { version: '1.2.0', status: 'online', has_pending_changes: false, + health: null, }; const deleteInstanceMock = jest.fn(() => Promise.resolve()); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx index 365c995e5e26..5f5edb5e160b 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx @@ -23,10 +23,17 @@ import asMock from 'helpers/mocking/AsMock'; import InstanceDetailDrawer from './InstanceDetailDrawer'; import useInstancePendingChanges from '../hooks/useInstancePendingChanges'; +import { useInstance } from '../hooks'; import type { CollectorInstanceView, PendingChangesResponse, Source } from '../types'; jest.mock('../hooks/useInstancePendingChanges'); +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useInstance: jest.fn(), + useCollectorRefetchInterval: jest.fn(() => 5000), +})); + const mockInstance: CollectorInstanceView = { id: 'inst-1', instance_uid: 'uid-1', @@ -45,6 +52,7 @@ const mockInstance: CollectorInstanceView = { version: '1.2.0', status: 'online', has_pending_changes: false, + health: null, }; const mockSources: Source[] = [ @@ -83,6 +91,7 @@ const pendingChanges: PendingChangesResponse = { describe('InstanceDetailDrawer', () => { beforeEach(() => { asMock(useInstancePendingChanges).mockReturnValue({ data: undefined, isLoading: true, isError: false }); + asMock(useInstance).mockReturnValue({ data: undefined, isLoading: true, error: null, isError: false }); }); it('renders instance hostname as title', async () => { @@ -307,4 +316,59 @@ describe('InstanceDetailDrawer', () => { await screen.findByText(/and 1 other collector/i); expect(screen.queryByRole('link', { name: 'aaa-other-host' })).not.toBeInTheDocument(); }); + + it('renders the health section from the instance health', async () => { + const unhealthy: CollectorInstanceView = { + ...mockInstance, + health: { + healthy_changed_at: '2026-07-31T10:00:00.000+0000', + component_health: { healthy: false, last_error: 'connection refused' }, + }, + }; + + render( + , + ); + + await screen.findByText('Health'); + await screen.findByText('Unhealthy'); + await screen.findByText('connection refused'); + }); + + it('renders fresh instance data over the stale row snapshot', async () => { + asMock(useInstance).mockReturnValue({ + data: { + ...mockInstance, + status: 'offline', + health: { + healthy_changed_at: '2026-07-31T10:00:00.000+0000', + component_health: { healthy: false, last_error: 'connection refused' }, + }, + }, + isLoading: false, + error: null, + isError: false, + }); + + // The stale snapshot says online/no health; the polled data must win. + render( + , + ); + + await screen.findByText('Offline'); + await screen.findByText('Last known: Unhealthy'); + await screen.findByText('connection refused'); + }); + + it('falls back to the row snapshot while the instance query has no data', async () => { + asMock(useInstance).mockReturnValue({ data: undefined, isLoading: true, error: null, isError: false }); + + render( + , + ); + + await screen.findByText('Online'); + // Guards the polling wiring: silent (no toast spam) on the shared collector cadence. + expect(useInstance).toHaveBeenCalledWith('uid-1', { refetchInterval: 5000, silent: true }); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx index e2a14a04eb2b..4e994c999d39 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx @@ -25,13 +25,15 @@ import type { IconName } from 'components/common/Icon/types'; import Routes from 'routing/Routes'; import { naturalSortIgnoreCase } from 'util/SortUtils'; +import InstanceHealthSection from './InstanceHealthSection'; + import ActivityEntryList from '../common/ActivityEntryList'; import { IconRow, IconRowList } from '../common/IconRowList'; import SyncStateIndicator from '../common/SyncStateIndicator'; import collectorReceivedMessagesUrl from '../common/collectorReceivedMessagesUrl'; import { COLLECTOR_INSTANCE_UID_FIELD } from '../common/fields'; import collectorSystemLogsUrl from '../common/collectorSystemLogsUrl'; -import { useInstancePendingChanges } from '../hooks'; +import { useInstance, useInstancePendingChanges, useCollectorRefetchInterval } from '../hooks'; import type { CoalescedActions, CollectorInstanceView, Source, TargetInfo } from '../types'; type Props = { @@ -133,7 +135,13 @@ const pendingActions = (coalesced: CoalescedActions): PendingAction[] => { return actions; }; -const InstanceDetailDrawer = ({ instance, sources, fleetName, onClose }: Props) => { +const InstanceDetailDrawer = ({ instance: instanceProp, sources, fleetName, onClose }: Props) => { + const refetchInterval = useCollectorRefetchInterval(); + // The prop is a row snapshot frozen at drawer-open; poll the instance itself so + // Status, Last Seen, and Health stay live (same pattern as the sync section below). + // silent: errors here are non-fatal — we keep rendering the last known instance. + const { data: freshInstance } = useInstance(instanceProp.instance_uid, { refetchInterval, silent: true }); + const instance = freshInstance ?? instanceProp; const osDescription = (instance.non_identifying_attributes?.['os.description'] as string) ?? null; const { data: pendingDetail, isError: pendingError } = useInstancePendingChanges(instance.instance_uid); // Use the backend's authoritative flag (consistent with the table); fall back to the table row's @@ -220,6 +228,8 @@ const InstanceDetailDrawer = ({ instance, sources, fleetName, onClose }: Props) + +
Attributes diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx new file mode 100644 index 000000000000..b2304efb6703 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { render, screen } from 'wrappedTestingLibrary'; + +import InstanceHealthSection from './InstanceHealthSection'; + +import type { CollectorHealth } from '../types'; + +const health = (healthy: boolean, lastError?: string): CollectorHealth => ({ + healthy_changed_at: '2026-07-31T10:00:00.000+0000', + component_health: { healthy, ...(lastError === undefined ? {} : { last_error: lastError }) }, +}); + +describe('InstanceHealthSection', () => { + it('renders Unknown without duration or error when health was never reported', async () => { + render(); + + await screen.findByText('Unknown'); + await screen.findByText(/has not reported health information/i); + expect(screen.queryByText(/\bfor\b/i)).not.toBeInTheDocument(); + }); + + it('renders Unknown even when the instance is offline', async () => { + render(); + + await screen.findByText('Unknown'); + expect(screen.queryByText(/last known/i)).not.toBeInTheDocument(); + }); + + it('renders Healthy with duration for a healthy online instance', async () => { + render(); + + await screen.findByText('Healthy'); + await screen.findByText(/\bfor\b/i); + expect(screen.queryByText(/for the past/i)).not.toBeInTheDocument(); + }); + + it('renders Unhealthy with duration and error for an unhealthy online instance', async () => { + render(); + + await screen.findByText('Unhealthy'); + await screen.findByText(/\bfor\b/i); + await screen.findByText('connection refused'); + }); + + it('renders no error block when unhealthy without error text', async () => { + render(); + + await screen.findByText('Unhealthy'); + expect(screen.queryByTestId('health-error')).not.toBeInTheDocument(); + }); + + it('renders error text verbatim, escaped', async () => { + const nasty = 'Get "": context deadline & exceeded'; + render(); + + await screen.findByText(nasty); + }); + + it('renders last-known health without a duration for an offline instance', async () => { + render(); + + await screen.findByText('Last known: Unhealthy'); + // The duration would silently include offline time (healthy for 1h, then offline + // 3 days ≠ "for 3 days"); the Last Seen row above conveys staleness instead. + expect(screen.queryByText(/\bfor\b/i)).not.toBeInTheDocument(); + // Error stays available for post-mortems even when offline. + await screen.findByText('connection refused'); + }); + + it('renders last-known Healthy for an offline previously-healthy instance', async () => { + render(); + + await screen.findByText('Last known: Healthy'); + expect(screen.queryByText(/\bfor\b/i)).not.toBeInTheDocument(); + }); + + it('renders no error block when the last known state is healthy', async () => { + // Defensive: a malformed or stale report may carry last_error alongside + // healthy: true — a green badge must not be followed by an unexplained error. + render(); + + await screen.findByText('Healthy'); + expect(screen.queryByTestId('health-error')).not.toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx new file mode 100644 index 000000000000..77f4732f1efc --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -0,0 +1,125 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { Label } from 'components/bootstrap'; +import { RelativeTime } from 'components/common'; + +import type { CollectorHealth } from '../types'; + +type Props = { + health: CollectorHealth | null; + online: boolean; +}; + +// Deliberately duplicated from InstanceDetailDrawer.tsx (module-private there); extraction is +// worth it at a third consumer, not two. +const Section = styled.div( + ({ theme }) => css` + margin-bottom: ${theme.spacings.md}; + `, +); + +const SectionTitle = styled.h4( + ({ theme }) => css` + margin-bottom: ${theme.spacings.sm}; + font-size: ${theme.fonts.size.body}; + font-weight: 600; + border-bottom: 1px solid ${theme.colors.gray[80]}; + padding-bottom: ${theme.spacings.xs}; + `, +); + +const EmptyText = styled.span( + ({ theme }) => css` + color: ${theme.colors.gray[60]}; + `, +); + +const Duration = styled.span( + ({ theme }) => css` + color: ${theme.colors.gray[60]}; + font-size: ${theme.fonts.size.small}; + `, +); + +// The global theme styles own the code-block look (fill, border, text color — +// theme-aware, same as e.g. ShowMessagePage's error rendering). Only the wrapping +// is adjusted: Bootstrap's pre defaults to no wrapping and word-break: break-all, +// neither of which suits long single-line agent errors in a narrow drawer. +const ErrorBlock = styled.pre( + ({ theme }) => css` + white-space: pre-wrap; + word-break: normal; + overflow-wrap: break-word; + margin-top: ${theme.spacings.xs}; + margin-bottom: 0; + `, +); + +const InstanceHealthSection = ({ health, online }: Props) => { + let body: React.ReactNode; + + if (!health) { + body = ( + <> + {' '} + This collector has not reported health information. + + ); + } else { + const { healthy, last_error: lastError } = health.component_health; + const stateText = healthy ? 'Healthy' : 'Unhealthy'; + + body = ( + <> + {online ? ( + + ) : ( + // Stale (offline) health must not read as a live signal: the muted default + // label + "Last known:" wording carry the de-emphasis (theme-safe, unlike + // opacity dimming, which has no precedent in the product). + + )} + {online && ( + <> + {' '} + {/* Only while online: offline time would silently inflate the duration + (healthy for 1h, then offline 3 days is not "Healthy for 3 days"); + the Last Seen row above conveys staleness for offline instances. */} + + for + + + )} + {/* Guard against malformed/stale reports carrying last_error alongside + healthy: true — a green badge must not trail an unexplained error. */} + {!healthy && lastError && {lastError}} + + ); + } + + return ( +
+ Health + {body} +
+ ); +}; + +export default InstanceHealthSection; diff --git a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx index 83eba592b500..fea3ed702323 100644 --- a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx @@ -68,6 +68,7 @@ jest.mock('./onboarding/WaitingForConnection', () => { version: '1.2.3', status: 'online' as const, has_pending_changes: false, + health: null, }; return function WaitingForConnectionStub({ diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 43e1bc985c0e..4ac32e21a6c0 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -25,6 +25,26 @@ export type Fleet = { updated_at: string; }; +// Mirrors the OpAMP ComponentHealth message as stored/served by the backend. +// Recursive; today's agent only ever fills the root node (healthy + last_error). +export type ComponentHealth = { + healthy: boolean; + status?: string; + last_error?: string; + start_time?: string; + status_time?: string; + components?: Record; +}; + +export type CollectorHealth = { + // Server-clocked timestamp of the last root-`healthy` transition. All health timestamps + // (`healthy_changed_at`, `start_time`, `status_time`) are backend-rendered with a + // `+0000`-style offset, unlike the instance-level timestamps' `Z` — fine for RelativeTime, + // never string-compare timestamps. + healthy_changed_at: string; + component_health: ComponentHealth; +}; + export type CollectorInstanceView = { id: string; instance_uid: string; @@ -43,6 +63,7 @@ export type CollectorInstanceView = { version: string | null; status: 'online' | 'offline'; has_pending_changes: boolean; + health: CollectorHealth | null; }; export type SourceType = 'file' | 'journald' | 'windows_event_log'; diff --git a/graylog2-web-interface/src/components/common/RelativeTime.test.tsx b/graylog2-web-interface/src/components/common/RelativeTime.test.tsx index 37977c3a5a76..675a1b143144 100644 --- a/graylog2-web-interface/src/components/common/RelativeTime.test.tsx +++ b/graylog2-web-interface/src/components/common/RelativeTime.test.tsx @@ -20,6 +20,7 @@ import { render, screen } from 'wrappedTestingLibrary'; import RelativeTime from './RelativeTime'; const mockedUnixTime = 1577836800000; // 2020-01-01 00:00:00.000 +const twelveDaysAgo = new Date(mockedUnixTime - 12 * 24 * 60 * 60 * 1000); jest.useFakeTimers().setSystemTime(mockedUnixTime); @@ -30,6 +31,13 @@ describe('RelativeTime', () => { expect(screen.getByText('a year ago')).toBeInTheDocument(); }); + it('renders the relative time without suffix when withoutSuffix is set', async () => { + render(); + + await screen.findByText('12 days'); + expect(screen.queryByText(/12 days ago/)).not.toBeInTheDocument(); + }); + it('should display time relative to current time, when date time is not defined', () => { render(); diff --git a/graylog2-web-interface/src/components/common/RelativeTime.tsx b/graylog2-web-interface/src/components/common/RelativeTime.tsx index 12f0de2267c5..809fba17d678 100644 --- a/graylog2-web-interface/src/components/common/RelativeTime.tsx +++ b/graylog2-web-interface/src/components/common/RelativeTime.tsx @@ -21,14 +21,16 @@ import { relativeDifference, adjustFormat } from 'util/DateTime'; type Props = { dateTime?: string | number | Date | Moment; + /** Render "12 days" instead of "12 days ago" (moment's fromNow(true)). */ + withoutSuffix?: boolean; }; /** * This component receives any date time and displays the relative time until now in a human-readable format. */ -const RelativeTime = ({ dateTime: dateTimeProp = undefined }: Props) => { +const RelativeTime = ({ dateTime: dateTimeProp = undefined, withoutSuffix = false }: Props) => { const dateTime = dateTimeProp ?? new Date(); - const relativeTime = relativeDifference(dateTime); + const relativeTime = relativeDifference(dateTime, withoutSuffix); const dateTimeString = adjustFormat(dateTime, 'internal'); return ( diff --git a/graylog2-web-interface/src/util/DateTime.ts b/graylog2-web-interface/src/util/DateTime.ts index aa8181164ca1..bfbf731978ec 100644 --- a/graylog2-web-interface/src/util/DateTime.ts +++ b/graylog2-web-interface/src/util/DateTime.ts @@ -104,10 +104,10 @@ export const formatAsBrowserTime = (time: DateTime, format: DateTimeFormats = 'd * Returns the time in a human-readable format, relative to the provided date time. * If you just want to display the output, you can use the `RelativeTime` component. */ -export const relativeDifference = (dateTime: DateTime) => { +export const relativeDifference = (dateTime: DateTime, withoutSuffix: boolean = false) => { const dateObject = toDateObject(dateTime); - return validateDateTime(dateObject, dateTime).fromNow(); + return validateDateTime(dateObject, dateTime).fromNow(withoutSuffix); }; /**