diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalApiHealthIndicator.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalApiHealthIndicator.java new file mode 100644 index 00000000000..4ec487f30e3 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalApiHealthIndicator.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.health; + +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; + +/** + * A sample {@link HealthIndicator} that simulates checking connectivity to an external + * API. In a real application this would perform an actual HTTP call or similar check. + */ +public class ExternalApiHealthIndicator implements HealthIndicator { + + @Override + public Health health() { + // Simulate a reachable external API + return Health.down().withDetail("url", "https://api.example.com").withDetail("responseTime", "42ms").build(); + } + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalServicesHealthContributor.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalServicesHealthContributor.java new file mode 100644 index 00000000000..83c36266508 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/ExternalServicesHealthContributor.java @@ -0,0 +1,65 @@ +/* + * Copyright 2014-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.health; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.HealthContributor; +import org.springframework.boot.health.contributor.HealthContributors; + +/** + * A {@link CompositeHealthContributor} that groups related external-dependency health + * checks under a single {@code /actuator/health/externalServices} entry. + * + *

+ * Spring Boot aggregates the individual {@link HealthContributor}s and rolls up the worst + * status to the composite level, so a single degraded dependency is immediately visible + * both in the top-level health summary and in the drill-down view. + * + *

+ * The composite is registered as a bean named {@code externalServices} in + * {@link HealthContributorConfig}, which causes Spring Boot Actuator to expose it at + * {@code /actuator/health/externalServices}. + */ +// tag::composite-health-contributor[] +public class ExternalServicesHealthContributor implements CompositeHealthContributor { + + private final Map contributors = new LinkedHashMap<>(); + + public ExternalServicesHealthContributor(ExternalApiHealthIndicator externalApi, + MessageBrokerHealthIndicator messageBroker) { + contributors.put("externalApi", externalApi); + contributors.put("messageBroker", messageBroker); + } + + @Override + public HealthContributor getContributor(String name) { + return contributors.get(name); + } + + @Override + public Stream stream() { + return contributors.entrySet() + .stream() + .map((entry) -> new HealthContributors.Entry(entry.getKey(), entry.getValue())); + } + +} +// end::composite-health-contributor[] diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/HealthContributorConfig.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/HealthContributorConfig.java new file mode 100644 index 00000000000..3fc112efa6d --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/HealthContributorConfig.java @@ -0,0 +1,52 @@ +/* + * Copyright 2014-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.health; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the individual health indicators and the composite contributor as Spring + * beans. + * + *

+ * The bean name {@code externalServices} is used by Spring Boot Actuator as the path + * segment, so the composite becomes available at + * {@code /actuator/health/externalServices}. + */ +@Configuration(proxyBeanMethods = false) +public class HealthContributorConfig { + + @Bean + public ExternalApiHealthIndicator externalApiHealthIndicator() { + return new ExternalApiHealthIndicator(); + } + + @Bean + public MessageBrokerHealthIndicator messageBrokerHealthIndicator() { + return new MessageBrokerHealthIndicator(); + } + + // tag::composite-health-contributor-bean[] + @Bean + public ExternalServicesHealthContributor externalServices(ExternalApiHealthIndicator externalApi, + MessageBrokerHealthIndicator messageBroker) { + return new ExternalServicesHealthContributor(externalApi, messageBroker); + } + // end::composite-health-contributor-bean[] + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/MessageBrokerHealthIndicator.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/MessageBrokerHealthIndicator.java new file mode 100644 index 00000000000..62c882555de --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/health/MessageBrokerHealthIndicator.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.health; + +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; + +/** + * A sample {@link HealthIndicator} that simulates checking an internal message broker. In + * a real application this would verify queue depth, connection status, etc. + */ +public class MessageBrokerHealthIndicator implements HealthIndicator { + + @Override + public Health health() { + // Simulate a healthy message broker + return Health.unknown().withDetail("broker", "in-memory-stub").withDetail("queueDepth", 0).build(); + } + +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/components/sba-status-badge.vue b/spring-boot-admin-server-ui/src/main/frontend/components/sba-status-badge.vue index 86d124ace03..ac94bd6f158 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/components/sba-status-badge.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/components/sba-status-badge.vue @@ -36,13 +36,13 @@ export default { @apply bg-gray-200 text-black text-xs inline-flex items-center uppercase rounded overflow-hidden px-3 py-1; } .up { - @apply bg-green-200 text-green-700; + @apply bg-status-up-bg text-status-up-text; } .down, .out_of_service { - @apply bg-red-200 text-red-700; + @apply bg-status-down-bg text-status-down-text; } .restricted { - @apply bg-yellow-200 text-yellow-700; + @apply bg-status-restricted-bg text-status-restricted-text; } diff --git a/spring-boot-admin-server-ui/src/main/frontend/components/sba-status.vue b/spring-boot-admin-server-ui/src/main/frontend/components/sba-status.vue index 4b48bd272e0..133dc1bd7df 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/components/sba-status.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/components/sba-status.vue @@ -71,17 +71,17 @@ export default { @apply text-gray-500 mx-auto; } .application-status__icon--UP { - color: #48c78e; + color: var(--color-status-up); } .application-status__icon--RESTRICTED { - color: #ffe08a; + color: var(--color-status-restricted); } .application-status__icon--OUT_OF_SERVICE, .application-status__icon--DOWN { - color: #f14668; + color: var(--color-status-down); } .application-status__icon--UNKNOWN, .application-status__icon--OFFLINE { - color: #7a7a7a; + color: var(--color-status-unknown); } diff --git a/spring-boot-admin-server-ui/src/main/frontend/theme.css b/spring-boot-admin-server-ui/src/main/frontend/theme.css index 23f6e454003..52c8e25ade0 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/theme.css +++ b/spring-boot-admin-server-ui/src/main/frontend/theme.css @@ -15,6 +15,7 @@ */ @import 'tailwindcss'; + @custom-variant dark (&:where(.dark, .dark *)); @plugin "@tailwindcss/forms"; @plugin "@tailwindcss/typography"; @@ -30,4 +31,25 @@ --color-sba-700: var(--main-700); --color-sba-800: var(--main-800); --color-sba-900: var(--main-900); + + /* ── Status colour tokens ───────────────────────────────────────────── + * Tailwind utilities: text-status-up, bg-status-up-bg, etc. + * Single source of truth for UP/DOWN/RESTRICTED/UNKNOWN across all + * components (sba-status-badge, sba-status, wallboard, health panel). + */ + --color-status-up: var(--color-green-400); + --color-status-up-bg: var(--color-green-200); + --color-status-up-text: var(--color-green-700); + + --color-status-down: var(--color-red-400); + --color-status-down-bg: var(--color-red-200); + --color-status-down-text: var(--color-red-700); + + --color-status-restricted: var(--color-yellow-500); + --color-status-restricted-bg: var(--color-yellow-200); + --color-status-restricted-text: var(--color-yellow-700); + + --color-status-unknown: var(--color-gray-400); + --color-status-unknown-bg: var(--color-gray-200); + --color-status-unknown-text: var(--color-gray-700); } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap index 673cf2a8713..716da368beb 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap @@ -4,7 +4,7 @@ exports[`HealthDetails > Health .details > should format object details correctl

diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue index 8bad6f5d79c..437a19ab346 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue @@ -25,7 +25,6 @@ v-if="health.status" :status="health.status" class="ml-2 transition-opacity" - :class="{ 'opacity-0': panelOpen }" /> @@ -47,63 +46,67 @@ class="border-l-4" :title="$t('term.fetch_failed')" /> -
- - - @@ -144,6 +147,26 @@ export default defineComponent({ health() { return this.instance.statusInfo; }, + overallStatusClass(): string { + return (this.health?.status ?? 'unknown') + .toLowerCase() + .replace(/_/g, '-'); + }, + hasComponents(): boolean { + const source = this.health?.details ?? this.health?.components; + return ( + source != null && + typeof source === 'object' && + Object.keys(source).length > 0 + ); + }, + componentEntries(): [string, any][] { + const source = this.health?.details ?? this.health?.components; + if (source && typeof source === 'object') { + return Object.entries(source); + } + return []; + }, hasHealthUrl() { if (this.instance.endpoints) { return ( @@ -155,7 +178,6 @@ export default defineComponent({ return false; }, healthGroupsKey() { - // Only re-fetch on meaningful status changes; deliberately excludes return `${this.instance.id}:${this.instance.statusInfo?.status ?? ''}:${this.instance.statusTimestamp ?? ''}`; }, }, @@ -180,7 +202,6 @@ export default defineComponent({ this.healthGroupLoadingMap = {}; this.healthGroupsError = null; } - // Re-fetch the (server-cached) group list on every instance change this.fetchHealthGroups(); }, isHealthGroupOpen(groupName: string) { @@ -191,13 +212,9 @@ export default defineComponent({ }, toggleHealthGroup(groupName: string) { const group = this.healthGroups.find((g) => g.name === groupName); - - if (group == undefined) { - return; - } + if (group == undefined) return; if (group.data === null) { - // First click — fetch group details this.fetchGroupDetails(groupName); } else if (this.isHealthGroupCollapsible(groupName)) { this.healthGroupOpenStatus[groupName].isOpen = @@ -205,9 +222,7 @@ export default defineComponent({ } }, async fetchHealthGroups() { - if (!this.hasHealthUrl) { - return; - } + if (!this.hasHealthUrl) return; this.healthGroupsError = null; @@ -221,11 +236,7 @@ export default defineComponent({ currentNames.length === incomingNames.length && currentNames.every((name, idx) => name === incomingNames[idx]); - // Skip reassignment when the group list is unchanged to preserve - // the accordion open/loading state and avoid needless re-renders. - if (unchanged) { - return; - } + if (unchanged) return; this.healthGroups = incomingNames.map((name: string) => ({ name, @@ -264,3 +275,79 @@ export default defineComponent({ }, }); + + diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue index 5b704f15267..1bd5f1f28bf 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue @@ -15,49 +15,91 @@ -->