diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/journal/InstanceEvent.ts b/spring-boot-admin-server-ui/src/main/frontend/views/journal/InstanceEvent.ts index a7c43607f25..d1960f53c7e 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/journal/InstanceEvent.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/journal/InstanceEvent.ts @@ -38,7 +38,7 @@ export class InstanceEvent implements IInstanceEvent { } get key() { - return `${this.instance}-${this.version}`; + return `${this.instance}-${this.version}-${this.type}-${this.timestamp.getTime()}`; } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.spec.ts new file mode 100644 index 00000000000..e83ab07fd48 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { deduplicateInstanceEvents } from './deduplicate-events'; + +import { + InstanceEvent, + InstanceEventType, +} from '@/views/journal/InstanceEvent'; + +const createEvent = ( + instance: string, + version: number, + type = InstanceEventType.REGISTERED, +) => + new InstanceEvent({ + instance, + version, + type, + timestamp: '2024-01-01T10:00:00Z', + registration: { name: instance }, + }); + +describe('deduplicateInstanceEvents', () => { + it('removes events with identical instance, type and version', () => { + const events = [ + createEvent('instance-1', 1), + createEvent('instance-1', 1, InstanceEventType.DEREGISTERED), + createEvent('instance-2', 3), + createEvent('instance-2', 3), + createEvent('instance-3', 2), + createEvent('instance-3', 2, InstanceEventType.INFO_CHANGED), + createEvent('instance-3', 2, InstanceEventType.INFO_CHANGED), + ]; + + const result = deduplicateInstanceEvents(events); + + expect(result).toHaveLength(5); + expect(result.map((event) => event.key)).toEqual([ + 'instance-1-1-REGISTERED-1704103200000', + 'instance-1-1-DEREGISTERED-1704103200000', + 'instance-2-3-REGISTERED-1704103200000', + 'instance-3-2-REGISTERED-1704103200000', + 'instance-3-2-INFO_CHANGED-1704103200000', + ]); + }); + + it('preserves the order of the first occurrences', () => { + const events = [ + createEvent('instance-1', 2), + createEvent('instance-2', 1), + createEvent('instance-1', 2), + createEvent('instance-3', 4), + ]; + + const result = deduplicateInstanceEvents(events); + + expect(result.map((event) => event.key)).toEqual([ + 'instance-1-2-REGISTERED-1704103200000', + 'instance-2-1-REGISTERED-1704103200000', + 'instance-3-4-REGISTERED-1704103200000', + ]); + }); +}); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.ts b/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.ts new file mode 100644 index 00000000000..71b37b505cd --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/journal/deduplicate-events.ts @@ -0,0 +1,30 @@ +import { InstanceEvent } from '@/views/journal/InstanceEvent'; + +/** + * Removes duplicate instance events from an array based on their key property. + * + * This function filters an array of InstanceEvent objects, keeping only the first occurrence + * of each unique event key. Subsequent events with the same key are filtered out. + * + * @param events - Array of InstanceEvent objects to deduplicate + * @returns A new array containing only unique events (by key), preserving the order of first occurrence + * + * @example + * const events = [ + * { key: 'event1', ... }, + * { key: 'event2', ... }, + * { key: 'event1', ... } // duplicate + * ]; + * const unique = deduplicateInstanceEvents(events); + * // Returns first two events only + */ +export function deduplicateInstanceEvents(events: InstanceEvent[]) { + const seen = new Set(); + return events.filter((event) => { + if (seen.has(event.key)) { + return false; + } + seen.add(event.key); + return true; + }); +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/journal/index.vue b/spring-boot-admin-server-ui/src/main/frontend/views/journal/index.vue index 19bd97580f8..51af1b241c6 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/journal/index.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/journal/index.vue @@ -46,11 +46,10 @@ import { useDateTimeFormatter } from '@/composables/useDateTimeFormatter'; import subscribing from '@/mixins/subscribing'; import Instance from '@/services/instance'; import { compareBy } from '@/utils/collections'; -import { - InstanceEvent, - InstanceEventType, -} from '@/views/journal/InstanceEvent'; +import { InstanceEvent } from '@/views/journal/InstanceEvent'; +import { InstanceEventType } from '@/views/journal/InstanceEvent'; import JournalTable from '@/views/journal/JournalTable.vue'; +import { deduplicateInstanceEvents } from '@/views/journal/deduplicate-events'; export default { components: { JournalTable, SbaAlert }, @@ -67,6 +66,7 @@ export default { data: () => ({ Event, events: [], + seenEventKeys: new Set(), listOffset: 0, showPayload: {}, pageSize: 25, @@ -104,7 +104,10 @@ export default { .reverse() .map((e) => new InstanceEvent(e)); - this.events = Object.freeze(events); + const deduplicated = deduplicateInstanceEvents(events); + this.seenEventKeys = new Set(deduplicated.map((event) => event.key)); + this.events = Object.freeze(deduplicated); + this.listOffset = events.length - deduplicated.length; this.error = null; } catch (error) { console.warn('Fetching events failed:', error); @@ -119,10 +122,12 @@ export default { return Instance.getEventStream().subscribe({ next: (message) => { this.error = null; - this.events = Object.freeze([ - new InstanceEvent(message.data), - ...this.events, - ]); + const incomingEvent = new InstanceEvent(message.data); + if (this.seenEventKeys.has(incomingEvent.key)) { + return; + } + this.seenEventKeys.add(incomingEvent.key); + this.events = Object.freeze([incomingEvent, ...this.events]); this.listOffset += 1; }, error: (error) => {