diff --git a/docs/docs/configuration/message-tracking.md b/docs/docs/configuration/message-tracking.md index 1bdb2770a2..5ff9b25900 100644 --- a/docs/docs/configuration/message-tracking.md +++ b/docs/docs/configuration/message-tracking.md @@ -73,3 +73,8 @@ LogRepository logRepository(Client client) { return new ElasticsearchLogRepository(client); } ``` + +### UI configuration +Ui console can be configured to show tracking urls to users for topics and subscriptions. +To enable this, make bean implementing `pl.allegro.tech.hermes.tracker.management.TrackingUrlProvider` +available in Spring context. diff --git a/hermes-console/json-server/db.json b/hermes-console/json-server/db.json index d041fcaa58..b43b84ada7 100644 --- a/hermes-console/json-server/db.json +++ b/hermes-console/json-server/db.json @@ -213,7 +213,7 @@ }, "jsonToAvroDryRun": false, "ack": "LEADER", - "trackingEnabled": false, + "trackingEnabled": true, "migratedFromJsonType": false, "schemaIdAwareSerializationEnabled": false, "contentType": "AVRO", @@ -251,6 +251,14 @@ "throughput": "0.0" } ], + "topicsTrackingUrls": [ + {"name": "Tracking Link 1", "url": "#"}, + {"name": "Tracking Link 2", "url": "#"} + ], + "subscriptionsTrackingUrls": [ + {"name": "Tracking Link 1", "url": "#"}, + {"name": "Tracking Link 2", "url": "#"} + ], "topicsOwners": [ { "id": "41", @@ -393,7 +401,7 @@ "retryClientErrors": true, "backoffMaxIntervalMillis": 600000 }, - "trackingEnabled": false, + "trackingEnabled": true, "trackingMode": "trackingOff", "owner": { "source": "Service Catalog", diff --git a/hermes-console/json-server/routes.json b/hermes-console/json-server/routes.json index b09e83e1d4..f082aaeedf 100644 --- a/hermes-console/json-server/routes.json +++ b/hermes-console/json-server/routes.json @@ -8,6 +8,8 @@ "/owners/sources/Service%20Catalog/:id": "/topicsOwners/:id", "/readiness/datacenters": "/readinessDatacenters", "/topics": "/topicNames", + "/tracking-urls/topics/:topicName": "/topicsTrackingUrls", + "/tracking-urls/topics/:topicName/subscriptions/:subscriptionName": "/subscriptionsTrackingUrls", "/topics/:id/metrics": "/topicsMetrics/:id", "/topics/:id/preview": "/topicPreview", "/topics/:id/offline-clients-source": "/offlineClientsSource", diff --git a/hermes-console/src/api/hermes-client/index.ts b/hermes-console/src/api/hermes-client/index.ts index 02e567d634..c98cefc70f 100644 --- a/hermes-console/src/api/hermes-client/index.ts +++ b/hermes-console/src/api/hermes-client/index.ts @@ -45,6 +45,7 @@ import type { Stats } from '@/api/stats'; import type { SubscriptionHealth } from '@/api/subscription-health'; import type { SubscriptionMetrics } from '@/api/subscription-metrics'; import type { TopicForm } from '@/composables/topic/use-form-topic/types'; +import type { TrackingUrl } from '@/api/tracking-url'; const acceptHeader = 'Accept'; const contentTypeHeader = 'Content-Type'; @@ -190,6 +191,21 @@ export function fetchOfflineClientsSource( ); } +export function getTopicTrackingUrls( + topicName: string, +): ResponsePromise { + return axios.get(`/tracking-urls/topics/${topicName}`); +} + +export function getSubscriptionTrackingUrls( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/tracking-urls/topics/${topicName}/subscriptions/${subscriptionName}`, + ); +} + export function fetchTopicClients( topicName: string, ): ResponsePromise { diff --git a/hermes-console/src/api/tracking-url.ts b/hermes-console/src/api/tracking-url.ts new file mode 100644 index 0000000000..6f7b07fa7e --- /dev/null +++ b/hermes-console/src/api/tracking-url.ts @@ -0,0 +1,4 @@ +export interface TrackingUrl { + name: string; + url: string; +} diff --git a/hermes-console/src/components/tracking-card/TrackingCard.spec.ts b/hermes-console/src/components/tracking-card/TrackingCard.spec.ts new file mode 100644 index 0000000000..aa43f1a359 --- /dev/null +++ b/hermes-console/src/components/tracking-card/TrackingCard.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'vitest'; +import { render } from '@/utils/test-utils'; +import TrackingCard from '@/components/tracking-card/TrackingCard.vue'; + +describe('TrackingCard', () => { + const props = { + trackingUrls: [ + { name: 'url1', url: 'https://test-tracking-url1' }, + { name: 'url2', url: 'https://test-tracking-url2' }, + ], + }; + + it('should render title properly', () => { + // when + const { getByText } = render(TrackingCard, { props }); + + // then + const row = getByText('trackingCard.title'); + expect(row).toBeVisible(); + }); + + it('should render all tracking urls', () => { + // when + const { container } = render(TrackingCard, { props }); + + // then + const elements = container.querySelectorAll('a')!!; + expect(elements[0]).toHaveAttribute('href', 'https://test-tracking-url1'); + expect(elements[0]).toHaveTextContent('url1'); + expect(elements[1]).toHaveAttribute('href', 'https://test-tracking-url2'); + expect(elements[1]).toHaveTextContent('url2'); + }); + + it('should render message when no tracking urls', () => { + // given + const emptyProps = { trackingUrls: [] }; + const { getByText } = render(TrackingCard, { emptyProps }); + + // then + const row = getByText('trackingCard.noTrackingUrls'); + expect(row).toBeVisible(); + }); +}); diff --git a/hermes-console/src/components/tracking-card/TrackingCard.vue b/hermes-console/src/components/tracking-card/TrackingCard.vue new file mode 100644 index 0000000000..c79a822f79 --- /dev/null +++ b/hermes-console/src/components/tracking-card/TrackingCard.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts b/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts index 811508c83f..7b4581bda1 100644 --- a/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts +++ b/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts @@ -6,6 +6,7 @@ import { fetchSubscriptionHealth as getSubscriptionHealth, fetchSubscriptionLastUndeliveredMessage as getSubscriptionLastUndeliveredMessage, fetchSubscriptionMetrics as getSubscriptionMetrics, + getSubscriptionTrackingUrls, fetchSubscriptionUndeliveredMessages as getSubscriptionUndeliveredMessages, retransmitSubscriptionMessages, suspendSubscription as suspend, @@ -20,6 +21,7 @@ import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Subscription } from '@/api/subscription'; import type { SubscriptionHealth } from '@/api/subscription-health'; import type { SubscriptionMetrics } from '@/api/subscription-metrics'; +import type { TrackingUrl } from '@/api/tracking-url'; export interface UseSubscription { subscription: Ref; @@ -28,6 +30,7 @@ export interface UseSubscription { subscriptionHealth: Ref; subscriptionUndeliveredMessages: Ref; subscriptionLastUndeliveredMessage: Ref; + trackingUrls: Ref; loading: Ref; error: Ref; removeSubscription: () => Promise; @@ -44,6 +47,7 @@ export interface UseSubscriptionsErrors { fetchSubscriptionHealth: Error | null; fetchSubscriptionUndeliveredMessages: Error | null; fetchSubscriptionLastUndeliveredMessage: Error | null; + getSubscriptionTrackingUrls: Error | null; } export function useSubscription( @@ -58,6 +62,7 @@ export function useSubscription( const subscriptionHealth = ref(); const subscriptionUndeliveredMessages = ref([]); const subscriptionLastUndeliveredMessage = ref(null); + const trackingUrls = ref(); const loading = ref(false); const error = ref({ fetchSubscription: null, @@ -66,6 +71,7 @@ export function useSubscription( fetchSubscriptionHealth: null, fetchSubscriptionUndeliveredMessages: null, fetchSubscriptionLastUndeliveredMessage: null, + getSubscriptionTrackingUrls: null, }); const fetchSubscription = async () => { @@ -150,6 +156,16 @@ export function useSubscription( } }; + const fetchSubscriptionTrackingUrls = async () => { + try { + trackingUrls.value = ( + await getSubscriptionTrackingUrls(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.getSubscriptionTrackingUrls = e as Error; + } + }; + const removeSubscription = async (): Promise => { try { await deleteSubscription(topicName, subscriptionName); @@ -278,6 +294,7 @@ export function useSubscription( }; fetchSubscription(); + fetchSubscriptionTrackingUrls(); return { subscription, @@ -286,6 +303,7 @@ export function useSubscription( subscriptionHealth, subscriptionUndeliveredMessages, subscriptionLastUndeliveredMessage, + trackingUrls, loading, error, removeSubscription, diff --git a/hermes-console/src/composables/topic/use-topic/useTopic.ts b/hermes-console/src/composables/topic/use-topic/useTopic.ts index e9dac5eaca..456923756e 100644 --- a/hermes-console/src/composables/topic/use-topic/useTopic.ts +++ b/hermes-console/src/composables/topic/use-topic/useTopic.ts @@ -9,6 +9,7 @@ import { fetchOwner as getTopicOwner, fetchTopicSubscriptionDetails as getTopicSubscriptionDetails, fetchTopicSubscriptions as getTopicSubscriptions, + getTopicTrackingUrls, } from '@/api/hermes-client'; import { dispatchErrorNotification } from '@/utils/notification-utils'; import { ref } from 'vue'; @@ -24,6 +25,7 @@ import type { OfflineClientsSource } from '@/api/offline-clients-source'; import type { Owner } from '@/api/owner'; import type { Ref } from 'vue'; import type { Subscription } from '@/api/subscription'; +import type { TrackingUrl } from '@/api/tracking-url'; export interface UseTopic { topic: Ref; @@ -32,6 +34,7 @@ export interface UseTopic { metrics: Ref; subscriptions: Ref; offlineClientsSource: Ref; + trackingUrls: Ref; loading: Ref; error: Ref; fetchOfflineClientsSource: () => Promise; @@ -46,6 +49,7 @@ export interface UseTopicErrors { fetchTopicMetrics: Error | null; fetchSubscriptions: Error | null; fetchOfflineClientsSource: Error | null; + getTopicTrackingUrls: Error | null; } export function useTopic(topicName: string): UseTopic { @@ -57,6 +61,7 @@ export function useTopic(topicName: string): UseTopic { const metrics = ref(); const subscriptions = ref(); const offlineClientsSource = ref(); + const trackingUrls = ref(); const loading = ref(false); const error = ref({ fetchTopic: null, @@ -65,6 +70,7 @@ export function useTopic(topicName: string): UseTopic { fetchTopicMetrics: null, fetchSubscriptions: null, fetchOfflineClientsSource: null, + getTopicTrackingUrls: null, }); const fetchTopic = async () => { @@ -152,6 +158,14 @@ export function useTopic(topicName: string): UseTopic { } }; + const fetchTopicTrackingUrls = async () => { + try { + trackingUrls.value = (await getTopicTrackingUrls(topicName)).data; + } catch (e) { + error.value.getTopicTrackingUrls = e as Error; + } + }; + const removeTopic = async (): Promise => { try { await deleteTopic(topicName); @@ -188,6 +202,7 @@ export function useTopic(topicName: string): UseTopic { }; fetchTopic(); + fetchTopicTrackingUrls(); return { topic, @@ -196,6 +211,7 @@ export function useTopic(topicName: string): UseTopic { metrics, subscriptions, offlineClientsSource, + trackingUrls, loading, error, fetchOfflineClientsSource, diff --git a/hermes-console/src/dummy/tracking-urls.ts b/hermes-console/src/dummy/tracking-urls.ts new file mode 100644 index 0000000000..2bf5249ba2 --- /dev/null +++ b/hermes-console/src/dummy/tracking-urls.ts @@ -0,0 +1,6 @@ +import type { TrackingUrl } from '@/api/tracking-url'; + +export const dummyTrackingUrls: TrackingUrl[] = [ + { name: 'url1', url: 'https://test-url1' }, + { name: 'url2', url: 'https://test-url2' }, +]; diff --git a/hermes-console/src/i18n/en-US/index.ts b/hermes-console/src/i18n/en-US/index.ts index 5bd9978baa..e189bac9df 100644 --- a/hermes-console/src/i18n/en-US/index.ts +++ b/hermes-console/src/i18n/en-US/index.ts @@ -838,6 +838,10 @@ const en_US = { title: 'Costs', detailsButton: 'DASHBOARD', }, + trackingCard: { + title: 'Tracking', + noTrackingUrls: 'No tracking urls available', + }, }; export default en_US; diff --git a/hermes-console/src/views/subscription/SubscriptionView.spec.ts b/hermes-console/src/views/subscription/SubscriptionView.spec.ts index 8e19728ce8..5cf315fa29 100644 --- a/hermes-console/src/views/subscription/SubscriptionView.spec.ts +++ b/hermes-console/src/views/subscription/SubscriptionView.spec.ts @@ -13,6 +13,7 @@ import { dummyUndeliveredMessage, dummyUndeliveredMessages, } from '@/dummy/subscription'; +import { dummyTrackingUrls } from '@/dummy/tracking-urls'; import { fireEvent } from '@testing-library/vue'; import { render } from '@/utils/test-utils'; import { Role } from '@/api/role'; @@ -35,6 +36,7 @@ const useSubscriptionStub: ReturnType = { subscriptionHealth: ref(dummySubscriptionHealth), subscriptionUndeliveredMessages: ref(dummyUndeliveredMessages), subscriptionLastUndeliveredMessage: ref(dummyUndeliveredMessage), + trackingUrls: ref(dummyTrackingUrls), error: ref({ fetchSubscription: null, fetchOwner: null, @@ -42,6 +44,7 @@ const useSubscriptionStub: ReturnType = { fetchSubscriptionHealth: null, fetchSubscriptionUndeliveredMessages: null, fetchSubscriptionLastUndeliveredMessage: null, + getSubscriptionTrackingUrls: null, }), loading: computed(() => false), removeSubscription: () => Promise.resolve(true), @@ -349,4 +352,26 @@ describe('SubscriptionView', () => { // then expect(queryByText('costsCard.title')).not.toBeInTheDocument(); }); + + it('should render tracking card when tracking is enabled', () => { + // given + const dummySubscription2 = dummySubscription; + dummySubscription2.trackingEnabled = true; + + // and + vi.mocked(useSubscription).mockReturnValueOnce({ + ...useSubscriptionStub, + subscription: ref(dummySubscription2), + }); + vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); + vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); + + // when + const { getByText } = render(SubscriptionView, { + testPinia: createTestingPiniaWithState(), + }); + + // then + expect(getByText('trackingCard.title')).toBeVisible(); + }); }); diff --git a/hermes-console/src/views/subscription/SubscriptionView.vue b/hermes-console/src/views/subscription/SubscriptionView.vue index e1e5b4ab88..728ae5e0d0 100644 --- a/hermes-console/src/views/subscription/SubscriptionView.vue +++ b/hermes-console/src/views/subscription/SubscriptionView.vue @@ -19,6 +19,7 @@ import MetricsCard from '@/views/subscription/metrics-card/MetricsCard.vue'; import PropertiesCard from '@/views/subscription/properties-card/PropertiesCard.vue'; import SubscriptionMetadata from '@/views/subscription/subscription-metadata/SubscriptionMetadata.vue'; + import TrackingCard from '@/components/tracking-card/TrackingCard.vue'; import UndeliveredMessagesCard from '@/views/subscription/undelivered-messages-card/UndeliveredMessagesCard.vue'; const router = useRouter(); @@ -34,6 +35,7 @@ subscriptionHealth, subscriptionUndeliveredMessages, subscriptionLastUndeliveredMessage, + trackingUrls, error, loading, removeSubscription, @@ -224,6 +226,10 @@ :iframe-url="costs.iframeUrl" :details-url="costs.detailsUrl" /> + Promise.resolve(), removeTopic: () => Promise.resolve(true), }; @@ -267,6 +270,7 @@ describe('TopicView', () => { fetchTopicMetrics: null, fetchSubscriptions: null, fetchOfflineClientsSource: null, + getTopicTrackingUrls: null, }), }); vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); @@ -346,4 +350,25 @@ describe('TopicView', () => { getByText('topicView.confirmationDialog.remove.text'), ).toBeInTheDocument(); }); + + it('should render tracking card when tracking is enabled', () => { + // given + const dummyTopic2 = dummyTopic; + dummyTopic2.trackingEnabled = true; + + // and + vi.mocked(useTopic).mockReturnValueOnce({ + ...useTopicMock, + topic: ref(dummyTopic2), + }); + vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); + + // when + const { getByText } = render(TopicView, { + testPinia: createTestingPiniaWithState(), + }); + + // then + expect(getByText('trackingCard.title')).toBeVisible(); + }); }); diff --git a/hermes-console/src/views/topic/TopicView.vue b/hermes-console/src/views/topic/TopicView.vue index 054afb19b6..d17e934915 100644 --- a/hermes-console/src/views/topic/TopicView.vue +++ b/hermes-console/src/views/topic/TopicView.vue @@ -18,6 +18,7 @@ import SchemaPanel from '@/views/topic/schema-panel/SchemaPanel.vue'; import SubscriptionsList from '@/views/topic/subscriptions-list/SubscriptionsList.vue'; import TopicHeader from '@/views/topic/topic-header/TopicHeader.vue'; + import TrackingCard from '@/components/tracking-card/TrackingCard.vue'; const router = useRouter(); @@ -37,6 +38,7 @@ error, subscriptions, offlineClientsSource, + trackingUrls, fetchOfflineClientsSource, removeTopic, fetchTopicClients, @@ -146,6 +148,10 @@ :iframe-url="costs.iframeUrl" :details-url="costs.detailsUrl" /> + diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/TrackingUrlsEndpoint.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/TrackingUrlsEndpoint.java new file mode 100644 index 0000000000..e246d4785d --- /dev/null +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/TrackingUrlsEndpoint.java @@ -0,0 +1,60 @@ +package pl.allegro.tech.hermes.management.api; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Controller; +import pl.allegro.tech.hermes.management.api.auth.Roles; +import pl.allegro.tech.hermes.tracker.management.TrackingUrlProvider; + +@Controller +@Path("/tracking-urls") +@Api(value = "/tracking-urls", description = "Tracking urls for topics and subscriptions") +public class TrackingUrlsEndpoint { + private final Optional trackingUrlProvider; + + public TrackingUrlsEndpoint(Optional trackingUrlProvider) { + this.trackingUrlProvider = trackingUrlProvider; + } + + @GET + @Path("/topics/{topic}") + @Produces(APPLICATION_JSON) + @RolesAllowed(Roles.ANY) + @ApiOperation( + value = "Tracking urls for given topic", + response = List.class, + httpMethod = HttpMethod.GET) + public Response getTopicTrackingUrls(@PathParam("topic") String topic) { + return trackingUrlProvider + .map(provider -> Response.ok(provider.getTrackingUrlsForTopic(topic))) + .orElse(Response.ok(List.of())) + .build(); + } + + @GET + @Path("/topics/{topic}/subscriptions/{subscription}") + @Produces(APPLICATION_JSON) + @RolesAllowed(Roles.ANY) + @ApiOperation( + value = "Tracking urls for given subscription", + response = List.class, + httpMethod = HttpMethod.GET) + public Response getSubscriptionTrackingUrls( + @PathParam("topic") String topic, @PathParam("subscription") String subscription) { + return trackingUrlProvider + .map(provider -> Response.ok(provider.getTrackingUrlsForSubscription(topic, subscription))) + .orElse(Response.ok(List.of())) + .build(); + } +} diff --git a/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrl.java b/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrl.java new file mode 100644 index 0000000000..f20b8324f7 --- /dev/null +++ b/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrl.java @@ -0,0 +1,5 @@ +package pl.allegro.tech.hermes.tracker.management; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TrackingUrl(@JsonProperty String name, @JsonProperty String url) {} diff --git a/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrlProvider.java b/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrlProvider.java new file mode 100644 index 0000000000..c614d8dd78 --- /dev/null +++ b/hermes-tracker/src/main/java/pl/allegro/tech/hermes/tracker/management/TrackingUrlProvider.java @@ -0,0 +1,10 @@ +package pl.allegro.tech.hermes.tracker.management; + +import java.util.Collection; + +public interface TrackingUrlProvider { + Collection getTrackingUrlsForTopic(String qualifiedTopicName); + + Collection getTrackingUrlsForSubscription( + String qualifiedTopicName, String subscriptionName); +}