From 00984d6474500d83ede5cfb824322a8dbe08e910 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Thu, 30 Jul 2026 16:01:50 +0200 Subject: [PATCH 01/18] Store collector health in instance collection --- .../collectors/CollectorInstanceService.java | 32 ++++++- .../collectors/db/CollectorHealthDTO.java | 64 +++++++++++++ .../collectors/db/CollectorInstanceDTO.java | 7 ++ .../db/CollectorInstanceReport.java | 5 +- .../collectors/db/ComponentHealthDTO.java | 94 +++++++++++++++++++ .../collectors/opamp/OpAmpService.java | 29 ++++++ ...mpServiceTransactionLogTruncationTest.java | 2 +- 7 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 graylog2-server/src/main/java/org/graylog/collectors/db/CollectorHealthDTO.java create mode 100644 graylog2-server/src/main/java/org/graylog/collectors/db/ComponentHealthDTO.java 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..8f27bb9603f8 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; @@ -198,7 +202,7 @@ public MinimalCollectorInstanceDTO updateFromReport(CollectorInstanceReport upda new FindOneAndUpdateOptions() .returnDocument(ReturnDocument.BEFORE) .projection(Projections.fields( - Projections.include(FIELD_MESSAGE_SEQ_NUM, FIELD_LAST_PROCESSED_TXN_SEQ, FIELD_FLEET_ID), + Projections.include(FIELD_MESSAGE_SEQ_NUM, FIELD_LAST_PROCESSED_TXN_SEQ, FIELD_FLEET_ID, FIELD_HEALTH), Projections.elemMatch(FIELD_NON_IDENTIFYING_ATTRIBUTES, Filters.eq(Attribute.FIELD_KEY, OS_TYPE_KEY)) ))); @@ -207,6 +211,29 @@ public MinimalCollectorInstanceDTO updateFromReport(CollectorInstanceReport upda throw new IllegalArgumentException("Instance not enrolled: " + update.instanceUid()); } + if (update.health().isPresent()) { + // If health changed, update the document and set the changed_at timestamp correctly + final var previousComponentHealth = Optional.ofNullable(previousInstanceDto.health()) + .map(CollectorHealthDTO::componentHealth); + + 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 previousInstanceDto; } @@ -660,7 +687,8 @@ public record MinimalCollectorInstanceDTO(@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) { + @JsonProperty(FIELD_NON_IDENTIFYING_ATTRIBUTES) List nonIdentifyingAttributes, + @JsonProperty(FIELD_HEALTH) @Nullable CollectorHealthDTO health) { public CollectorOSType osType() { if (nonIdentifyingAttributes == null) { return CollectorOSType.UNKNOWN; 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/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java index d23ea1e09b50..2bf7aa0b507b 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java @@ -186,7 +186,7 @@ private static AgentToServer message(long sequenceNum, long capabilities) { private void stubPreviousState(long lastProcessedTxnSeq) { when(collectorInstanceService.updateFromReport(any())).thenReturn( - new MinimalCollectorInstanceDTO("id-1", FLEET_ID, SEQUENCE_NUM - 1, lastProcessedTxnSeq, null)); + new MinimalCollectorInstanceDTO("id-1", FLEET_ID, SEQUENCE_NUM - 1, lastProcessedTxnSeq, null, null)); } private void stubMarkers(long lastProcessedTxnSeq, List markers) { From b289bb1ab4feff33bf6e6e460d88e32073848910 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Thu, 30 Jul 2026 16:02:37 +0200 Subject: [PATCH 02/18] Add tests for collector health storage path Covers the OpAMP ComponentHealth proto mapping in OpAmpService (recursion, proto3-default and blank-string normalization, capability gating) and the health snapshot + healthy_changed_at semantics in CollectorInstanceService.updateFromReport (first report, identical re-send, root flip, no-flip content change, absent health, recursive round trip, stored document shape). Co-Authored-By: Claude Fable 5 --- .../opamp/CollectorInstanceServiceTest.java | 193 ++++++++++++++ .../opamp/OpAmpServiceHealthReportTest.java | 241 ++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java 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..5b3a0e175487 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,198 @@ 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 updateFromReportReturnsPreviousHealthState() throws Exception { + final var uid = "health-previous"; + enroll(uid); + + final var first = collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(true, null))); + assertThat(first.health()).isNull(); + + final var second = collectorInstanceService.updateFromReport(healthReport(uid, 2L, health(false, "boom"))); + assertThat(second.health()).isNotNull(); + assertThat(second.health().componentHealth()).isEqualTo(health(true, null)); + } + + @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..b1ae87639942 --- /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, 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); + } +} From a574a74bf94b662ffbd9399fa85e7e4c01bcdbd7 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 09:26:14 +0200 Subject: [PATCH 03/18] Add health to REST response --- .../graylog/collectors/rest/CollectorInstanceResponse.java | 4 +++- .../graylog/collectors/rest/CollectorInstancesResource.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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) ); } From 44d1d9820a9d2e0ed2aa553f20fc70a0227c47e0 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 10:58:10 +0200 Subject: [PATCH 04/18] Add collector health to instance view type and mapping The backend now serves collector health on instance responses. Map it into CollectorInstanceView so a later task can render it in the instance details drawer. --- .../hooks/useInstanceQueries.test.ts | 27 +++++++++++++++++++ .../collectors/hooks/useInstanceQueries.ts | 2 ++ .../instances/ColumnRenderers.test.tsx | 1 + .../instances/InstanceActions.test.tsx | 1 + .../instances/InstanceDetailDrawer.test.tsx | 1 + .../overview/FirstOnboarding.test.tsx | 1 + .../src/components/collectors/types.ts | 20 ++++++++++++++ 7 files changed, 53 insertions(+) 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..a9daa32a0edc 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,33 @@ describe('useInstance', () => { expect(Collectors.getInstance).not.toHaveBeenCalled(); }); + + 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..cba167a92823 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -53,6 +53,8 @@ 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, + // Absent on the wire for never-reported instances; normalize to the declared null. + health: dto.health ?? null, }; }; 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..7c38372a0caf 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx @@ -45,6 +45,7 @@ const mockInstance: CollectorInstanceView = { version: '1.2.0', status: 'online', has_pending_changes: false, + health: null, }; const mockSources: Source[] = [ 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..867071b0d33b 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -25,6 +25,25 @@ 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. Rendered by the backend + // with a `+0000`-style offset (unlike the other 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 +62,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'; From 77b8181066531130fab7af7cd95ca3df578a2dae Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 11:10:51 +0200 Subject: [PATCH 05/18] Add withoutSuffix option to RelativeTime --- .../src/components/common/RelativeTime.test.tsx | 8 ++++++++ .../src/components/common/RelativeTime.tsx | 6 ++++-- graylog2-web-interface/src/util/DateTime.ts | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) 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); }; /** From 69822989bf3ad1a07f8000901d8e844ffe587aac Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 11:13:59 +0200 Subject: [PATCH 06/18] Add InstanceHealthSection component --- .../instances/InstanceHealthSection.test.tsx | 88 +++++++++++++ .../instances/InstanceHealthSection.tsx | 117 ++++++++++++++++++ .../src/components/collectors/types.ts | 5 +- 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx create mode 100644 graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx 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..125d9017bc50 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx @@ -0,0 +1,88 @@ +/* + * 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(/for the past/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(/for the past/i); + }); + + it('renders Unhealthy with duration and error for an unhealthy online instance', async () => { + render(); + + await screen.findByText('Unhealthy'); + await screen.findByText(/for the past/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 dimmed last-known health for an offline instance', async () => { + render(); + + await screen.findByText('Last known: Unhealthy'); + await screen.findByText(/for the past/i); + // 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'); + }); +}); 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..ed19646df940 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -0,0 +1,117 @@ +/* + * 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}; + `, +); + +// Stale (offline) health is shown but must not read as a live signal. +const Body = styled.div<{ $stale: boolean }>( + ({ $stale }) => css` + opacity: ${$stale ? 0.6 : 1}; + `, +); + +const ErrorBlock = styled.pre( + ({ theme }) => css` + font-family: ${theme.fonts.family.monospace}; + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.variant.danger}; + margin-top: ${theme.spacings.xs}; + margin-bottom: 0; + padding: ${theme.spacings.xs} ${theme.spacings.sm}; + overflow-x: auto; + `, +); + +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 ? ( + + ) : ( + + )}{' '} + + for the past + + {lastError && {lastError}} + + ); + } + + return ( +
+ Health + {body} +
+ ); +}; + +export default InstanceHealthSection; diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 867071b0d33b..4ac32e21a6c0 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -37,8 +37,9 @@ export type ComponentHealth = { }; export type CollectorHealth = { - // Server-clocked timestamp of the last root-`healthy` transition. Rendered by the backend - // with a `+0000`-style offset (unlike the other timestamps' `Z`) — fine for RelativeTime, + // 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; From 92820a30d845eeb8b0529ff5476953728204f3d5 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 11:22:50 +0200 Subject: [PATCH 07/18] Show collector health in instance detail drawer --- .../instances/InstanceDetailDrawer.test.tsx | 18 ++++++++++++++++++ .../instances/InstanceDetailDrawer.tsx | 4 ++++ 2 files changed, 22 insertions(+) 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 7c38372a0caf..48f19b0e6ba3 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx @@ -308,4 +308,22 @@ 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'); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx index e2a14a04eb2b..68bbe5b71c0c 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx @@ -25,6 +25,8 @@ 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'; @@ -220,6 +222,8 @@ const InstanceDetailDrawer = ({ instance, sources, fleetName, onClose }: Props) + +
Attributes From 2de2754600c64c7ddb7fd51d8d8f5ca28ea7bebf Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 11:53:03 +0200 Subject: [PATCH 08/18] Fix health duration wording for singular time units --- .../collectors/instances/InstanceHealthSection.test.tsx | 9 +++++---- .../collectors/instances/InstanceHealthSection.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx index 125d9017bc50..24fd9b7ca02b 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx @@ -32,7 +32,7 @@ describe('InstanceHealthSection', () => { await screen.findByText('Unknown'); await screen.findByText(/has not reported health information/i); - expect(screen.queryByText(/for the past/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/\bfor\b/i)).not.toBeInTheDocument(); }); it('renders Unknown even when the instance is offline', async () => { @@ -46,14 +46,15 @@ describe('InstanceHealthSection', () => { render(); await screen.findByText('Healthy'); - await screen.findByText(/for the past/i); + 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(/for the past/i); + await screen.findByText(/\bfor\b/i); await screen.findByText('connection refused'); }); @@ -75,7 +76,7 @@ describe('InstanceHealthSection', () => { render(); await screen.findByText('Last known: Unhealthy'); - await screen.findByText(/for the past/i); + await screen.findByText(/\bfor\b/i); // Error stays available for post-mortems even when offline. await screen.findByText('connection refused'); }); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx index ed19646df940..e12d3372c3d7 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -99,7 +99,7 @@ const InstanceHealthSection = ({ health, online }: Props) => { )}{' '} - for the past + for {lastError && {lastError}} From d440ead3267b3d035ed192407eb8e3800b1f8aea Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 12:31:28 +0200 Subject: [PATCH 09/18] Wrap long health error messages in the drawer Co-Authored-By: Claude Fable 5 --- .../collectors/instances/InstanceHealthSection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx index e12d3372c3d7..b6a03aa1ef27 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -73,7 +73,10 @@ const ErrorBlock = styled.pre( margin-top: ${theme.spacings.xs}; margin-bottom: 0; padding: ${theme.spacings.xs} ${theme.spacings.sm}; - overflow-x: auto; + /* Agent errors are often long single lines; wrap instead of forcing a + horizontal scroller inside the narrow drawer. */ + white-space: pre-wrap; + overflow-wrap: anywhere; `, ); From 0ae90a969f6efccf3fee2879dd98137f9b897511 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 12:42:36 +0200 Subject: [PATCH 10/18] Keep instance detail drawer fresh while open --- .../collectors/hooks/useInstanceQueries.ts | 24 ++++++++--- .../instances/InstanceDetailDrawer.test.tsx | 43 +++++++++++++++++++ .../instances/InstanceDetailDrawer.tsx | 10 ++++- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index cba167a92823..b67c473a4cf8 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -120,16 +120,26 @@ 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: () => { + const promise = Collectors.getInstance(instanceUid).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/InstanceDetailDrawer.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx index 48f19b0e6ba3..e2aeb36c094c 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', @@ -84,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 () => { @@ -326,4 +334,39 @@ describe('InstanceDetailDrawer', () => { 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'); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx index 68bbe5b71c0c..4e994c999d39 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx @@ -33,7 +33,7 @@ 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 = { @@ -135,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 From fdf24f00d6bfd8def8ea5dac3eeecf9889271ddc Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 14:23:26 +0200 Subject: [PATCH 11/18] Do not extend the session from the drawer's instance poll Polling the instance for a fresh drawer is a background refresh and must follow the module's NO_SESSION_EXT convention; the one-shot form keeps extending the session as before. Also pins the drawer's polling options and the hook's session opt-out with dedicated assertions. Co-Authored-By: Claude Fable 5 --- .../collectors/hooks/useInstanceQueries.test.ts | 12 ++++++++++++ .../collectors/hooks/useInstanceQueries.ts | 9 ++++++++- .../instances/InstanceDetailDrawer.test.tsx | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) 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 a9daa32a0edc..edc8ed127646 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts @@ -92,6 +92,18 @@ 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', diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index b67c473a4cf8..bf1f76f6cfe3 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -128,7 +128,14 @@ export const useInstance = ( // 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: () => { - const promise = Collectors.getInstance(instanceUid).then((response) => toView(response)); + // 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 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 e2aeb36c094c..5f5edb5e160b 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.test.tsx @@ -368,5 +368,7 @@ describe('InstanceDetailDrawer', () => { ); 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 }); }); }); From 912f79c8a112bbc09d8d0d7d0f00ad56d220c3ac Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 14:44:31 +0200 Subject: [PATCH 12/18] Align health section styling with established patterns The error block now uses the feature's code-block chrome (per InstallCommand's CommandBlock) instead of a novel red-on-pre hybrid, with break-word wrapping so words stay intact. Offline de-emphasis relies on the muted default label instead of opacity dimming, which has no precedent in the product and was not theme-safe. Co-Authored-By: Claude Fable 5 --- .../instances/InstanceHealthSection.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx index b6a03aa1ef27..ad8ff8af965e 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -58,25 +58,21 @@ const Duration = styled.span( `, ); -// Stale (offline) health is shown but must not read as a live signal. -const Body = styled.div<{ $stale: boolean }>( - ({ $stale }) => css` - opacity: ${$stale ? 0.6 : 1}; - `, -); - +// Code-block chrome per this feature's convention (see InstallCommand's CommandBlock), +// except the wrapping: break-word keeps words intact and only splits tokens (URLs) +// that would otherwise overflow the narrow drawer. const ErrorBlock = styled.pre( ({ theme }) => css` + padding: ${theme.spacings.sm}; + background: ${theme.colors.global.contentBackground}; + border: 1px solid ${theme.colors.cards.border}; + border-radius: ${theme.spacings.xs}; font-family: ${theme.fonts.family.monospace}; font-size: ${theme.fonts.size.small}; - color: ${theme.colors.variant.danger}; + white-space: pre-wrap; + overflow-wrap: break-word; margin-top: ${theme.spacings.xs}; margin-bottom: 0; - padding: ${theme.spacings.xs} ${theme.spacings.sm}; - /* Agent errors are often long single lines; wrap instead of forcing a - horizontal scroller inside the narrow drawer. */ - white-space: pre-wrap; - overflow-wrap: anywhere; `, ); @@ -95,17 +91,20 @@ const InstanceHealthSection = ({ health, online }: Props) => { 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). )}{' '} for {lastError && {lastError}} - + ); } From 74a7bf7350a8fe8cbf2b19a731c96d5f36b46064 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 14:47:29 +0200 Subject: [PATCH 13/18] Omit health duration when offline and hide error for healthy reports Offline time would silently inflate the "for " (healthy for an hour, then offline three days is not "Healthy for 3 days"); the Last Seen row conveys staleness for offline instances. A last_error carried by a malformed or stale healthy report is no longer rendered next to a green badge. Co-Authored-By: Claude Fable 5 --- .../instances/InstanceHealthSection.test.tsx | 16 +++++++++++++-- .../instances/InstanceHealthSection.tsx | 20 ++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx index 24fd9b7ca02b..b2304efb6703 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.test.tsx @@ -72,11 +72,13 @@ describe('InstanceHealthSection', () => { await screen.findByText(nasty); }); - it('renders dimmed last-known health for an offline instance', async () => { + it('renders last-known health without a duration for an offline instance', async () => { render(); await screen.findByText('Last known: Unhealthy'); - await screen.findByText(/\bfor\b/i); + // 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'); }); @@ -85,5 +87,15 @@ describe('InstanceHealthSection', () => { 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 index ad8ff8af965e..702f241ad908 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -99,11 +99,21 @@ const InstanceHealthSection = ({ health, online }: Props) => { // label + "Last known:" wording carry the de-emphasis (theme-safe, unlike // opacity dimming, which has no precedent in the product). - )}{' '} - - for - - {lastError && {lastError}} + )} + {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}} ); } From 3bbc6b1875d865c4084fb65335ead2a625b1c963 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 15:09:06 +0200 Subject: [PATCH 14/18] Use the product's standard pre styling for the health error block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product-wide, machine text renders as a bare pre wearing the global theme styles (fill/border/text, theme-aware) with at most a whitespace tweak — e.g. ShowMessagePage's error rendering. The CommandBlock-style chrome was a one-off whose contentBackground fill vanished against the drawer and whose cards.border is barely visible in dark mode. Only the wrapping is customized: pre-wrap with word-break: normal, since Bootstrap's defaults (no wrap, or break-all) both read badly for long single-line errors. Co-Authored-By: Claude Fable 5 --- .../collectors/instances/InstanceHealthSection.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx index 702f241ad908..77f4732f1efc 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceHealthSection.tsx @@ -58,18 +58,14 @@ const Duration = styled.span( `, ); -// Code-block chrome per this feature's convention (see InstallCommand's CommandBlock), -// except the wrapping: break-word keeps words intact and only splits tokens (URLs) -// that would otherwise overflow the narrow drawer. +// 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` - padding: ${theme.spacings.sm}; - background: ${theme.colors.global.contentBackground}; - border: 1px solid ${theme.colors.cards.border}; - border-radius: ${theme.spacings.xs}; - font-family: ${theme.fonts.family.monospace}; - font-size: ${theme.fonts.size.small}; white-space: pre-wrap; + word-break: normal; overflow-wrap: break-word; margin-top: ${theme.spacings.xs}; margin-bottom: 0; From 05e193ee3febf09ff28a6d80c178726b7cd92e15 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 15:52:57 +0200 Subject: [PATCH 15/18] add changelog --- changelog/unreleased/pr-26840.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/unreleased/pr-26840.toml 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"] From 27ad85efd5f5767ec71a8ccda2328e67886c4f97 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 15:53:54 +0200 Subject: [PATCH 16/18] Remove redundant comment --- .../src/components/collectors/hooks/useInstanceQueries.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index bf1f76f6cfe3..b7cd44634db0 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -53,7 +53,6 @@ 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, - // Absent on the wire for never-reported instances; normalize to the declared null. health: dto.health ?? null, }; }; From 7853bd6271c7711bd18b7c0bb994ca28fdc44717 Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 16:34:06 +0200 Subject: [PATCH 17/18] Fetch health field only when reported --- .../collectors/CollectorInstanceService.java | 42 ++++++++++++++----- .../opamp/CollectorInstanceServiceTest.java | 13 ------ .../opamp/OpAmpServiceHealthReportTest.java | 2 +- ...mpServiceTransactionLogTruncationTest.java | 8 ++-- 4 files changed, 37 insertions(+), 28 deletions(-) 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 8f27bb9603f8..f7a2fc836bcb 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorInstanceService.java @@ -117,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; @@ -126,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; @@ -193,18 +193,25 @@ 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, FIELD_HEALTH), - 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. @@ -212,10 +219,10 @@ public MinimalCollectorInstanceDTO updateFromReport(CollectorInstanceReport upda } if (update.health().isPresent()) { - // If health changed, update the document and set the changed_at timestamp correctly 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), @@ -234,7 +241,13 @@ public MinimalCollectorInstanceDTO updateFromReport(CollectorInstanceReport upda } } - return previousInstanceDto; + return new MinimalCollectorInstanceDTO( + previousInstanceDto.id(), + previousInstanceDto.fleetId(), + previousInstanceDto.messageSeqNum(), + previousInstanceDto.lastProcessTxnSeq(), + previousInstanceDto.nonIdentifyingAttributes() + ); } /** @@ -683,12 +696,19 @@ 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, @JsonProperty(FIELD_LAST_PROCESSED_TXN_SEQ) long lastProcessTxnSeq, - @JsonProperty(FIELD_NON_IDENTIFYING_ATTRIBUTES) List nonIdentifyingAttributes, - @JsonProperty(FIELD_HEALTH) @Nullable CollectorHealthDTO health) { + @JsonProperty(FIELD_NON_IDENTIFYING_ATTRIBUTES) List nonIdentifyingAttributes) { public CollectorOSType osType() { if (nonIdentifyingAttributes == null) { return CollectorOSType.UNKNOWN; 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 5b3a0e175487..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 @@ -1111,19 +1111,6 @@ void updateFromReportWithoutHealthLeavesStoredHealthUntouched() throws Exception })); } - @Test - void updateFromReportReturnsPreviousHealthState() throws Exception { - final var uid = "health-previous"; - enroll(uid); - - final var first = collectorInstanceService.updateFromReport(healthReport(uid, 1L, health(true, null))); - assertThat(first.health()).isNull(); - - final var second = collectorInstanceService.updateFromReport(healthReport(uid, 2L, health(false, "boom"))); - assertThat(second.health()).isNotNull(); - assertThat(second.health().componentHealth()).isEqualTo(health(true, null)); - } - @Test void updateFromReportRoundTripsRecursiveHealthTree() throws Exception { final var uid = "health-tree"; 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 index b1ae87639942..e17b273d904a 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceHealthReportTest.java @@ -91,7 +91,7 @@ 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, null)); + new MinimalCollectorInstanceDTO("id-1", "fleet-A", 0L, 0L, null)); opAmpService = new OpAmpService(enrollmentTokenService, agentTokenService, collectorCaService, certificateService, collectorInstanceService, collectorsConfigService, clusterIdService, fleetTransactionLogService, sourceService); diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java index 2bf7aa0b507b..6b8fa0d6130b 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java @@ -93,7 +93,8 @@ class OpAmpServiceTransactionLogTruncationTest { private OpAmpService opAmpService; @BeforeEach - @SuppressWarnings("MustBeClosedChecker") // stubbing streamAllByFleet on a mock opens no resource + @SuppressWarnings("MustBeClosedChecker") + // stubbing streamAllByFleet on a mock opens no resource void setUp() { lenient().when(clusterIdService.getString()).thenReturn("clusterId"); // the exporter config is built on every identified exchange, forced recompute or not @@ -157,7 +158,8 @@ void appliedStatusAdvancesTheCursorPassedToCoalesce() { } @Test - @SuppressWarnings("MustBeClosedChecker") // stubbing streamAllByFleet on a mock opens no resource + @SuppressWarnings("MustBeClosedChecker") + // stubbing streamAllByFleet on a mock opens no resource void forcedRecomputePreservesRetainedFleetReassignment() { stubPreviousState(5L); stubMarkers(5L, List.of()); @@ -186,7 +188,7 @@ private static AgentToServer message(long sequenceNum, long capabilities) { private void stubPreviousState(long lastProcessedTxnSeq) { when(collectorInstanceService.updateFromReport(any())).thenReturn( - new MinimalCollectorInstanceDTO("id-1", FLEET_ID, SEQUENCE_NUM - 1, lastProcessedTxnSeq, null, null)); + new MinimalCollectorInstanceDTO("id-1", FLEET_ID, SEQUENCE_NUM - 1, lastProcessedTxnSeq, null)); } private void stubMarkers(long lastProcessedTxnSeq, List markers) { From 7251374881b95fd3d2c56f2c38c072d8273435be Mon Sep 17 00:00:00 2001 From: Othello Maurer Date: Fri, 31 Jul 2026 16:41:36 +0200 Subject: [PATCH 18/18] Restore suppression comments to their annotation lines IDE auto-format artifact from the projection refactor; no functional change. Co-Authored-By: Claude Fable 5 --- .../opamp/OpAmpServiceTransactionLogTruncationTest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java index 6b8fa0d6130b..d23ea1e09b50 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceTransactionLogTruncationTest.java @@ -93,8 +93,7 @@ class OpAmpServiceTransactionLogTruncationTest { private OpAmpService opAmpService; @BeforeEach - @SuppressWarnings("MustBeClosedChecker") - // stubbing streamAllByFleet on a mock opens no resource + @SuppressWarnings("MustBeClosedChecker") // stubbing streamAllByFleet on a mock opens no resource void setUp() { lenient().when(clusterIdService.getString()).thenReturn("clusterId"); // the exporter config is built on every identified exchange, forced recompute or not @@ -158,8 +157,7 @@ void appliedStatusAdvancesTheCursorPassedToCoalesce() { } @Test - @SuppressWarnings("MustBeClosedChecker") - // stubbing streamAllByFleet on a mock opens no resource + @SuppressWarnings("MustBeClosedChecker") // stubbing streamAllByFleet on a mock opens no resource void forcedRecomputePreservesRetainedFleetReassignment() { stubPreviousState(5L); stubMarkers(5L, List.of());