diff --git a/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSchemaParser.java b/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSchemaParser.java index 37050351e..1e4372dbb 100644 --- a/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSchemaParser.java +++ b/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSchemaParser.java @@ -4,6 +4,8 @@ import java.util.Set; import java.util.stream.Collectors; +import org.jboss.logging.Logger; + import io.apicurio.registry.resolver.ParsedSchema; import io.apicurio.registry.resolver.SchemaParser; import io.apicurio.registry.resolver.data.Record; @@ -13,6 +15,7 @@ */ public class MultiformatSchemaParser implements SchemaParser { + private static final Logger LOGGER = Logger.getLogger(MultiformatSchemaParser.class); private final Map> delegates; public MultiformatSchemaParser(Set> delegates) { @@ -41,6 +44,8 @@ public Object parseSchema(byte[] rawSchema, Map> re return delegate.parseSchema(rawSchema, resolvedReferences); } catch (Exception e) { // Schema is not valid for the delegate parser + LOGGER.debugf("Schema could not be parsed as artifact type %s: %s", + delegate.artifactType(), e.getLocalizedMessage()); } } diff --git a/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSerializer.java b/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSerializer.java index 9db7d22bc..d110b9bb0 100644 --- a/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSerializer.java +++ b/api/src/main/java/com/github/streamshub/console/api/support/serdes/MultiformatSerializer.java @@ -205,9 +205,20 @@ SchemaLookupResult resolveSchema(String topic, Headers headers, RecordDa try { schema = getSchemaResolver().resolveSchemaByArtifactReference(reference); } catch (Exception e) { - LOGGER.warnf("Exception retrieving schema: %s", RootCause.of(e) - .map(Throwable::getMessage) - .orElseGet(() -> String.valueOf(e))); + Throwable cause = RootCause.of(e).orElse(e); + + if (cause instanceof io.apicurio.registry.rest.client.v2.models.Error clientError) { + LOGGER.infof("Schema could not be resolved: %s. Message: %s", reference, clientError.getMessageEscaped()); + } else if (LOGGER.isDebugEnabled()) { + /* + * Only log the stack trace at debug level. Schema resolution will be attempted + * for every message consumed and will lead to excessive logging in case of a + * problem. + */ + LOGGER.debugf(e, "Exception resolving schema reference: %s", reference); + } else { + LOGGER.warnf("Exception resolving schema reference: %s ; %s", reference, e.getMessage()); + } schema = EMPTY_RESULT; } } diff --git a/api/src/main/webui/src/api/client.ts b/api/src/main/webui/src/api/client.ts index 51db954e6..1cbbfc1df 100644 --- a/api/src/main/webui/src/api/client.ts +++ b/api/src/main/webui/src/api/client.ts @@ -83,6 +83,29 @@ class ApiClient { return data; } + /** + * GET request returning plain text (e.g. schema content) + */ + async getText(path: string, options?: RequestInit): Promise { + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + ...options, + method: 'GET', + headers: { + 'X-Requested-With': 'JavaScript', + ...options?.headers, + }, + }); + if (response.status === 499 && response.headers.get('WWW-Authenticate') === 'OIDC') { + this.login(); + return ''; + } + if (!response.ok) { + throw new ApiError(response.status, response.statusText); + } + return response.text(); + } + /** * GET request */ diff --git a/api/src/main/webui/src/api/hooks/useSchemaContent.ts b/api/src/main/webui/src/api/hooks/useSchemaContent.ts new file mode 100644 index 000000000..19d595117 --- /dev/null +++ b/api/src/main/webui/src/api/hooks/useSchemaContent.ts @@ -0,0 +1,19 @@ +/** + * Hook for fetching schema content from a schema registry link. + * + * The `links.content` field on a RelatedSchema is an absolute path from origin + * (e.g. `/api/registries/{registryId}/schemas/{schemaId}`). + * The response is plain text (the raw schema definition). + */ + +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../client'; + +export function useSchemaContent(contentUrl: string | null | undefined) { + return useQuery({ + queryKey: ['schemaContent', contentUrl], + queryFn: () => apiClient.getText(contentUrl!), + enabled: !!contentUrl, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/api/src/main/webui/src/components/kafka/KafkaClusterSidebar.tsx b/api/src/main/webui/src/components/kafka/KafkaClusterSidebar.tsx index cd53cea87..24d23d208 100644 --- a/api/src/main/webui/src/components/kafka/KafkaClusterSidebar.tsx +++ b/api/src/main/webui/src/components/kafka/KafkaClusterSidebar.tsx @@ -39,7 +39,7 @@ export function KafkaClusterSidebar() { { id: 'connect', label: t('kafka.connect.title'), - path: `/kafka/${kafkaId}/connect/connectors`, + path: `/kafka/${kafkaId}/connect`, }, { id: 'users', diff --git a/api/src/main/webui/src/components/kafka/topics/MessageDetails.tsx b/api/src/main/webui/src/components/kafka/topics/MessageDetails.tsx index be8a8e306..a74533be1 100644 --- a/api/src/main/webui/src/components/kafka/topics/MessageDetails.tsx +++ b/api/src/main/webui/src/components/kafka/topics/MessageDetails.tsx @@ -16,12 +16,17 @@ import { TabTitleText, ClipboardCopy, Title, + Tooltip, + CodeBlock, + CodeBlockCode, } from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; import { allExpanded, darkStyles, defaultStyles, JsonView } from 'react-json-view-lite'; import 'react-json-view-lite/dist/index.css'; import { KafkaRecord } from '@/api/types'; import { formatDateTime } from '@/utils/dateTime'; import { useTheme } from '@/components/app/ThemeProvider'; +import { useSchemaContent } from '@/api/hooks/useSchemaContent'; interface MessageDetailsProps { message: KafkaRecord; @@ -78,6 +83,11 @@ export function MessageDetails({ message }: MessageDetailsProps) { const valueJson = maybeJson(message.attributes.value); const headers = Object.entries(message.attributes.headers ?? {}); + const keySchemaContentUrl = message.relationships.keySchema?.links?.content; + const valueSchemaContentUrl = message.relationships.valueSchema?.links?.content; + const { data: keySchemaContent } = useSchemaContent(keySchemaContentUrl); + const { data: valueSchemaContent } = useSchemaContent(valueSchemaContentUrl); + return ( @@ -96,7 +106,12 @@ export function MessageDetails({ message }: MessageDetailsProps) { - {t('topics.messages.field.size')} + + {t('topics.messages.field.size')}{' '} + + + + {formatBytes(message.attributes.size)} @@ -167,6 +182,11 @@ export function MessageDetails({ message }: MessageDetailsProps) { {t('topics.messages.schema')}

{message.relationships.valueSchema.meta.name}

+ {valueSchemaContent && ( + + {valueSchemaContent} + + )} )} @@ -196,6 +216,11 @@ export function MessageDetails({ message }: MessageDetailsProps) { {t('topics.messages.schema')}

{message.relationships.keySchema.meta.name}

+ {keySchemaContent && ( + + {keySchemaContent} + + )} )} diff --git a/api/src/main/webui/src/components/kafka/topics/MessagesTable.tsx b/api/src/main/webui/src/components/kafka/topics/MessagesTable.tsx index b377315e7..fa3e20cc6 100644 --- a/api/src/main/webui/src/components/kafka/topics/MessagesTable.tsx +++ b/api/src/main/webui/src/components/kafka/topics/MessagesTable.tsx @@ -11,9 +11,10 @@ import { Button, Title, Content, + Tooltip, } from '@patternfly/react-core'; import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; -import { SearchIcon } from '@patternfly/react-icons'; +import { ExclamationTriangleIcon, HelpIcon, SearchIcon } from '@patternfly/react-icons'; import { KafkaRecord } from '@/api/types'; import { Column, useColumnLabels } from './ColumnsModal'; import { formatDateTime } from '@/utils/dateTime'; @@ -129,11 +130,39 @@ export function MessagesTable({ case 'timestamp': return formatTimestampLocal(message.attributes.timestamp); case 'key': - return truncate(message.attributes.key); + return ( + <> + {truncate(message.attributes.key)} + {message.relationships.keySchema && ( + + + {message.relationships.keySchema.meta?.name} + {message.relationships.keySchema.meta?.errors && ( + <> {message.relationships.keySchema.meta.errors[0].detail} + )} + + + )} + + ); case 'headers': return renderHeaders(message.attributes.headers); case 'value': - return truncate(message.attributes.value); + return ( + <> + {truncate(message.attributes.value)} + {message.relationships.valueSchema && ( + + + {message.relationships.valueSchema.meta?.name} + {message.relationships.valueSchema.meta?.errors && ( + <> {message.relationships.valueSchema.meta.errors[0].detail} + )} + + + )} + + ); case 'size': return formatBytes(message.attributes.size); default: @@ -167,7 +196,16 @@ export function MessagesTable({ return ( - {columnLabels[column]} + {column === 'size' ? ( + <> + {columnLabels[column]}{' '} + + + + + ) : ( + columnLabels[column] + )} ); })} diff --git a/api/src/main/webui/src/hooks/index.ts b/api/src/main/webui/src/hooks/index.ts index 1186879ed..632412c3b 100644 --- a/api/src/main/webui/src/hooks/index.ts +++ b/api/src/main/webui/src/hooks/index.ts @@ -2,4 +2,5 @@ * Custom React Hooks */ -export { useTableState } from './useTableState'; \ No newline at end of file +export { useTableState } from './useTableState'; +export { usePageTitle } from './usePageTitle'; \ No newline at end of file diff --git a/api/src/main/webui/src/hooks/usePageTitle.ts b/api/src/main/webui/src/hooks/usePageTitle.ts new file mode 100644 index 000000000..1ed447809 --- /dev/null +++ b/api/src/main/webui/src/hooks/usePageTitle.ts @@ -0,0 +1,37 @@ +/** + * usePageTitle Hook + * + * Sets the browser tab/window title in the format: + * "Page Name | StreamsHub Console" + * + * When called with no argument (from KafkaLayout and HomePage), the title is + * derived from the `handle.title` of the deepest matched route that declares + * one. Detail pages that have a dynamically-fetched name (e.g. topic name, + * connector name) call usePageTitle(fetchedName) to override the title once + * the data is available. + * + * When no page title can be determined the fallback is just "StreamsHub Console". + */ + +import { useEffect } from 'react'; +import { useMatches } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { RouteHandle } from '@/routes'; + +export function usePageTitle(override?: string) { + const { t } = useTranslation(); + const matches = useMatches(); + + // Walk matches from deepest to shallowest to find the most specific title. + const routeTitle = [...matches] + .reverse() + .map((m) => (m.handle as RouteHandle | undefined)?.title) + .find((titleFn) => typeof titleFn === 'function')?.(t); + + const page = override ?? routeTitle; + const appTitle = t('common.title'); + + useEffect(() => { + document.title = page ? `${page} | ${appTitle}` : appTitle; + }, [page, appTitle]); +} diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index 97f9b3574..12121b758 100644 --- a/api/src/main/webui/src/i18n/messages/en.json +++ b/api/src/main/webui/src/i18n/messages/en.json @@ -111,6 +111,7 @@ "title": "Kafka Connect", "connectors": "Connectors", "connectClusters": "Clusters", + "connectClustersTitle": "Connect Clusters", "searchConnectors": "Search connectors", "searchClusters": "Search connect clusters", "noConnectors": "No connectors found", diff --git a/api/src/main/webui/src/pages/HomePage.tsx b/api/src/main/webui/src/pages/HomePage.tsx index bfc3de49f..ae4c8d56b 100644 --- a/api/src/main/webui/src/pages/HomePage.tsx +++ b/api/src/main/webui/src/pages/HomePage.tsx @@ -27,12 +27,14 @@ import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import { useKafkaClusters } from '../api/hooks/useKafkaClusters'; import { useMetadata } from '../api/hooks/useMetadata'; import { useShowLearning } from '../hooks/useShowLearning'; +import { usePageTitle } from '@/hooks'; import { AppLayout } from '@/components/app/AppLayout'; import { ClustersDataView } from '@/components/home/ClustersDataView'; import { ResourceListParams } from '@/api/hooks/useResourceList'; export function HomePage() { const { t } = useTranslation(); + usePageTitle(); const [dataParams, setDataParams] = useState({}); const clusterResult = useKafkaClusters(dataParams); const { data: metadata } = useMetadata(); diff --git a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx index 20e6c7a09..2e995a4f8 100644 --- a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx +++ b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx @@ -4,6 +4,7 @@ import { Outlet, useParams, useLocation, Link } from 'react-router'; import { useTranslation } from 'react-i18next'; +import { usePageTitle } from '@/hooks'; import { Page, PageSection, @@ -45,6 +46,10 @@ export function KafkaLayout() { nodeId?: string; }>(); + // Must be called unconditionally before any early returns. + // Static routes supply handle.title; detail pages call usePageTitle themselves. + usePageTitle(); + const location = useLocation(); const { data, isLoading, error } = useKafkaCluster(kafkaId, { fields: 'name,namespace,status,kafkaVersion,creationTimestamp,listeners,conditions', diff --git a/api/src/main/webui/src/pages/kafka/connect/detail/ConnectClusterDetailPage.tsx b/api/src/main/webui/src/pages/kafka/connect/detail/ConnectClusterDetailPage.tsx index f2a04e7e7..ad1f69046 100644 --- a/api/src/main/webui/src/pages/kafka/connect/detail/ConnectClusterDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/connect/detail/ConnectClusterDetailPage.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router'; +import { usePageTitle } from '@/hooks'; import { PageSection, Tabs, @@ -53,6 +54,9 @@ export function ConnectClusterDetailPage() { const { data, isLoading, error } = useConnectCluster(connectClusterId); + const clusterName = data?.data?.attributes?.name || connectClusterId || ''; + usePageTitle(clusterName || undefined); + const handleTabClick = ( _event: React.MouseEvent, tabIndex: string | number diff --git a/api/src/main/webui/src/pages/kafka/connect/detail/ConnectorDetailPage.tsx b/api/src/main/webui/src/pages/kafka/connect/detail/ConnectorDetailPage.tsx index 8e4ff1e27..5a2892270 100644 --- a/api/src/main/webui/src/pages/kafka/connect/detail/ConnectorDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/connect/detail/ConnectorDetailPage.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { useParams } from 'react-router'; +import { usePageTitle } from '@/hooks'; import { PageSection, Title, @@ -53,6 +54,9 @@ export function ConnectorDetailPage() { const { data, isLoading, error } = useConnector(connectorId); + const connectorName = data?.data?.attributes?.name || connectorId || ''; + usePageTitle(connectorName || undefined); + const handleTabClick = ( _event: React.MouseEvent, tabIndex: string | number @@ -84,7 +88,6 @@ export function ConnectorDetailPage() { const tasks = data.included?.filter((item) => item.type === 'connectorTasks') || []; const config = connector.attributes.config || {}; const configEntries = Object.entries(config); - const connectorName = connector.attributes.name || connectorId || ''; return ( <> diff --git a/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx b/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx index 1518f61a3..10e23a5e6 100644 --- a/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { useParams, useNavigate, useLocation, Outlet } from 'react-router'; import { useTranslation } from 'react-i18next'; +import { usePageTitle } from '@/hooks'; import { PageSection, Title, @@ -31,6 +32,9 @@ export function GroupDetailPage() { const location = useLocation(); const { data: group, isLoading, error } = useGroup(kafkaId, groupId); + const groupName = group?.attributes.groupId || groupId || ''; + usePageTitle(groupName || undefined); + // Reset offset modal state const [isResetOffsetModalOpen, setIsResetOffsetModalOpen] = useState(false); const [resetOffsetSuccessMessage, setResetOffsetSuccessMessage] = useState(); @@ -72,7 +76,6 @@ export function GroupDetailPage() { ); } - const groupName = group?.attributes.groupId || groupId || ''; const canResetOffset = hasPrivilege('UPDATE', group) && group?.attributes.state === 'EMPTY' && diff --git a/api/src/main/webui/src/pages/kafka/nodes/detail/NodeDetailPage.tsx b/api/src/main/webui/src/pages/kafka/nodes/detail/NodeDetailPage.tsx index 12079a0f8..aac798d82 100644 --- a/api/src/main/webui/src/pages/kafka/nodes/detail/NodeDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/nodes/detail/NodeDetailPage.tsx @@ -4,6 +4,7 @@ import { useParams, useNavigate, useLocation, Outlet } from 'react-router'; import { useTranslation } from 'react-i18next'; +import { usePageTitle } from '@/hooks'; import { PageSection, Tabs, @@ -25,6 +26,8 @@ export function NodeDetailPage() { // Fetch node configuration to verify node exists const { data, isLoading, error } = useNodeConfig(kafkaId, nodeId); + usePageTitle(t('nodes.brokerTitle', { nodeId })); + // Determine active tab from URL const pathSegments = location.pathname.split('/').filter(Boolean); const lastSegment = pathSegments[pathSegments.length - 1]; diff --git a/api/src/main/webui/src/pages/kafka/topics/detail/TopicDetailPage.tsx b/api/src/main/webui/src/pages/kafka/topics/detail/TopicDetailPage.tsx index 9c596f48d..3c4e7ce7a 100644 --- a/api/src/main/webui/src/pages/kafka/topics/detail/TopicDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/topics/detail/TopicDetailPage.tsx @@ -5,6 +5,7 @@ import { useParams, useNavigate, useLocation, Outlet } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useEffect } from 'react'; +import { usePageTitle } from '@/hooks'; import { PageSection, Tabs, @@ -27,6 +28,9 @@ export function TopicDetailPage() { const { data, isLoading, error } = useTopic(kafkaId, topicId); const { addViewedTopic } = useViewedTopics(kafkaId); + const topicName = data?.data?.attributes?.name || topicId || ''; + usePageTitle(topicName || undefined); + // Track this topic as viewed when data is loaded useEffect(() => { if (data?.data && kafkaId && topicId) { @@ -75,7 +79,6 @@ export function TopicDetailPage() { } const topic = data?.data; - const topicName = topic?.attributes.name || topicId || ''; return ( <> diff --git a/api/src/main/webui/src/pages/kafka/users/detail/UserDetailPage.tsx b/api/src/main/webui/src/pages/kafka/users/detail/UserDetailPage.tsx index 4f89d8408..936367b87 100644 --- a/api/src/main/webui/src/pages/kafka/users/detail/UserDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/users/detail/UserDetailPage.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; +import { usePageTitle } from '@/hooks'; import { PageSection, Title, @@ -42,6 +43,9 @@ export function UserDetailPage() { const { data, isLoading, error } = useUser(kafkaId, userId); + const kafkaUserName = data?.data?.attributes?.username || userId || ''; + usePageTitle(kafkaUserName || undefined); + const handleTabClick = ( _event: React.MouseEvent | React.KeyboardEvent, tabIndex: string | number diff --git a/api/src/main/webui/src/routes/index.tsx b/api/src/main/webui/src/routes/index.tsx index 2ae1a3d36..f83f559c9 100644 --- a/api/src/main/webui/src/routes/index.tsx +++ b/api/src/main/webui/src/routes/index.tsx @@ -3,6 +3,7 @@ */ import { createBrowserRouter, Navigate } from 'react-router'; +import type { TFunction } from 'i18next'; import App from '../App'; // Root pages @@ -45,6 +46,11 @@ import { ConnectClusterDetailPage } from '@/pages/kafka/connect/detail/ConnectCl import { UsersPage } from '@/pages/kafka/users/UsersPage'; import { UserDetailPage } from '@/pages/kafka/users/detail/UserDetailPage'; +// Route handle type — each route may declare a page title factory. +export interface RouteHandle { + title?: (t: TFunction) => string; +} + export const router = createBrowserRouter([ { path: '/', @@ -65,14 +71,17 @@ export const router = createBrowserRouter([ }, { path: 'overview', + handle: { title: (t: TFunction) => t('kafka.overview') } satisfies RouteHandle, element: , }, { path: 'topics', + handle: { title: (t: TFunction) => t('kafka.topics') } satisfies RouteHandle, element: , }, { path: 'topics/:topicId', + // Title is dynamic (fetched topic name) — set by TopicDetailPage via usePageTitle element: , children: [ { @@ -99,6 +108,7 @@ export const router = createBrowserRouter([ }, { path: 'nodes', + handle: { title: (t: TFunction) => t('nodes.title') } satisfies RouteHandle, element: , children: [ { @@ -107,16 +117,19 @@ export const router = createBrowserRouter([ }, { path: 'overview', + handle: { title: (t: TFunction) => t('nodes.title') } satisfies RouteHandle, element: , }, { path: 'rebalances', + handle: { title: (t: TFunction) => t('nodes.tabs.rebalances') } satisfies RouteHandle, element: , }, ], }, { path: 'nodes/:nodeId', + // Title is dynamic (broker ID) — set by NodeDetailPage via usePageTitle element: , children: [ { @@ -131,10 +144,12 @@ export const router = createBrowserRouter([ }, { path: 'groups', + handle: { title: (t: TFunction) => t('groups.title') } satisfies RouteHandle, element: , }, { path: 'groups/:groupId', + // Title is dynamic (fetched group ID) — set by GroupDetailPage via usePageTitle element: , children: [ { @@ -153,6 +168,7 @@ export const router = createBrowserRouter([ }, { path: 'connect', + handle: { title: (t: TFunction) => t('kafka.connect.title') } satisfies RouteHandle, element: , children: [ { @@ -161,28 +177,34 @@ export const router = createBrowserRouter([ }, { path: 'connectors', + handle: { title: (t: TFunction) => t('kafka.connect.connectors') } satisfies RouteHandle, element: , }, { path: 'clusters', + handle: { title: (t: TFunction) => t('kafka.connect.connectClustersTitle') } satisfies RouteHandle, element: , }, ], }, { path: 'connect/connectors/:connectorId', + // Title is dynamic (fetched connector name) — set by ConnectorDetailPage via usePageTitle element: , }, { path: 'connect/clusters/:connectClusterId', + // Title is dynamic (fetched cluster name) — set by ConnectClusterDetailPage via usePageTitle element: , }, { path: 'users', + handle: { title: (t: TFunction) => t('kafka.users') } satisfies RouteHandle, element: , }, { path: 'users/:userId', + // Title is dynamic (fetched username) — set by UserDetailPage via usePageTitle element: , }, // More routes will be added as pages are migrated