diff --git a/docker/latest/management/management.yaml b/docker/latest/management/management.yaml index bfde4b029d..0e5680158a 100644 --- a/docker/latest/management/management.yaml +++ b/docker/latest/management/management.yaml @@ -4,15 +4,13 @@ zookeeper: storage: pathPrefix: /run/hermes clusters: - - - datacenter: dc - clusterName: zk + - datacenter: dc + root: /run/hermes connectionString: zk:2181 kafka: clusters: - - - datacenter: dc + - datacenter: dc clusterName: primary connectionTimeout: 3000 bootstrapKafkaServer: kafka:29092 diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItem.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItem.java new file mode 100644 index 0000000000..2e5c4def8a --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItem.java @@ -0,0 +1,15 @@ +package pl.allegro.tech.hermes.api; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = TopicSearchItem.class, name = "TOPIC"), + @JsonSubTypes.Type(value = SubscriptionSearchItem.class, name = "SUBSCRIPTION"), +}) +public interface SearchItem { + SearchItemType type(); + + String name(); +} diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItemType.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItemType.java new file mode 100644 index 0000000000..60dc2c8661 --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchItemType.java @@ -0,0 +1,6 @@ +package pl.allegro.tech.hermes.api; + +public enum SearchItemType { + TOPIC, + SUBSCRIPTION +} diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchResults.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchResults.java new file mode 100644 index 0000000000..8243b0fcc2 --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SearchResults.java @@ -0,0 +1,11 @@ +package pl.allegro.tech.hermes.api; + +import static java.util.Collections.emptyList; + +import java.util.List; + +public record SearchResults(List results, long totalCount) { + public static SearchResults empty() { + return new SearchResults(emptyList(), 0); + } +} diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SubscriptionSearchItem.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SubscriptionSearchItem.java new file mode 100644 index 0000000000..1db3635109 --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/SubscriptionSearchItem.java @@ -0,0 +1,12 @@ +package pl.allegro.tech.hermes.api; + +public record SubscriptionSearchItem(String name, Subscription subscription) implements SearchItem { + @Override + public SearchItemType type() { + return SearchItemType.SUBSCRIPTION; + } + + public record Subscription(String endpoint, Topic topic) {} + + public record Topic(String name, String qualifiedName, String groupName) {} +} diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicSearchItem.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicSearchItem.java new file mode 100644 index 0000000000..677d82b43a --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicSearchItem.java @@ -0,0 +1,12 @@ +package pl.allegro.tech.hermes.api; + +public record TopicSearchItem(String name, Topic topic) implements SearchItem { + @Override + public SearchItemType type() { + return SearchItemType.TOPIC; + } + + public record Topic(String groupName, Owner owner) {} + + public record Owner(String id) {} +} diff --git a/hermes-console/.nvmrc b/hermes-console/.nvmrc index d135defb28..442c7587a9 100644 --- a/hermes-console/.nvmrc +++ b/hermes-console/.nvmrc @@ -1 +1 @@ -22.12.0 \ No newline at end of file +22.20.0 diff --git a/hermes-console/README.md b/hermes-console/README.md index d83356d39c..0091821ddb 100644 --- a/hermes-console/README.md +++ b/hermes-console/README.md @@ -4,7 +4,7 @@ Hermes console written in Vue 3. ## Requirements -* node >=22.12.0 +* node >=22.20.0 * yarn ## Node setup diff --git a/hermes-console/json-server/db.json b/hermes-console/json-server/db.json index cc2d46ab2a..5f39b8d4c0 100644 --- a/hermes-console/json-server/db.json +++ b/hermes-console/json-server/db.json @@ -768,4 +768,4 @@ "avroSubscriptionCount": 100 } } -} \ No newline at end of file +} diff --git a/hermes-console/json-server/routes.json b/hermes-console/json-server/routes.json index f082aaeedf..bc3fb90572 100644 --- a/hermes-console/json-server/routes.json +++ b/hermes-console/json-server/routes.json @@ -30,5 +30,6 @@ "/owners/sources/*?search=:searchPhrase": "/topicsOwners?name_like=:searchPhrase", "/dashboards/topics/:topicName": "/topicDashboardUrl", "/dashboards/topics/:topicName/subscriptions/:id": "/subscriptionDashboardUrl", - "/inactive-topics": "/inactiveTopics" + "/inactive-topics": "/inactiveTopics", + "/search/query?q=:searchPhrase": "/search" } diff --git a/hermes-console/json-server/search.json b/hermes-console/json-server/search.json new file mode 100644 index 0000000000..a50835ebce --- /dev/null +++ b/hermes-console/json-server/search.json @@ -0,0 +1,37 @@ +{ + "results": [ + { + "type": "TOPIC", + "name": "pl.allegro.public.group.DummyEvent", + "topic": { + "groupName": "pl.allegro.public.group", + "owner": { + "id": "1234" + } + } + }, + { + "type": "SUBSCRIPTION", + "name": "foobar-service", + "subscription": { + "topic": { + "name": "foobar-service", + "groupName": "pl.allegro.public.group", + "qualifiedName": "pl.allegro.public.group.DummyEvent" + } + } + }, + { + "type": "SUBSCRIPTION", + "name": "barbaz-service", + "subscription": { + "topic": { + "name": "barbaz-service", + "groupName": "pl.allegro.public.group", + "qualifiedName": "pl.allegro.public.group.DummyEvent" + } + } + } + ], + "totalCount": 3 +} diff --git a/hermes-console/json-server/server.ts b/hermes-console/json-server/server.ts index ae0693b4aa..0489ed6e78 100644 --- a/hermes-console/json-server/server.ts +++ b/hermes-console/json-server/server.ts @@ -3,6 +3,7 @@ const activeOfflineRetransmissionTasks = require('./active-offline-retransmissio const subscriptions = require('./subscriptions.json'); const routes = require('./routes.json'); const filterDebug = require('./filter-debug.json'); +const search = require('./search.json'); const jsonServer = require('json-server'); const server = jsonServer.create(); @@ -21,6 +22,10 @@ server.post('/query/subscriptions', (req, res) => { res.jsonp(subscriptions); }); +server.get('search/query', (req, res) => { + res.jsonp(search); +}); + server.post('/topicSubscriptions', (req, res) => { res.sendStatus(200); }); diff --git a/hermes-console/package.json b/hermes-console/package.json index 49bcbd3a23..3a05248d22 100644 --- a/hermes-console/package.json +++ b/hermes-console/package.json @@ -3,7 +3,7 @@ "description": "Console for Hermes Management", "license": "Apache-2.0", "engines": { - "node": ">=22.12.0" + "node": ">=22.20.0" }, "scripts": { "dev": "vite", @@ -29,7 +29,7 @@ "vue-i18n": "11.1.10", "vue-router": "4.3.2", "vue3-ace-editor": "2.2.4", - "vuetify": "3.10.5" + "vuetify": "3.11.0" }, "devDependencies": { "@mdi/font": "7.4.47", diff --git a/hermes-console/src/api/SearchResults.ts b/hermes-console/src/api/SearchResults.ts new file mode 100644 index 0000000000..ff68187220 --- /dev/null +++ b/hermes-console/src/api/SearchResults.ts @@ -0,0 +1,39 @@ +export interface SearchResults { + totalCount: number; + results: SearchResultItem[]; +} + +export type SearchResultItem = + | SearchResultTopicItem + | SearchResultSubscriptionItem; + +export interface SearchResultTopicItem { + type: 'TOPIC'; + name: string; + topic: TopicItemDetails; +} + +export interface TopicItemDetails { + groupName: string; + owner: TopicOwnerDetails; +} + +export interface TopicOwnerDetails { + id: string; +} + +export interface SearchResultSubscriptionItem { + type: 'SUBSCRIPTION'; + name: string; + subscription: SubscriptionItemDetails; +} + +export interface SubscriptionItemDetails { + topic: SubscriptionTopicDetails; +} + +export interface SubscriptionTopicDetails { + name: string; + qualifiedName: string; + groupName: string; +} diff --git a/hermes-console/src/api/hermes-client/index.ts b/hermes-console/src/api/hermes-client/index.ts index 1463ef5419..8385a75d67 100644 --- a/hermes-console/src/api/hermes-client/index.ts +++ b/hermes-console/src/api/hermes-client/index.ts @@ -43,6 +43,7 @@ import type { Owner, OwnerSource } from '@/api/owner'; import type { ResponsePromise } from '@/utils/axios/axios-utils'; import type { RetransmissionDate } from '@/api/OffsetRetransmissionDate'; import type { Role } from '@/api/role'; +import type { SearchResults } from '@/api/SearchResults'; import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Stats } from '@/api/stats'; import type { SubscriptionHealth } from '@/api/subscription-health'; @@ -305,6 +306,12 @@ export function querySubscriptions( }); } +export function search(query: string): ResponsePromise { + return axios.get( + `/search/query?q=${encodeURIComponent(query)}`, + ); +} + export function fetchRoles(path: string): ResponsePromise { return axios.get(path); } diff --git a/hermes-console/src/components/command-palette/CommandPalette.spec.ts b/hermes-console/src/components/command-palette/CommandPalette.spec.ts new file mode 100644 index 0000000000..d6745c1459 --- /dev/null +++ b/hermes-console/src/components/command-palette/CommandPalette.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; +import { render, renderWithEmits } from '@/utils/test-utils'; +import CommandPalette from '@/components/command-palette/CommandPalette.vue'; + +describe('CommandPalette', () => { + const items = [ + { + id: '1', + type: 'subheader', + title: 'Group 1', + }, + { + id: '2', + type: 'item', + title: 'Item 1', + subtitle: 'Subtitle 1', + onClick: vi.fn(), + }, + { + id: '3', + type: 'divider', + }, + { + id: '4', + type: 'subheader', + title: 'Group 2', + }, + { + id: '5', + type: 'item', + title: 'Item 2', + onClick: vi.fn(), + }, + ]; + + it('should render search input', () => { + // given + const { getByPlaceholderText } = render(CommandPalette, { + props: { + items: [], + numberOfResults: 0, + search: '', + loading: false, + modelValue: true, + inputPlaceholder: 'Search topics and subscriptions', + }, + }); + + // expect + expect( + getByPlaceholderText('Search topics and subscriptions'), + ).toBeVisible(); + }); + + it('should render loading indicator', () => { + // given + const { getByRole } = render(CommandPalette, { + props: { + items: [], + numberOfResults: 0, + search: '', + loading: true, + modelValue: true, + }, + }); + + // expect + expect(getByRole('progressbar')).toBeVisible(); + }); + + it('should render no results message', () => { + // given + const { getByText } = render(CommandPalette, { + props: { + items: [], + numberOfResults: 0, + search: 'test', + loading: false, + modelValue: true, + }, + }); + + // expect + expect(getByText('commandPalette.noResults')).toBeVisible(); + }); + + it('should render search incentive message', () => { + // given + const { getByText } = render(CommandPalette, { + props: { + items: [], + numberOfResults: 0, + search: '', + loading: false, + modelValue: true, + }, + }); + + // expect + expect(getByText('commandPalette.searchIncentive')).toBeVisible(); + }); + + it('should render items', () => { + // given + const { getByText } = render(CommandPalette, { + props: { + items, + numberOfResults: 2, + search: 'test', + loading: false, + modelValue: true, + }, + }); + + // expect + expect(getByText('Group 1')).toBeVisible(); + expect(getByText('Item 1')).toBeVisible(); + expect(getByText('Subtitle 1')).toBeVisible(); + expect(getByText('Group 2')).toBeVisible(); + expect(getByText('Item 2')).toBeVisible(); + }); + + it('should emit update:search on search input', async () => { + // given + const wrapper = renderWithEmits(CommandPalette, { + props: { + items: [], + numberOfResults: 0, + search: '', + loading: false, + modelValue: true, + inputPlaceholder: 'Search topics and subscriptions', + }, + }); + + // when + const input = document.body.querySelector( + 'input[placeholder="Search topics and subscriptions"]', + ) as HTMLInputElement; + await fireEvent.update(input, 'new value'); + + // then + expect(wrapper.emitted()['update:search'][0]).toEqual(['new value']); + }); + + it('should call action on item click', async () => { + // given + const { getByText } = render(CommandPalette, { + props: { + items, + numberOfResults: 2, + search: 'test', + loading: false, + modelValue: true, + }, + }); + + // when + await fireEvent.click(getByText('Item 1')); + + // then + const item = items.find((it) => it.id === '2')!!; + expect(vi.mocked(item.onClick)).toHaveBeenCalled(); + }); +}); diff --git a/hermes-console/src/components/command-palette/CommandPalette.vue b/hermes-console/src/components/command-palette/CommandPalette.vue new file mode 100644 index 0000000000..135d1c19d3 --- /dev/null +++ b/hermes-console/src/components/command-palette/CommandPalette.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.spec.ts b/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.spec.ts new file mode 100644 index 0000000000..4d525b48c4 --- /dev/null +++ b/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { render, renderWithEmits } from '@/utils/test-utils'; +import CommandPaletteItem from '@/components/command-palette/command-palette-item/CommandPaletteItem.vue'; + +describe('CommandPaletteItem', () => { + it('should render title properly', () => { + // given + const { getByText } = render(CommandPaletteItem, { + props: { + title: 'Sample title', + }, + }); + + // expect + expect(getByText('Sample title')).toBeVisible(); + }); + + it('should render subtitle properly', () => { + // given + const { getByText } = render(CommandPaletteItem, { + props: { + title: 'Sample title', + subtitle: 'Sample subtitle', + }, + }); + + // expect + expect(getByText('Sample subtitle')).toBeVisible(); + }); + + it('should render icon properly', () => { + // given + const { container } = render(CommandPaletteItem, { + props: { + title: 'Sample title', + icon: 'mdi-home', + }, + }); + + // expect + const icon = container.querySelector('i.mdi-home'); + expect(icon).toBeVisible(); + }); + + it('should render label properly', () => { + // given + const { getByText } = render(CommandPaletteItem, { + props: { + title: 'Sample title', + label: 'Sample label', + }, + }); + + // expect + expect(getByText('Sample label')).toBeVisible(); + }); + + it('should emit click event on click', async () => { + // given + const wrapper = renderWithEmits(CommandPaletteItem, { + props: { + title: 'Sample title', + }, + }); + + // when + await wrapper.find('[role="button"]').trigger('click'); + + // then + expect(wrapper.emitted().click).toBeTruthy(); + }); +}); diff --git a/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.vue b/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.vue new file mode 100644 index 0000000000..40175d252e --- /dev/null +++ b/hermes-console/src/components/command-palette/command-palette-item/CommandPaletteItem.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/hermes-console/src/components/command-palette/types.ts b/hermes-console/src/components/command-palette/types.ts new file mode 100644 index 0000000000..22a6181653 --- /dev/null +++ b/hermes-console/src/components/command-palette/types.ts @@ -0,0 +1,26 @@ +export type CommandPaletteElement = + | CommandPaletteSubheaderElement + | CommandPaletteDividerElement + | CommandPaletteItemElement; + +export interface CommandPaletteSubheaderElement { + type: 'subheader'; + id: string; + title: string; +} + +export interface CommandPaletteDividerElement { + type: 'divider'; + id: string; +} + +export interface CommandPaletteItemElement { + type: 'item'; + id: string; + title: string; + subtitle: string; + icon: string; + label: string; + labelColor: string; + onClick?: () => void; +} diff --git a/hermes-console/src/components/console-header/ConsoleHeader.spec.ts b/hermes-console/src/components/console-header/ConsoleHeader.spec.ts index a5f12b9d84..8981a5c9c6 100644 --- a/hermes-console/src/components/console-header/ConsoleHeader.spec.ts +++ b/hermes-console/src/components/console-header/ConsoleHeader.spec.ts @@ -13,12 +13,12 @@ import ConsoleHeader from '@/components/console-header/ConsoleHeader.vue'; describe('ConsoleHeader', () => { it('renders properly', () => { // when - const { getByRole } = render(ConsoleHeader, { + const { getAllByRole } = render(ConsoleHeader, { testPinia: createTestingPiniaWithState(), }); // then - expect(getByRole('img')).toHaveAttribute('alt', 'Hermes'); + expect(getAllByRole('img')[0]).toHaveAttribute('alt', 'Hermes'); }); it('should display login button', () => { diff --git a/hermes-console/src/components/console-header/ConsoleHeader.vue b/hermes-console/src/components/console-header/ConsoleHeader.vue index e43a56d6ea..5e49387905 100644 --- a/hermes-console/src/components/console-header/ConsoleHeader.vue +++ b/hermes-console/src/components/console-header/ConsoleHeader.vue @@ -1,22 +1,24 @@ diff --git a/hermes-console/src/components/search-commander/search-bar/SearchBar.spec.ts b/hermes-console/src/components/search-commander/search-bar/SearchBar.spec.ts new file mode 100644 index 0000000000..592fd0120d --- /dev/null +++ b/hermes-console/src/components/search-commander/search-bar/SearchBar.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { render, renderWithEmits } from '@/utils/test-utils'; +import SearchBar from '@/components/search-commander/search-bar/SearchBar.vue'; + +describe('SearchBar', () => { + it('should render the search button', () => { + // given + const props = { + hotKey: 'Ctrl+k', + }; + + // when + const { getByText } = render(SearchBar, { props }); + + // then + expect(getByText('Search')).toBeVisible(); + expect(getByText('Ctrl')).toBeVisible(); + expect(getByText('+')).toBeVisible(); + expect(getByText('K')).toBeVisible(); + }); + + it('should emit an open event when the search button is clicked', async () => { + // given + const props = { + hotKey: 'ctrl+k', + }; + const wrapper = renderWithEmits(SearchBar, { props }); + + // when + await wrapper.find('[data-testid="search-bar-button"]').trigger('click'); + + // then + expect(wrapper.emitted()).toHaveProperty('open'); + }); +}); diff --git a/hermes-console/src/components/search-commander/search-bar/SearchBar.vue b/hermes-console/src/components/search-commander/search-bar/SearchBar.vue new file mode 100644 index 0000000000..947240cd4a --- /dev/null +++ b/hermes-console/src/components/search-commander/search-bar/SearchBar.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/hermes-console/src/composables/roles/use-roles/useRoles.ts b/hermes-console/src/composables/roles/use-roles/useRoles.ts index 0fb259f32a..0ba0130c56 100644 --- a/hermes-console/src/composables/roles/use-roles/useRoles.ts +++ b/hermes-console/src/composables/roles/use-roles/useRoles.ts @@ -1,5 +1,5 @@ import { fetchRoles as getRoles } from '@/api/hermes-client'; -import { ref } from 'vue'; +import { type MaybeRef, ref, toValue, watch } from 'vue'; import { useGlobalI18n } from '@/i18n'; import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { Ref } from 'vue'; @@ -16,8 +16,8 @@ export interface UseRolesErrors { } export function useRoles( - topicName: string | null, - subscriptionName: string | null, + topicName: MaybeRef, + subscriptionName: MaybeRef, ): UseRoles { const notificationStore = useNotificationsStore(); @@ -30,7 +30,7 @@ export function useRoles( try { loading.value = true; roles.value = ( - await getRoles(buildPath(topicName, subscriptionName)) + await getRoles(buildPath(toValue(topicName), toValue(subscriptionName))) ).data; } catch (e) { error.value.fetchRoles = e as Error; @@ -43,7 +43,13 @@ export function useRoles( } }; - fetchRoles(); + watch( + () => [toValue(topicName), toValue(subscriptionName)], + async () => { + await fetchRoles(); + }, + { immediate: true }, + ); return { roles, diff --git a/hermes-console/src/composables/search-v2/useSearchV2.ts b/hermes-console/src/composables/search-v2/useSearchV2.ts new file mode 100644 index 0000000000..8f5a3259c1 --- /dev/null +++ b/hermes-console/src/composables/search-v2/useSearchV2.ts @@ -0,0 +1,41 @@ +import { ref } from 'vue'; +import { search } from '@/api/hermes-client'; +import type { Ref } from 'vue'; +import type { SearchResults } from '@/api/SearchResults'; + +export interface UseSearch { + results: Ref; + runSearch: (query: string) => void; + loading: Ref; + error: Ref; +} + +export interface UseSearchErrors { + fetchError: Error | null; +} + +export function useSearch(): UseSearch { + const results = ref(null); + const error = ref({ + fetchError: null, + }); + const loading = ref(false); + + const runSearch = async (query: string) => { + try { + loading.value = true; + results.value = (await search(query)).data; + } catch (e) { + error.value.fetchError = e as Error; + } finally { + loading.value = false; + } + }; + + return { + results, + runSearch, + loading, + error, + }; +} diff --git a/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts b/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts index 149ac21a1c..5932e2bc46 100644 --- a/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts +++ b/hermes-console/src/composables/subscription/use-subscription/useSubscription.ts @@ -12,11 +12,11 @@ import { suspendSubscription as suspend, } from '@/api/hermes-client'; import { dispatchErrorNotification } from '@/utils/notification-utils'; -import { ref } from 'vue'; +import { type MaybeRef, ref, toValue } from 'vue'; +import { type Ref, watch } from 'vue'; import { useGlobalI18n } from '@/i18n'; import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { Owner } from '@/api/owner'; -import type { Ref } from 'vue'; import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Subscription } from '@/api/subscription'; import type { SubscriptionHealth } from '@/api/subscription-health'; @@ -53,8 +53,8 @@ export interface UseSubscriptionsErrors { } export function useSubscription( - topicName: string, - subscriptionName: string, + topicName: MaybeRef, + subscriptionName: MaybeRef, ): UseSubscription { const notificationStore = useNotificationsStore(); @@ -101,7 +101,7 @@ export function useSubscription( const fetchSubscriptionInfo = async () => { try { subscription.value = ( - await getSubscription(topicName, subscriptionName) + await getSubscription(toValue(topicName), toValue(subscriptionName)) ).data; } catch (e) { error.value.fetchSubscription = e as Error; @@ -122,7 +122,10 @@ export function useSubscription( const fetchSubscriptionMetrics = async () => { try { subscriptionMetrics.value = ( - await getSubscriptionMetrics(topicName, subscriptionName) + await getSubscriptionMetrics( + toValue(topicName), + toValue(subscriptionName), + ) ).data; } catch (e) { error.value.fetchSubscriptionMetrics = e as Error; @@ -132,7 +135,10 @@ export function useSubscription( const fetchSubscriptionHealth = async () => { try { subscriptionHealth.value = ( - await getSubscriptionHealth(topicName, subscriptionName) + await getSubscriptionHealth( + toValue(topicName), + toValue(subscriptionName), + ) ).data; } catch (e) { error.value.fetchSubscriptionHealth = e as Error; @@ -142,7 +148,10 @@ export function useSubscription( const fetchSubscriptionUndeliveredMessages = async () => { try { subscriptionUndeliveredMessages.value = ( - await getSubscriptionUndeliveredMessages(topicName, subscriptionName) + await getSubscriptionUndeliveredMessages( + toValue(topicName), + toValue(subscriptionName), + ) ).data; } catch (e) { error.value.fetchSubscriptionUndeliveredMessages = e as Error; @@ -152,7 +161,10 @@ export function useSubscription( const fetchSubscriptionLastUndeliveredMessage = async () => { try { subscriptionLastUndeliveredMessage.value = ( - await getSubscriptionLastUndeliveredMessage(topicName, subscriptionName) + await getSubscriptionLastUndeliveredMessage( + toValue(topicName), + toValue(subscriptionName), + ) ).data; } catch (e) { error.value.fetchSubscriptionLastUndeliveredMessage = e as Error; @@ -162,7 +174,10 @@ export function useSubscription( const fetchSubscriptionTrackingUrls = async () => { try { trackingUrls.value = ( - await getSubscriptionTrackingUrls(topicName, subscriptionName) + await getSubscriptionTrackingUrls( + toValue(topicName), + toValue(subscriptionName), + ) ).data; } catch (e) { error.value.getSubscriptionTrackingUrls = e as Error; @@ -171,20 +186,20 @@ export function useSubscription( const removeSubscription = async (): Promise => { try { - await deleteSubscription(topicName, subscriptionName); + await deleteSubscription(toValue(topicName), toValue(subscriptionName)); await notificationStore.dispatchNotification({ text: useGlobalI18n().t('notifications.subscription.delete.success', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), type: 'success', }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t('notifications.subscription.delete.failure', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), ); return false; @@ -193,20 +208,20 @@ export function useSubscription( const suspendSubscription = async (): Promise => { try { - await suspend(topicName, subscriptionName); + await suspend(toValue(topicName), toValue(subscriptionName)); await notificationStore.dispatchNotification({ text: useGlobalI18n().t('notifications.subscription.suspend.success', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), type: 'success', }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t('notifications.subscription.suspend.failure', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), ); return false; @@ -215,20 +230,20 @@ export function useSubscription( const activateSubscription = async (): Promise => { try { - await activate(topicName, subscriptionName); + await activate(toValue(topicName), toValue(subscriptionName)); await notificationStore.dispatchNotification({ text: useGlobalI18n().t('notifications.subscription.activate.success', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), type: 'success', }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t('notifications.subscription.activate.failure', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), ); return false; @@ -238,14 +253,18 @@ export function useSubscription( const retransmitMessages = async (from: string): Promise => { retransmitting.value = true; try { - await retransmitSubscriptionMessages(topicName, subscriptionName, { - retransmissionDate: from, - }); + await retransmitSubscriptionMessages( + toValue(topicName), + toValue(subscriptionName), + { + retransmissionDate: from, + }, + ); await notificationStore.dispatchNotification({ title: useGlobalI18n().t( 'notifications.subscription.retransmit.success', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }, ), text: '', @@ -253,11 +272,11 @@ export function useSubscription( }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t('notifications.subscription.retransmit.failure', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }), ); return false; @@ -271,14 +290,18 @@ export function useSubscription( const tomorrowDate = new Date(); tomorrowDate.setDate(tomorrowDate.getDate() + 1); try { - await retransmitSubscriptionMessages(topicName, subscriptionName, { - retransmissionDate: tomorrowDate.toISOString(), - }); + await retransmitSubscriptionMessages( + toValue(topicName), + toValue(subscriptionName), + { + retransmissionDate: tomorrowDate.toISOString(), + }, + ); await notificationStore.dispatchNotification({ title: useGlobalI18n().t( 'notifications.subscription.skipAllMessages.success', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }, ), text: '', @@ -286,13 +309,13 @@ export function useSubscription( }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t( 'notifications.subscription.skipAllMessages.failure', { - subscriptionName, + subscriptionName: toValue(subscriptionName), }, ), ); @@ -302,8 +325,14 @@ export function useSubscription( } }; - fetchSubscription(); - fetchSubscriptionTrackingUrls(); + watch( + () => [toValue(topicName), toValue(subscriptionName)], + async () => { + fetchSubscription().then(); + fetchSubscriptionTrackingUrls().then(); + }, + { immediate: true }, + ); return { subscription, diff --git a/hermes-console/src/composables/topic/use-topic/useTopic.ts b/hermes-console/src/composables/topic/use-topic/useTopic.ts index d2b09cac1c..bd9f430370 100644 --- a/hermes-console/src/composables/topic/use-topic/useTopic.ts +++ b/hermes-console/src/composables/topic/use-topic/useTopic.ts @@ -13,7 +13,7 @@ import { getTopicTrackingUrls, } from '@/api/hermes-client'; import { dispatchErrorNotification } from '@/utils/notification-utils'; -import { ref } from 'vue'; +import { type MaybeRef, type Ref, ref, toValue, watch } from 'vue'; import { useGlobalI18n } from '@/i18n'; import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { ContentType } from '@/api/content-type'; @@ -25,7 +25,6 @@ import type { import type { OfflineClientsSource } from '@/api/offline-clients-source'; import type { OfflineRetransmissionActiveTask } from '@/api/offline-retransmission'; import type { Owner } from '@/api/owner'; -import type { Ref } from 'vue'; import type { Subscription } from '@/api/subscription'; import type { TrackingUrl } from '@/api/tracking-url'; @@ -55,7 +54,7 @@ export interface UseTopicErrors { getTopicTrackingUrls: Error | null; } -export function useTopic(topicName: string): UseTopic { +export function useTopic(topicName: MaybeRef): UseTopic { const notificationStore = useNotificationsStore(); const topic = ref(); @@ -97,7 +96,7 @@ export function useTopic(topicName: string): UseTopic { const fetchTopicInfo = async () => { try { - topic.value = (await getTopic(topicName)).data; + topic.value = (await getTopic(toValue(topicName))).data; } catch (e) { error.value.fetchTopic = e as Error; } @@ -113,7 +112,7 @@ export function useTopic(topicName: string): UseTopic { const fetchTopicMessagesPreview = async () => { try { - messages.value = (await getTopicMessagesPreview(topicName)).data; + messages.value = (await getTopicMessagesPreview(toValue(topicName))).data; } catch (e) { error.value.fetchTopicMessagesPreview = e as Error; } @@ -121,7 +120,7 @@ export function useTopic(topicName: string): UseTopic { const fetchTopicMetrics = async () => { try { - metrics.value = (await getTopicMetrics(topicName)).data; + metrics.value = (await getTopicMetrics(toValue(topicName))).data; } catch (e) { error.value.fetchTopicMetrics = e as Error; } @@ -129,9 +128,11 @@ export function useTopic(topicName: string): UseTopic { const fetchSubscriptions = async () => { try { - const subscriptionsList = (await getTopicSubscriptions(topicName)).data; + const subscriptionsList = ( + await getTopicSubscriptions(toValue(topicName)) + ).data; const subscriptionsDetails = subscriptionsList.map(async (subscription) => - getTopicSubscriptionDetails(topicName, subscription), + getTopicSubscriptionDetails(toValue(topicName), subscription), ); const results = await Promise.allSettled(subscriptionsDetails); subscriptions.value = results @@ -156,7 +157,7 @@ export function useTopic(topicName: string): UseTopic { const fetchOfflineClientsSource = async () => { try { offlineClientsSource.value = ( - await getOfflineClientsSource(topicName) + await getOfflineClientsSource(toValue(topicName)) ).data; } catch (e) { error.value.fetchOfflineClientsSource = e as Error; @@ -165,7 +166,9 @@ export function useTopic(topicName: string): UseTopic { const fetchTopicTrackingUrls = async () => { try { - trackingUrls.value = (await getTopicTrackingUrls(topicName)).data; + trackingUrls.value = ( + await getTopicTrackingUrls(toValue(topicName)) + ).data; } catch (e) { error.value.getTopicTrackingUrls = e as Error; } @@ -173,7 +176,7 @@ export function useTopic(topicName: string): UseTopic { const removeTopic = async (): Promise => { try { - await deleteTopic(topicName); + await deleteTopic(toValue(topicName)); await notificationStore.dispatchNotification({ text: useGlobalI18n().t('notifications.topic.delete.success', { topicName, @@ -182,7 +185,7 @@ export function useTopic(topicName: string): UseTopic { }); return true; } catch (e: any) { - await dispatchErrorNotification( + dispatchErrorNotification( e, notificationStore, useGlobalI18n().t('notifications.topic.delete.failure', { @@ -195,7 +198,7 @@ export function useTopic(topicName: string): UseTopic { const fetchTopicClients = async () => { try { - return (await getTopicClients(topicName)).data; + return (await getTopicClients(toValue(topicName))).data; } catch (e: any) { dispatchErrorNotification( e, @@ -209,7 +212,7 @@ export function useTopic(topicName: string): UseTopic { const fetchActiveOfflineRetransmissions = async () => { try { activeRetransmissions.value = ( - await getActiveOfflineRetransmissions(topicName) + await getActiveOfflineRetransmissions(toValue(topicName)) ).data; } catch (e: any) { dispatchErrorNotification( @@ -222,8 +225,14 @@ export function useTopic(topicName: string): UseTopic { } }; - fetchTopic(); - fetchTopicTrackingUrls(); + watch( + () => toValue(topicName), + async () => { + fetchTopic().then(); + fetchTopicTrackingUrls().then(); + }, + { immediate: true }, + ); return { topic, diff --git a/hermes-console/src/dummy/search.ts b/hermes-console/src/dummy/search.ts new file mode 100644 index 0000000000..79ac233f49 --- /dev/null +++ b/hermes-console/src/dummy/search.ts @@ -0,0 +1,39 @@ +import type { SearchResults } from '@/api/SearchResults'; + +export const dummySearchResults: SearchResults = { + results: [ + { + type: 'TOPIC', + name: 'pl.allegro.public.group.DummyEvent', + topic: { + groupName: 'pl.allegro.public.group', + owner: { + id: '1234', + }, + }, + }, + { + type: 'SUBSCRIPTION', + name: 'foobar-service', + subscription: { + topic: { + name: 'foobar-service', + groupName: 'pl.allegro.public.group', + qualifiedName: 'pl.allegro.public.group.DummyEvent', + }, + }, + }, + { + type: 'SUBSCRIPTION', + name: 'barbaz-service', + subscription: { + topic: { + name: 'barbaz-service', + groupName: 'pl.allegro.public.group', + qualifiedName: 'pl.allegro.public.group.DummyEvent', + }, + }, + }, + ], + totalCount: 3, +}; diff --git a/hermes-console/src/i18n/en-US/index.ts b/hermes-console/src/i18n/en-US/index.ts index 981478f9b7..382d42e5ab 100644 --- a/hermes-console/src/i18n/en-US/index.ts +++ b/hermes-console/src/i18n/en-US/index.ts @@ -1,3 +1,4 @@ +import searchCommander from '@/i18n/en-US/searchCommander'; import subscriptionForm from '@/i18n/en-US/subscription-form'; import topicForm from '@/i18n/en-US/topic-form'; @@ -894,6 +895,7 @@ const en_US = { }, subscriptionForm, topicForm, + searchCommander, filterDebug: { title: 'Debug subscription filters', cancelButton: 'Cancel', @@ -913,6 +915,11 @@ const en_US = { title: 'Tracking', noTrackingUrls: 'No tracking urls available', }, + commandPalette: { + resultsCounts: 'results', + noResults: 'No results found', + searchIncentive: 'Type to start searching', + }, }; export default en_US; diff --git a/hermes-console/src/i18n/en-US/searchCommander.ts b/hermes-console/src/i18n/en-US/searchCommander.ts new file mode 100644 index 0000000000..395c1d7140 --- /dev/null +++ b/hermes-console/src/i18n/en-US/searchCommander.ts @@ -0,0 +1,12 @@ +const searchCommander = { + sections: { + topics: 'Topics', + subscriptions: 'Subscriptions', + others: 'Others', + }, + owner: 'Owner: ', + topic: 'Topic: ', + searchInputPlaceholder: 'Search for topics or subscriptions', +}; + +export default searchCommander; diff --git a/hermes-console/src/main.scss b/hermes-console/src/main.scss index d31c0872b1..2212450502 100644 --- a/hermes-console/src/main.scss +++ b/hermes-console/src/main.scss @@ -3,7 +3,6 @@ */ .v-overlay, .v-tooltip { .v-overlay__content { - background: rgba(var(--v-theme-surface-variant), 1) !important; line-height: 1.25 !important; max-width: 300px !important; } diff --git a/hermes-console/src/views/search/SearchView.spec.ts b/hermes-console/src/views/search/SearchView.spec.ts index 084a7617cb..c68647d4db 100644 --- a/hermes-console/src/views/search/SearchView.spec.ts +++ b/hermes-console/src/views/search/SearchView.spec.ts @@ -128,18 +128,23 @@ describe('SearchView', () => { const { getByLabelText } = render(SearchView, { testPinia: createTestingPiniaWithState(), - global: { plugins: [router] }, }); - expect(getByLabelText('collection').value).toBe('topics'); - expect(getByLabelText('filter').value).toBe('name'); - expect(getByLabelText('regex pattern').value).toBe('test'); + expect((getByLabelText('collection') as HTMLInputElement).value).toBe( + 'search.collection.topics', + ); + expect((getByLabelText('filter') as HTMLInputElement).value).toBe( + 'search.filter.name', + ); + expect((getByLabelText('regex pattern') as HTMLInputElement).value).toBe( + 'test', + ); }); it('should update query parameters in URL when form inputs are modified', async () => { const { getByLabelText, getByRole } = render(SearchView, { testPinia: createTestingPiniaWithState(), - global: { plugins: [router] }, + options: { router: router }, }); await fireEvent.update(getByLabelText('regex pattern'), 'newPattern'); diff --git a/hermes-console/src/views/subscription/SubscriptionView.spec.ts b/hermes-console/src/views/subscription/SubscriptionView.spec.ts index bf0d94b8d2..ed9da8310d 100644 --- a/hermes-console/src/views/subscription/SubscriptionView.spec.ts +++ b/hermes-console/src/views/subscription/SubscriptionView.spec.ts @@ -80,6 +80,9 @@ const useMetricsStub: UseMetrics = { describe('SubscriptionView', () => { beforeEach(async () => { setActivePinia(createPinia()); + vi.mocked(useSubscription).mockReturnValue(useSubscriptionStub); + vi.mocked(useRoles).mockReturnValue(useRolesStub); + vi.mocked(useMetrics).mockReturnValue(useMetricsStub); await router.push( '/ui/groups/pl.allegro.public.group' + '/topics/pl.allegro.public.group.DummyEvent' + @@ -88,11 +91,6 @@ describe('SubscriptionView', () => { }); it('should render all tabs if subscription data was successfully fetched', () => { - // given - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); - // when const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), @@ -116,9 +114,6 @@ describe('SubscriptionView', () => { ])('should activate tab on click', async (tab: string) => { // given const user = userEvent.setup(); - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), }); @@ -134,9 +129,6 @@ describe('SubscriptionView', () => { it('should show appropriate sections on general tab click', async () => { // given const user = userEvent.setup(); - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), }); @@ -153,9 +145,6 @@ describe('SubscriptionView', () => { it('should show appropriate sections on filters tab click', async () => { // given const user = userEvent.setup(); - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), }); @@ -170,9 +159,6 @@ describe('SubscriptionView', () => { it('should show appropriate sections on mutations tab click', async () => { // given const user = userEvent.setup(); - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), }); @@ -187,9 +173,6 @@ describe('SubscriptionView', () => { it('should show appropriate sections on messages tab click', async () => { // given const user = userEvent.setup(); - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), }); @@ -231,7 +214,6 @@ describe('SubscriptionView', () => { ...useRolesStub, roles: ref([]), }); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { queryByText, getByText } = render(SubscriptionView, { @@ -245,11 +227,6 @@ describe('SubscriptionView', () => { ); it('should render subscription health alert', () => { - // given - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); - // when const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), @@ -270,7 +247,6 @@ describe('SubscriptionView', () => { ...useSubscriptionStub, loading: computed(() => true), }); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { queryByTestId } = render(SubscriptionView, { @@ -288,7 +264,6 @@ describe('SubscriptionView', () => { ...useSubscriptionStub, loading: computed(() => false), }); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { queryByTestId } = render(SubscriptionView, { @@ -312,9 +287,9 @@ describe('SubscriptionView', () => { fetchSubscriptionHealth: null, fetchSubscriptionUndeliveredMessages: null, fetchSubscriptionLastUndeliveredMessage: null, + getSubscriptionTrackingUrls: null, }), }); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { queryByText } = render(SubscriptionView, { @@ -333,7 +308,6 @@ describe('SubscriptionView', () => { ...useSubscriptionStub, loading: computed(() => false), }); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { queryByText } = render(SubscriptionView, { @@ -348,11 +322,6 @@ describe('SubscriptionView', () => { }); it('should show confirmation dialog on remove button click', async () => { - // given - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); - // when const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), @@ -371,11 +340,6 @@ describe('SubscriptionView', () => { }); it('should show confirmation dialog on suspend button click', async () => { - // given - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); - // when const { getByText } = render(SubscriptionView, { testPinia: createTestingPiniaWithState(), @@ -402,8 +366,6 @@ describe('SubscriptionView', () => { state: ref(State.SUSPENDED), }), }); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { getByText } = render(SubscriptionView, { @@ -423,11 +385,6 @@ describe('SubscriptionView', () => { }); it('should not render costs card when it is disabled in app config', () => { - // given - vi.mocked(useSubscription).mockReturnValueOnce(useSubscriptionStub); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); - // when const { queryByText } = render(TopicView, { testPinia: createTestingPinia({ @@ -466,8 +423,6 @@ describe('SubscriptionView', () => { ...useSubscriptionStub, subscription: ref(dummySubscription2), }); - vi.mocked(useRoles).mockReturnValueOnce(useRolesStub); - vi.mocked(useMetrics).mockReturnValueOnce(useMetricsStub); // when const { getByText, queryByText } = render(SubscriptionView, { diff --git a/hermes-console/src/views/subscription/SubscriptionView.vue b/hermes-console/src/views/subscription/SubscriptionView.vue index 723bd529d0..e8a110624c 100644 --- a/hermes-console/src/views/subscription/SubscriptionView.vue +++ b/hermes-console/src/views/subscription/SubscriptionView.vue @@ -1,11 +1,11 @@