diff --git a/.changeset/sixty-islands-shop.md b/.changeset/sixty-islands-shop.md new file mode 100644 index 00000000..5dee7a8f --- /dev/null +++ b/.changeset/sixty-islands-shop.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": minor +--- + +feat: new architecture pages and initial landing page for EventCatalog diff --git a/eventcatalog/astro.config.mjs b/eventcatalog/astro.config.mjs index 5aaddffe..383132b3 100644 --- a/eventcatalog/astro.config.mjs +++ b/eventcatalog/astro.config.mjs @@ -22,9 +22,6 @@ export default defineConfig({ base, server: { port: config.port || 3000 }, - - - outDir: config.outDir ? join(projectDirectory, config.outDir) : join(projectDirectory, 'dist'), // https://docs.astro.build/en/reference/configuration-reference/#site diff --git a/eventcatalog/src/components/Grids/DomainGrid.tsx b/eventcatalog/src/components/Grids/DomainGrid.tsx new file mode 100644 index 00000000..c1983ea2 --- /dev/null +++ b/eventcatalog/src/components/Grids/DomainGrid.tsx @@ -0,0 +1,233 @@ +import { useState, useMemo } from 'react'; +import { ServerIcon, EnvelopeIcon, RectangleGroupIcon } from '@heroicons/react/24/outline'; +import { buildUrlWithParams } from '@utils/url-builder'; +import type { CollectionEntry } from 'astro:content'; +import { type CollectionMessageTypes } from '@types'; +import { getCollectionStyles } from './utils'; +import { SearchBar } from './components'; + +export interface ExtendedDomain extends CollectionEntry<'domains'> { + sends: CollectionEntry[]; + receives: CollectionEntry[]; + services: CollectionEntry<'services'>[]; +} + +interface DomainGridProps { + domains: ExtendedDomain[]; +} + +export default function DomainGrid({ domains }: DomainGridProps) { + const [searchQuery, setSearchQuery] = useState(''); + + const filteredDomains = useMemo(() => { + let result = [...domains]; + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (domain) => + domain.data.name?.toLowerCase().includes(query) || + domain.data.summary?.toLowerCase().includes(query) || + domain.data.services?.some((service: any) => service.data.name.toLowerCase().includes(query)) || + domain.sends?.some((message: any) => message.data.name.toLowerCase().includes(query)) || + domain.receives?.some((message: any) => message.data.name.toLowerCase().includes(query)) + ); + } + + // Sort by name by default + result.sort((a, b) => (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id)); + + return result; + }, [domains, searchQuery]); + + return ( +
+ {/* Breadcrumb */} + + +
+
+
+

+ Domains ({filteredDomains.length}) +

+

Browse and manage domains in your event-driven architecture

+
+ +
+ +
+
+
+ +
+ {filteredDomains.map((domain) => ( + s.data.id).join(','), + domainId: domain.data.id, + domainName: domain.data.name, + })} + className="group hover:bg-orange-100 border-2 border-orange-400/50 bg-yellow-50 rounded-lg shadow-sm hover:shadow-lg transition-all duration-200 overflow-hidden" + > +
+
+
+ +

+ {domain.data.name || domain.data.id} +

+
+ + v{domain.data.version} + +
+ +

+ {domain.data.summary || No summary available} +

+ +
+
+ +
+

{domain.data.services?.length || 0} Services

+
+
+
+ +
+

+ {(domain.sends?.length || 0) + (domain.receives?.length || 0)} Messages +

+
+
+
+ +
+ {/* Services and their messages */} + {domain.data.services?.slice(0, 2).map((service: any) => ( +
+
+
+ +

{service.data.name || service.data.id}

+
+ v{service.data.version} +
+ +
+
+
+ {service.data.receives?.slice(0, 3).map((message: any) => { + const { Icon, color } = getCollectionStyles(message.collection); + return ( +
+
+ +
+ {message.id} +
+ ); + })} + {service.data.receives && service.data.receives.length > 3 && ( +
+

+ {service.data.receives.length - 3} more

+
+ )} + {!service.data.receives?.length && ( +
+

No messages received

+
+ )} +
+
+ +
+
+
+
+ +
+

{service.data.name || service.data.id}

+

v{service.data.version}

+
+
+
+
+
+ +
+
+ {service.data.sends?.slice(0, 3).map((message: any) => { + const { Icon, color } = getCollectionStyles(message.collection); + return ( +
+
+ +
+ {message.id} +
+ ); + })} + {service.data.sends && service.data.sends.length > 3 && ( +
+

+ {service.data.sends.length - 3} more

+
+ )} + {!service.data.sends?.length && ( +
+

No messages sent

+
+ )} +
+
+
+
+ ))} + {domain.data.services && domain.data.services.length > 2 && ( +
+
+
+ +

+{domain.data.services.length - 2} more services

+
+
+
+ )} +
+
+
+ ))} +
+ + {filteredDomains.length === 0 && ( +
+

No domains found matching your criteria

+
+ )} +
+ ); +} diff --git a/eventcatalog/src/components/Grids/MessageGrid.tsx b/eventcatalog/src/components/Grids/MessageGrid.tsx new file mode 100644 index 00000000..f80d55ae --- /dev/null +++ b/eventcatalog/src/components/Grids/MessageGrid.tsx @@ -0,0 +1,457 @@ +import { useState, useMemo, useEffect } from 'react'; +import { EnvelopeIcon, ChevronRightIcon, ServerIcon } from '@heroicons/react/24/outline'; +import { RectangleGroupIcon } from '@heroicons/react/24/outline'; +import { buildUrl, buildUrlWithParams } from '@utils/url-builder'; +import type { CollectionEntry } from 'astro:content'; +import type { CollectionMessageTypes } from '@types'; +import { getCollectionStyles } from './utils'; +import { SearchBar, TypeFilters, Pagination } from './components'; + +interface MessageGridProps { + messages: CollectionEntry[]; +} + +interface GroupedMessages { + all?: CollectionEntry[]; + sends?: CollectionEntry[]; + receives?: CollectionEntry[]; +} + +export default function MessageGrid({ messages }: MessageGridProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [urlParams, setUrlParams] = useState<{ + serviceId?: string; + serviceName?: string; + domainId?: string; + domainName?: string; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const [selectedTypes, setSelectedTypes] = useState([]); + const [producerConsumerFilter, setProducerConsumerFilter] = useState<'all' | 'no-producers' | 'no-consumers'>('all'); + const ITEMS_PER_PAGE = 15; + + // Effect to sync URL params with state + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const serviceId = params.get('serviceId') || undefined; + const serviceName = params.get('serviceName') ? decodeURIComponent(params.get('serviceName')!) : undefined; + const domainId = params.get('domainId') || undefined; + const domainName = params.get('domainName') || undefined; + setUrlParams({ + serviceId, + serviceName, + domainId, + domainName, + }); + }, []); + + const filteredAndSortedMessages = useMemo(() => { + if (urlParams === null) return []; + + let result = [...messages]; + + // Filter by message type + if (selectedTypes.length > 0) { + result = result.filter((message) => selectedTypes.includes(message.collection)); + } + + // Apply producer/consumer filters + if (producerConsumerFilter === 'no-producers') { + result = result.filter((message) => !message.data.producers || message.data.producers.length === 0); + } else if (producerConsumerFilter === 'no-consumers') { + result = result.filter((message) => !message.data.consumers || message.data.consumers.length === 0); + } + + // Filter by service ID or name if present + if (urlParams.serviceId) { + result = result.filter( + (message) => + message.data.producers?.some( + (producer: any) => producer.data.id === urlParams.serviceId && !producer.id.includes('/versioned/') + ) || + message.data.consumers?.some( + (consumer: any) => consumer.data.id === urlParams.serviceId && !consumer.id.includes('/versioned/') + ) + ); + } + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (message) => + message.data.name?.toLowerCase().includes(query) || + message.data.summary?.toLowerCase().includes(query) || + message.data.producers?.some((producer: any) => producer.data.id?.toLowerCase().includes(query)) || + message.data.consumers?.some((consumer: any) => consumer.data.id?.toLowerCase().includes(query)) + ); + } + + // Sort by name by default + result.sort((a, b) => a.data.name.localeCompare(b.data.name)); + + return result; + }, [messages, searchQuery, urlParams, selectedTypes, producerConsumerFilter]); + + // Add totalPages calculation + const totalPages = useMemo(() => { + if (urlParams?.serviceId || urlParams?.domainId) return 1; + return Math.ceil(filteredAndSortedMessages.length / ITEMS_PER_PAGE); + }, [filteredAndSortedMessages.length, urlParams]); + + // Add paginatedMessages calculation + const paginatedMessages = useMemo(() => { + if (urlParams?.serviceId || urlParams?.domainId) { + return filteredAndSortedMessages; + } + + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + return filteredAndSortedMessages.slice(startIndex, startIndex + ITEMS_PER_PAGE); + }, [filteredAndSortedMessages, currentPage, urlParams]); + + // Reset pagination when search query or filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, selectedTypes]); + + // Group messages by sends/receives when a service is selected + const groupedMessages = useMemo(() => { + if (!urlParams?.serviceId) return { all: filteredAndSortedMessages }; + + const serviceIdentifier = urlParams.serviceId; + const sends = filteredAndSortedMessages.filter((message) => + message.data.producers?.some((producer: any) => producer.data.id === serviceIdentifier) + ); + const receives = filteredAndSortedMessages.filter((message) => + message.data.consumers?.some((consumer: any) => consumer.data.id === serviceIdentifier) + ); + + return { sends, receives }; + }, [filteredAndSortedMessages, urlParams]); + + const renderTypeFilters = () => { + return ( +
+
+ selectedTypes.includes(m.collection)).length} + /> +
+ +
+
+ + {producerConsumerFilter !== 'all' && ( + + )} +
+
+
+ ); + }; + + const renderMessageGrid = (messages: CollectionEntry[]) => ( +
+ {messages.map((message) => { + const { color, Icon } = getCollectionStyles(message.collection); + const hasProducers = message.data.producers && message.data.producers.length > 0; + const hasConsumers = message.data.consumers && message.data.consumers.length > 0; + return ( + +
+
+
+ +

+ {message.data.name} (v{message.data.version}) +

+
+
+ + {message.data.summary &&

{message.data.summary}

} + + {/* Only show stats in non-service view */} + {!urlParams?.serviceName && ( +
+
+
+
+ +
+
{message.data.producers?.length ?? 0}
+
Producers
+
+
+
+ +
+
{message.data.consumers?.length ?? 0}
+
Consumers
+
+
+
+ )} +
+
+ ); + })} +
+ ); + + const renderPaginationControls = () => { + if (totalPages <= 1 || urlParams?.serviceName || urlParams?.domainId) return null; + + return ( + + ); + }; + + return ( +
+ {/* Breadcrumb */} + + + {/* Title Section */} +
+
+
+
+

+ {urlParams?.domainName ? `Messages in ${urlParams.serviceName}` : 'All Messages'} +

+
+

+ {urlParams?.domainName + ? `Browse messages in the ${urlParams.serviceName} service` + : 'Browse and discover messages in your event-driven architecture'} +

+
+ +
+ +
+
+
+ +
+ {/* Results count and top pagination */} +
+ {renderTypeFilters()} + {renderPaginationControls()} +
+
+ + {filteredAndSortedMessages.length > 0 && ( +
+ {urlParams?.domainName && ( + <> + + + )} + +
+ {urlParams?.serviceName ? ( + <> + {/*
*/} + {/* Service Title */} +
+ +

{urlParams.serviceName}

+ +
+
+ {/* Receives Section */} +
+
+

+ + Receives messages ({groupedMessages.receives?.length || 0}) +

+
+ {groupedMessages.receives && groupedMessages.receives.length > 0 ? ( + renderMessageGrid(groupedMessages.receives) + ) : ( +
+

+ {selectedTypes.length > 0 + ? `Service does not receive ${selectedTypes.join(' or ')}` + : 'Service does not receive any messages'} +

+
+ )} +
+ + {/* Arrow from Receives to Service */} +
+
+
+
+ + {/* Service Information */} +
+
+ +

{urlParams.serviceName}

+
+
+ + {/* Arrow from Service to Sends */} +
+
+
+
+ + {/* Sends Section */} +
+
+

+ + Sends messages ({groupedMessages.sends?.length || 0}) +

+
+ {groupedMessages.sends && groupedMessages.sends.length > 0 ? ( + renderMessageGrid(groupedMessages.sends) + ) : ( +
+

+ {selectedTypes.length > 0 + ? `Service does not send ${selectedTypes.join(' or ')}` + : 'Service does not send any messages'} +

+
+ )} +
+
+ + ) : ( + <> + {renderMessageGrid(paginatedMessages)} +
{renderPaginationControls()}
+ + )} +
+
+ )} + + {filteredAndSortedMessages.length === 0 && ( +
+

No messages found matching your criteria

+
+ )} +
+ ); +} diff --git a/eventcatalog/src/components/Grids/ServiceGrid.tsx b/eventcatalog/src/components/Grids/ServiceGrid.tsx new file mode 100644 index 00000000..aab62291 --- /dev/null +++ b/eventcatalog/src/components/Grids/ServiceGrid.tsx @@ -0,0 +1,364 @@ +import { useState, useMemo, useEffect } from 'react'; +import { ServerIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { RectangleGroupIcon } from '@heroicons/react/24/outline'; +import { buildUrl, buildUrlWithParams } from '@utils/url-builder'; +import type { CollectionEntry } from 'astro:content'; +import type { CollectionMessageTypes } from '@types'; +import { getCollectionStyles } from './utils'; +import { SearchBar, TypeFilters, Pagination } from './components'; + +interface ServiceGridProps { + services: CollectionEntry<'services'>[]; +} + +export default function ServiceGrid({ services }: ServiceGridProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [selectedTypes, setSelectedTypes] = useState([]); + const ITEMS_PER_PAGE = 16; + const [urlParams, setUrlParams] = useState<{ + serviceIds?: string[]; + domainId?: string; + domainName?: string; + serviceName?: string; + } | null>(null); + + // Effect to sync URL params with state + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const serviceIds = params.get('serviceIds')?.split(',').filter(Boolean); + const domainId = params.get('domainId') || undefined; + const domainName = params.get('domainName') || undefined; + const serviceName = params.get('serviceName') || undefined; + setUrlParams({ + serviceIds, + domainId, + domainName, + serviceName, + }); + }, []); + + const filteredAndSortedServices = useMemo(() => { + // Don't filter until we have URL params + if (urlParams === null) return []; + + let result = [...services]; + + // Filter by service IDs if present + if (urlParams.serviceIds?.length) { + result = result.filter( + (service) => urlParams.serviceIds?.includes(service.data.id) && !service.data.id.includes('/versioned/') + ); + } + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (service) => + service.data.name?.toLowerCase().includes(query) || + service.data.summary?.toLowerCase().includes(query) || + service.data.sends?.some((message: any) => message.data.name.toLowerCase().includes(query)) || + service.data.receives?.some((message: any) => message.data.name.toLowerCase().includes(query)) + ); + } + + // Filter by selected message types + if (selectedTypes.length > 0) { + result = result.filter((service) => { + const hasMatchingSends = service.data.sends?.some((message: any) => selectedTypes.includes(message.collection)); + const hasMatchingReceives = service.data.receives?.some((message: any) => selectedTypes.includes(message.collection)); + return hasMatchingSends || hasMatchingReceives; + }); + } + + // Sort by name by default + result.sort((a, b) => (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id)); + + return result; + }, [services, searchQuery, urlParams, selectedTypes]); + + // Add pagination calculation + const paginatedServices = useMemo(() => { + if (urlParams?.domainId || urlParams?.serviceIds?.length) { + return filteredAndSortedServices; + } + + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + return filteredAndSortedServices.slice(startIndex, startIndex + ITEMS_PER_PAGE); + }, [filteredAndSortedServices, currentPage, urlParams]); + + const totalPages = useMemo(() => { + if (urlParams?.domainId || urlParams?.serviceIds?.length) return 1; + return Math.ceil(filteredAndSortedServices.length / ITEMS_PER_PAGE); + }, [filteredAndSortedServices.length, urlParams]); + + // Reset pagination when search query or filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, selectedTypes]); + + return ( +
+ {/* Breadcrumb */} + + + {/* Title Section */} +
+
+
+
+

+ {urlParams?.domainId + ? `Services in the ${urlParams.domainName} domain (${filteredAndSortedServices.length})` + : 'All Services'} +

+
+

+ {urlParams?.domainId + ? `Browse services in the ${urlParams.domainId} domain` + : 'Browse and discover services in your event-driven architecture'} +

+
+ +
+ +
+
+
+ +
+ {/* Results count and pagination */} +
+ +
+ {urlParams?.domainId || urlParams?.serviceIds?.length ? ( + + Showing {filteredAndSortedServices.length} services in the {urlParams.domainId} domain + + ) : ( + + Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to{' '} + {Math.min(currentPage * ITEMS_PER_PAGE, filteredAndSortedServices.length)} of {filteredAndSortedServices.length}{' '} + services + + )} +
+ {!(urlParams?.domainId || urlParams?.serviceIds?.length) && ( + + )} +
+
+ + {filteredAndSortedServices.length > 0 && ( +
+ {urlParams?.domainName && ( + <> +
+
+ + {urlParams.domainName} +
+ +
+ + )} + +
+ {paginatedServices.map((service) => { + return ( + +
+
+
+ +

+ {service.data.name || service.data.id} (v{service.data.version}) +

+
+
+ + {service.data.summary && ( +

{service.data.summary}

+ )} + +
+ {/* Messages Section */} + {!urlParams?.serviceName && ( +
+
+
+ {service.data.receives + ?.filter( + (message: any) => selectedTypes.length === 0 || selectedTypes.includes(message.collection) + ) + ?.map((message: any) => { + const { Icon, color } = getCollectionStyles(message.collection); + return ( + +
+ +
+ {message.data.name} +
+ ); + })} + {(!service.data.receives?.length || + (selectedTypes.length > 0 && + !service.data.receives?.some((message: any) => + selectedTypes.includes(message.collection) + ))) && ( +
+

+ {selectedTypes.length > 0 + ? `Service does not receive ${selectedTypes.join(' or ')}` + : 'Service does not receive any messages'} +

+
+ )} +
+
+ +
+
+
+
+ +
+

{service.data.name || service.data.id}

+

v{service.data.version}

+
+
+
+
+
+ +
+
+ {service.data.sends + ?.filter( + (message: any) => selectedTypes.length === 0 || selectedTypes.includes(message.collection) + ) + ?.map((message: any) => { + const { Icon, color } = getCollectionStyles(message.collection); + return ( + +
+ +
+ {message.data.name} +
+ ); + })} + {(!service.data.sends?.length || + (selectedTypes.length > 0 && + !service.data.sends?.some((message: any) => selectedTypes.includes(message.collection)))) && ( +
+

+ {selectedTypes.length > 0 + ? `Service does not send ${selectedTypes.join(' or ')}` + : 'Service does not send any messages'} +

+
+ )} +
+
+
+ )} +
+
+ + ); + })} +
+
+ )} + + {filteredAndSortedServices.length === 0 && ( +
+

+ {selectedTypes.length > 0 + ? `No services found that ${selectedTypes.length > 1 ? 'handle' : 'handles'} ${selectedTypes.join(' or ')} messages` + : 'No services found matching your criteria'} +

+
+ )} + + {/* Bottom pagination */} + {!(urlParams?.domainId || urlParams?.serviceIds?.length) && ( +
+ +
+ )} +
+ ); +} diff --git a/eventcatalog/src/components/Grids/components.tsx b/eventcatalog/src/components/Grids/components.tsx new file mode 100644 index 00000000..7050fa47 --- /dev/null +++ b/eventcatalog/src/components/Grids/components.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { + MagnifyingGlassIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, +} from '@heroicons/react/24/outline'; +import type { CollectionMessageTypes } from '@types'; +import { getCollectionStyles, type PaginationProps, type SearchBarProps, type TypeFilterProps } from './utils'; + +export function SearchBar({ searchQuery, onSearchChange, placeholder, totalResults, totalItems }: SearchBarProps) { + return ( +
+
+
+
+ onSearchChange(e.target.value)} + className="block w-full rounded-lg border-0 py-2.5 pl-10 pr-4 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + /> + {searchQuery && ( +
+ +
+ )} +
+ {searchQuery && totalResults !== undefined && totalItems !== undefined && ( +
+ + Found {totalResults} of{' '} + {totalItems} + + ESC to clear +
+ )} +
+ ); +} + +export function TypeFilters({ selectedTypes, onTypeChange, filteredCount, totalCount }: TypeFilterProps) { + const types: CollectionMessageTypes[] = ['events', 'commands', 'queries']; + + return ( +
+ {types.map((type) => { + const { color, Icon } = getCollectionStyles(type); + const isSelected = selectedTypes.includes(type); + return ( + + ); + })} + {selectedTypes.length > 0 && ( + + )} +
+ ); +} + +export function Pagination({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }: PaginationProps) { + if (totalPages <= 1) return null; + + return ( +
+
+ + +
+
+
+

+ Showing {(currentPage - 1) * itemsPerPage + 1} to{' '} + {Math.min(currentPage * itemsPerPage, totalItems)} of{' '} + {totalItems} results +

+
+
+ +
+
+
+ ); +} diff --git a/eventcatalog/src/components/Grids/utils.tsx b/eventcatalog/src/components/Grids/utils.tsx new file mode 100644 index 00000000..57f91d5a --- /dev/null +++ b/eventcatalog/src/components/Grids/utils.tsx @@ -0,0 +1,38 @@ +import { BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon, EnvelopeIcon } from '@heroicons/react/24/outline'; +import type { CollectionMessageTypes } from '@types'; + +export const getCollectionStyles = (collection: CollectionMessageTypes) => { + switch (collection) { + case 'events': + return { color: 'orange', Icon: BoltIcon }; + case 'commands': + return { color: 'blue', Icon: ChatBubbleLeftIcon }; + case 'queries': + return { color: 'green', Icon: MagnifyingGlassIcon }; + default: + return { color: 'gray', Icon: EnvelopeIcon }; + } +}; + +export interface PaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + itemsPerPage: number; + onPageChange: (page: number) => void; +} + +export interface SearchBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + placeholder?: string; + totalResults?: number; + totalItems?: number; +} + +export interface TypeFilterProps { + selectedTypes: CollectionMessageTypes[]; + onTypeChange: (types: CollectionMessageTypes[]) => void; + filteredCount?: number; + totalCount?: number; +} diff --git a/eventcatalog/src/layouts/VerticalSideBarLayout.astro b/eventcatalog/src/layouts/VerticalSideBarLayout.astro index c0a0f3cf..2b342d38 100644 --- a/eventcatalog/src/layouts/VerticalSideBarLayout.astro +++ b/eventcatalog/src/layouts/VerticalSideBarLayout.astro @@ -9,6 +9,7 @@ import { BookOpenText, Workflow, TableProperties, House, BookUser, MessageSquare import Header from '../components/Header.astro'; import SEO from '../components/Seo.astro'; import SideNav from '../components/SideNav/SideNav.astro'; +import '@fontsource/inter'; import { getCommands } from '@utils/commands'; import { getDomains } from '@utils/collections/domains'; @@ -92,6 +93,15 @@ const navigationItems = [ current: currentPath.includes('/directory'), sidebar: false, }, + { + id: '/architecture', + label: 'Architecture', + icon: BookUser, + href: buildUrl('/architecture/domains'), + current: currentPath.includes('/architecture'), + sidebar: false, + hidden: true, + }, { id: '/chat', label: 'AI Assistant', @@ -139,23 +149,25 @@ const canPageBeEmbedded = process.env.ENABLE_EMBED === 'true'; diff --git a/eventcatalog/src/pages/architecture/[type]/index.astro b/eventcatalog/src/pages/architecture/[type]/index.astro new file mode 100644 index 00000000..526791be --- /dev/null +++ b/eventcatalog/src/pages/architecture/[type]/index.astro @@ -0,0 +1,88 @@ +--- +import { getDomains, getMessagesForDomain } from '@utils/collections/domains'; +import { getServices } from '@utils/collections/services'; +import { getMessages } from '@utils/messages'; +import type { ExtendedDomain } from '@components/Grids/DomainGrid'; +import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; +import DomainGrid from '@components/Grids/DomainGrid'; +import ServiceGrid from '@components/Grids/ServiceGrid'; +import MessageGrid from '@components/Grids/MessageGrid'; + +import type { CollectionEntry } from 'astro:content'; +import type { CollectionMessageTypes } from '@types'; + +import { ClientRouter, fade } from 'astro:transitions'; +// Define valid types and their corresponding data fetchers +const VALID_TYPES = ['domains', 'services', 'messages'] as const; +type ValidType = (typeof VALID_TYPES)[number]; + +interface Service extends CollectionEntry<'services'> { + sends: CollectionEntry<'events' | 'commands' | 'queries'>[]; + receives: CollectionEntry<'events' | 'commands' | 'queries'>[]; +} + +export async function getStaticPaths() { + const VALID_TYPES = ['domains', 'services', 'messages'] as const; + return VALID_TYPES.map((type) => ({ + params: { type }, + })); +} + +const { type } = Astro.params as { type: ValidType }; + +// Get data based on type +let items: ExtendedDomain[] | Service[] | CollectionEntry<'commands'>[] | CollectionEntry[] = []; + +if (type === 'domains') { + const domains = await getDomains({ getAllVersions: false }); + + // Get messages for each domain + items = await Promise.all( + domains.map(async (domain) => { + const messages = await getMessagesForDomain(domain); + // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. + return { + ...domain, + sends: messages.sends.map((s) => ({ ...s, body: undefined, catalog: undefined })), + receives: messages.receives.map((r) => ({ ...r, body: undefined, catalog: undefined })), + catalog: undefined, + body: undefined, + } as ExtendedDomain; + }) + ); +} else if (type === 'services') { + const services = await getServices({ getAllVersions: false }); + let filteredServices = services.map((s) => { + // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. + return { + ...s, + sends: (s.data.sends || []).map((s) => ({ ...s, body: undefined, catalog: undefined })), + receives: (s.data.receives || []).map((r) => ({ ...r, body: undefined, catalog: undefined })), + catalog: undefined, + body: undefined, + } as Service; + }) as unknown as Service[]; + items = filteredServices; +} else if (type === 'messages') { + const { events, commands, queries } = await getMessages({ getAllVersions: false }); + const messages = [...events, ...commands, ...queries]; + items = messages.map((m) => ({ + ...m, + body: undefined, + catalog: undefined, + })) as unknown as CollectionEntry[]; +} +--- + + +
+
+
+ {type === 'domains' && } + {type === 'services' && } + {type === 'messages' && []} client:load />} +
+
+ +
+
diff --git a/eventcatalog/src/pages/index.astro b/eventcatalog/src/pages/index.astro index 0362c0d3..380466c0 100644 --- a/eventcatalog/src/pages/index.astro +++ b/eventcatalog/src/pages/index.astro @@ -8,6 +8,7 @@ import { ServerIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline'; +import config from '@config'; import { getMessages } from '@utils/messages'; import { getDomains } from '@utils/collections/domains'; @@ -15,107 +16,271 @@ import { getServices } from '@utils/collections/services'; import { getFlows } from '@utils/collections/flows'; import DiscoverInsight from '@components/DiscoverInsight.astro'; import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { BookOpen, BookOpenText, TableProperties, Workflow } from 'lucide-react'; +import { BookOpenText, Workflow, TableProperties, House, BookUser, MessageSquare, BotMessageSquare, Users } from 'lucide-react'; const { commands = [], events = [], queries = [] } = await getMessages({ getAllVersions: false }); +const messages = [...events, ...queries, ...commands]; const domains = await getDomains({ getAllVersions: false }); const services = await getServices({ getAllVersions: false }); const flows = await getFlows({ getAllVersions: false }); +const gettingStartedItems = [ + { + title: 'Add a New Message', + icon: ChatBubbleLeftIcon, + iconBg: 'blue', + description: 'Document a new message in your system with schemas, examples, and relationships.', + links: [ + { + text: 'How to add a message', + href: 'https://www.eventcatalog.dev/docs/messages', + }, + { + text: 'Versioning guide', + href: 'https://www.eventcatalog.dev/docs/development/guides/messages/events/versioning', + }, + { + text: 'Adding schemas', + href: 'https://www.eventcatalog.dev/docs/development/guides/messages/events/adding-schemas', + }, + ], + }, + { + title: 'Document a Service', + icon: ServerIcon, + iconBg: 'green', + description: 'Add details about a service, including its events, APIs, and dependencies.', + links: [ + { + text: 'How to add a service', + href: 'https://www.eventcatalog.dev/docs/services', + }, + { + text: 'Service ownership', + href: 'https://www.eventcatalog.dev/docs/development/guides/services/owners', + }, + { + text: 'Assign specifications to services', + href: 'https://www.eventcatalog.dev/docs/development/guides/services/adding-spec-files-to-services', + }, + ], + }, + { + title: 'Create a Domain', + icon: RectangleGroupIcon, + iconBg: 'purple', + description: 'Organize your services and events into logical business domains.', + links: [ + { + text: 'How to add a domain', + href: 'https://www.eventcatalog.dev/docs/domains', + }, + { + text: 'Adding services to domains', + href: 'https://www.eventcatalog.dev/docs/development/guides/domains/adding-services-to-domains', + }, + { + text: 'Creating a ubiquitous language', + href: 'https://www.eventcatalog.dev/docs/development/guides/domains/adding-ubiquitous-language', + }, + ], + }, +]; + const getDefaultUrl = (route: string, defaultValue: string) => { if (domains.length > 0) return buildUrl(`/${route}/domains/${domains[0].data.id}/${domains[0].data.latestVersion}`); if (services.length > 0) return buildUrl(`/${route}/services/${services[0].data.id}/${services[0].data.latestVersion}`); if (flows.length > 0) return buildUrl(`/${route}/flows/${flows[0].data.id}/${flows[0].data.latestVersion}`); return buildUrl(defaultValue); }; + +const topTiles = [ + { + title: 'Domains', + count: domains.length, + description: 'Business domains defined', + href: buildUrl('/architecture/domains'), + icon: RectangleGroupIcon, + bgColor: 'bg-yellow-100', + textColor: 'text-yellow-600', + arrowColor: 'text-yellow-600', + }, + { + title: 'Services', + count: services.length, + description: 'Services documented in the catalog', + href: buildUrl('/architecture/services'), + icon: ServerIcon, + bgColor: 'bg-pink-100', + textColor: 'text-pink-600', + arrowColor: 'text-pink-600', + }, + { + title: 'Messages', + count: messages.length, + description: 'Messages documented in the catalog', + href: buildUrl('/architecture/messages'), + icon: ChatBubbleLeftIcon, + bgColor: 'bg-blue-100', + textColor: 'text-blue-600', + arrowColor: 'text-blue-600', + }, +]; --- - -
-
-
-

Welcome to EventCatalog

-

- Discover and document your event-driven architecture effortlessly. EventCatalog centralizes your events, services, and - schemas in one place. -

-
- - -
+ +
+

Getting Started

+
+ { + gettingStartedItems.map((item) => ( +
+
+
+ +
+

{item.title}

+
+

{item.description}

+
+ {item.links.map((link) => ( + + → {link.text} + + ))} +
+
+ )) + } +
+
diff --git a/eventcatalog/src/utils/url-builder.ts b/eventcatalog/src/utils/url-builder.ts index 3802e721..1fa83197 100644 --- a/eventcatalog/src/utils/url-builder.ts +++ b/eventcatalog/src/utils/url-builder.ts @@ -22,3 +22,23 @@ export const buildUrl = (url: string, ignoreTrailingSlash = false) => { return cleanUrl(newUrl); }; + +// Helper function to build URLs with query parameters +export const buildUrlWithParams = (baseUrl: string, params: Record) => { + // Filter out undefined values and empty strings + const validParams = Object.entries(params) + .filter(([_, value]) => value !== undefined && value !== '') + .reduce>((acc, [key, value]) => ({ ...acc, [key]: value as string }), {}); + + // If no valid params, just return the base URL + if (Object.keys(validParams).length === 0) { + return buildUrl(baseUrl); + } + + // Build query string with encoded values + const queryString = Object.entries(validParams) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + + return buildUrl(`${baseUrl}?${queryString}`); +}; diff --git a/eventcatalog/tailwind.config.mjs b/eventcatalog/tailwind.config.mjs index 41d073f6..af64dcee 100644 --- a/eventcatalog/tailwind.config.mjs +++ b/eventcatalog/tailwind.config.mjs @@ -8,6 +8,9 @@ export default { content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], theme: { extend: { + fontFamily: { + sans: ['Inter', 'sans-serif'], + }, height: { header: HEADER_HEIGHT, content: `calc(100vh - ${HEADER_HEIGHT})`, @@ -44,10 +47,18 @@ export default { 'bg-orange-600', 'bg-red-50', 'bg-yellow-50', + 'bg-pink-50', + 'bg-green-50', + 'bg-blue-50', 'bg-indigo-50', 'border-l-red-500', 'border-l-yellow-500', 'border-l-blue-500', + 'bg-yellow-100', + 'bg-pink-100', + 'bg-green-100', + 'bg-blue-100', + 'bg-indigo-100', ], plugins: [require('@tailwindcss/typography')], diff --git a/package.json b/package.json index 23ffdee4..8386ed2b 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@asyncapi/parser": "^3.4.0", "@asyncapi/react-component": "^2.4.3", "@eventcatalog/generator-ai": "^0.1.5", + "@fontsource/inter": "^5.2.5", "@headlessui/react": "^2.0.3", "@heroicons/react": "^2.1.3", "@huggingface/transformers": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9a0c49f..07de3b49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@eventcatalog/generator-ai': specifier: ^0.1.5 version: 0.1.5(@langchain/core@0.3.40(openai@4.85.4(ws@8.18.0)(zod@3.24.1)))(axios@1.7.9)(openai@4.85.4(ws@8.18.0)(zod@3.24.1))(ws@8.18.0) + '@fontsource/inter': + specifier: ^5.2.5 + version: 5.2.5 '@headlessui/react': specifier: ^2.0.3 version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -245,7 +248,7 @@ importers: version: 8.3.6(jiti@1.21.7)(postcss@8.5.1)(typescript@5.7.3)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^4.3.2 - version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0)) + version: 4.3.2(typescript@5.7.3)(vite@6.2.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0)) vitest: specifier: 2.1.6 version: 2.1.6(@types/node@20.17.17)(jiti@1.21.7)(jsdom@26.0.0)(yaml@2.7.0) @@ -935,6 +938,9 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@fontsource/inter@5.2.5': + resolution: {integrity: sha512-kbsPKj0S4p44JdYRFiW78Td8Ge2sBVxi/PIBwmih+RpSXUdvS9nbs1HIiuUSPtRMi14CqLEZ/fbk7dj7vni1Sg==} + '@fortawesome/fontawesome-common-types@6.7.2': resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} engines: {node: '>=6'} @@ -8353,6 +8359,8 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@fontsource/inter@5.2.5': {} + '@fortawesome/fontawesome-common-types@6.7.2': {} '@fortawesome/fontawesome-svg-core@6.7.2': @@ -15646,13 +15654,13 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0)): + vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.2.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.7.3) optionalDependencies: - vite: 6.1.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0) + vite: 6.2.0(@types/node@20.17.17)(jiti@1.21.7)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript