diff --git a/ui/api/schema/action.ts b/ui/api/schema/action.ts new file mode 100644 index 000000000..9231d28d5 --- /dev/null +++ b/ui/api/schema/action.ts @@ -0,0 +1,12 @@ +"use server"; + +import { getHeaders } from "../api"; + +export async function getSchema(contentLink: string) { + const url = `${process.env.BACKEND_URL}/${contentLink}`; + const res = await fetch(url, { + headers: await getHeaders(), + }); + const rawData = await res.text(); + return rawData; +} diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/ConnectedMessagesTable.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/ConnectedMessagesTable.tsx index d81d3a416..d24d47b42 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/ConnectedMessagesTable.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/ConnectedMessagesTable.tsx @@ -30,12 +30,14 @@ export function ConnectedMessagesTable({ topicName, selectedMessage: serverSelectedMessage, partitions, + baseurl }: { kafkaId: string; topicId: string; topicName: string; selectedMessage: Message | undefined; partitions: number; + baseurl: string; }) { const [params, sp] = useParseSearchParams(); const updateUrl = useFilterParams(sp); @@ -208,6 +210,7 @@ export function ConnectedMessagesTable({ onSelectMessage={setSelected} onDeselectMessage={deselectMessage} onReset={onReset} + baseurl={baseurl} > {limit === "continuously" && ( ); } diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/ConnectedSchema.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/ConnectedSchema.tsx new file mode 100644 index 000000000..5e28ba77a --- /dev/null +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/ConnectedSchema.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { SchemaValue } from "@/components/MessagesTable/components/SchemaValue"; +import { PageSection, Title } from "@patternfly/react-core"; + +export function ConnectedSchema({ + content, + name, +}: { + content: string; + name: string; +}) { + return ( + + {name} + + + ); +} diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/page.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/page.tsx new file mode 100644 index 000000000..a36a44ed2 --- /dev/null +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/topics/[topicId]/messages/schema/page.tsx @@ -0,0 +1,21 @@ +import { getSchema } from "@/api/schema/action"; +import { ConnectedSchema } from "./ConnectedSchema"; + +export default async function ConnectedSchemaPage({ + searchParams, +}: { + searchParams: { content?: string; schemaname?: string }; +}) { + const urlSearchParams = new URLSearchParams(searchParams); + + const content = urlSearchParams.get("content"); + const schemaname = urlSearchParams.get("schemaname"); + + if (!content) { + throw new Error("Content parameter is missing."); + } + + const schemaContent = await getSchema(content); + + return ; +} diff --git a/ui/components/MessagesTable/MessagesTable.tsx b/ui/components/MessagesTable/MessagesTable.tsx index d47ccf101..0b6c3926b 100644 --- a/ui/components/MessagesTable/MessagesTable.tsx +++ b/ui/components/MessagesTable/MessagesTable.tsx @@ -12,7 +12,10 @@ import { TextContent, Tooltip, } from "@/libs/patternfly/react-core"; -import { ExclamationTriangleIcon, HelpIcon } from "@/libs/patternfly/react-icons"; +import { + ExclamationTriangleIcon, + HelpIcon, +} from "@/libs/patternfly/react-icons"; import { BaseCellProps, InnerScrollContainer, @@ -36,6 +39,7 @@ import { NoData } from "./components/NoData"; import { NoResultsEmptyState } from "./components/NoResultsEmptyState"; import { UnknownValuePreview } from "./components/UnknownValuePreview"; import { beautifyUnknownValue, isSameMessage } from "./components/utils"; +import { ExternalLink } from "../Navigation/ExternalLink"; const columnWidths: Record = { "offset-partition": 10, @@ -48,10 +52,12 @@ const columnWidths: Record = { }; const defaultColumns: Column[] = [ - "timestampUTC", "offset-partition", + "timestampUTC", "key", + "headers", "value", + "size", ]; export type MessagesTableProps = { @@ -71,6 +77,7 @@ export type MessagesTableProps = { onSelectMessage: (message: Message) => void; onDeselectMessage: () => void; onReset: () => void; + baseurl: string; }; export function MessagesTable({ @@ -91,6 +98,7 @@ export function MessagesTable({ onDeselectMessage, onReset, children, + baseurl, }: PropsWithChildren) { const t = useTranslations("message-browser"); const columnLabels = useColumnLabels(); @@ -181,7 +189,7 @@ export function MessagesTable({ + {children} ); @@ -264,30 +268,49 @@ export function MessagesTable({ {row.attributes.key ? ( <> - { - setDefaultTab("key"); - onSelectMessage(row); - }} - /> - {row.relationships.keySchema && ( - - - {row.relationships.keySchema?.meta?.artifactType && ( - <> - {row.relationships.keySchema?.meta?.artifactType} - - )} - {row.relationships.keySchema?.meta?.errors && ( + { + setDefaultTab("key"); + onSelectMessage(row); + }} + /> + {row.relationships.keySchema && ( + + + {row.relationships.keySchema?.meta + ?.name && + row.relationships.keySchema?.links + ?.content ? ( + + { + row.relationships.keySchema?.meta + ?.name + } + + ) : ( + row.relationships.keySchema?.meta?.name + )} + {row.relationships.keySchema?.meta + ?.errors && ( <> - { row.relationships.keySchema?.meta?.errors[0].detail } + {" "} + { + row.relationships.keySchema?.meta + ?.errors[0].detail + } - )} - - - )} + )} + + + )} ) : ( empty @@ -318,30 +341,47 @@ export function MessagesTable({ {row.attributes.value ? ( <> - { - setDefaultTab("value"); - onSelectMessage(row); - }} - /> - {row.relationships.valueSchema && ( - - - {row.relationships.valueSchema?.meta?.artifactType && ( - <> - {row.relationships.valueSchema?.meta?.artifactType} - - )} - {row.relationships.valueSchema?.meta?.errors && ( - <> - { row.relationships.valueSchema?.meta?.errors[0].detail } - - )} - - - )} + { + setDefaultTab("value"); + onSelectMessage(row); + }} + /> + {row.relationships.valueSchema && ( + + + {row.relationships.valueSchema?.meta + ?.name && + row.relationships.valueSchema?.links + ?.content ? ( + + { + row.relationships.valueSchema.meta + .name + } + + ) : ( + row.relationships.valueSchema?.meta + ?.name + )} + {row.relationships.valueSchema?.meta + ?.errors && ( + <> + {" "} + { + row.relationships.valueSchema?.meta + ?.errors[0].detail + } + + )} + + + )} ) : ( empty diff --git a/ui/components/MessagesTable/components/MessageDetails.stories.tsx b/ui/components/MessagesTable/components/MessageDetails.stories.tsx index 706ec8e0a..f35e8dd01 100644 --- a/ui/components/MessagesTable/components/MessageDetails.stories.tsx +++ b/ui/components/MessagesTable/components/MessageDetails.stories.tsx @@ -16,7 +16,9 @@ export default { partition: 4, offset: 16, size: 1234, - timestamp: new Date(Date.UTC(2024, 11, 31, 23, 59, 59, 999)).toISOString(), + timestamp: new Date( + Date.UTC(2024, 11, 31, 23, 59, 59, 999), + ).toISOString(), headers: { "post-office-box": "string", "extended-address": "string", @@ -29,6 +31,23 @@ export default { value: '{"order":{"address":{"street":"123 any st","city":"Austin","state":"TX","zip":"78626"},"contact":{"firstName":"james","lastName":"smith","phone":"512-123-1234"},"orderId":"123"},"primitives":{"stringPrimitive":"some value","booleanPrimitive":true,"numberPrimitive":24},"addressList":[{"street":"123 any st","city":"Austin","state":"TX","zip":"78626"},{"street":"123 any st","city":"Austin","state":"TX","zip":"78626"},{"street":"123 any st","city":"Austin","state":"TX","zip":"78626"},{"street":"123 any st","city":"Austin","state":"TX","zip":"78626"}]}', }, + relationships: { + keySchema: null, + valueSchema: { + meta: { + artifactType: "AVRO", + name: "com.example.price", + }, + data: { + type: "schemas", + id: "eyJnbG9iYWxJZCI6MX0=", + }, + links: { + content: + "/api/registries/my-apicurio-registry/schemas/eyJnbG9iYWxJZCI6MX0=", + }, + }, + }, }, defaultTab: "value", onClose: fn(), diff --git a/ui/components/MessagesTable/components/MessageDetails.tsx b/ui/components/MessagesTable/components/MessageDetails.tsx index d9f5a7691..68b3d8c67 100644 --- a/ui/components/MessagesTable/components/MessageDetails.tsx +++ b/ui/components/MessagesTable/components/MessageDetails.tsx @@ -23,12 +23,20 @@ import { Tooltip, } from "@/libs/patternfly/react-core"; import { HelpIcon } from "@/libs/patternfly/react-icons"; -import { ClipboardCopy } from "@patternfly/react-core"; +import { + ClipboardCopy, + Stack, + StackItem, + TabContent, + Title, +} from "@patternfly/react-core"; import { useTranslations } from "next-intl"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { allExpanded, defaultStyles, JsonView } from "react-json-view-lite"; import { NoData } from "./NoData"; import { maybeJson } from "./utils"; +import { getSchema } from "@/api/schema/action"; +import { SchemaValue } from "./SchemaValue"; export type MessageDetailsProps = { onClose: () => void; @@ -42,7 +50,6 @@ export function MessageDetails({ message, }: MessageDetailsProps) { const t = useTranslations("message-browser"); - const body = useMemo(() => { return ( message && ( @@ -83,6 +90,37 @@ export function MessageDetailsBody({ const [key, isKeyJson] = maybeJson(message.attributes.key || "{}"); const [value, isValueJson] = maybeJson(message.attributes.value || "{}"); + const [keySchemaContent, setKeySchemaContent] = useState(); + const [valueSchemaContent, setValueSchemaContent] = useState(); + + useEffect(() => { + async function fetchSchemas() { + try { + // Fetch Key Schema + if (message?.relationships.keySchema?.links?.content) { + const keySchemaLink = message.relationships.keySchema.links.content; + const keySchema = await getSchema(keySchemaLink); + setKeySchemaContent(keySchema); + } else { + console.log("No URL found for key schema."); + } + + // Fetch Value Schema + if (message?.relationships.valueSchema?.links?.content) { + const valueSchemaLink = + message.relationships.valueSchema.links.content; + const valueSchema = await getSchema(valueSchemaLink); + setValueSchemaContent(valueSchema); + } else { + console.log("No URL found for value schema."); + } + } catch (error) { + console.error("Error fetching schemas:", error); + } + } + fetchSchemas(); + }, [message]); + return ( @@ -143,6 +181,18 @@ export function MessageDetailsBody({ )} + + {t("field.key-format")} + + {message.relationships.keySchema?.meta?.artifactType ?? "Plain"} + + + + {t("field.value-format")} + + {message.relationships.valueSchema?.meta?.artifactType ?? "Plain"} + + @@ -152,45 +202,75 @@ export function MessageDetailsBody({ eventKey={"value"} title={{t("field.value")}} > - - {message.attributes.value ?? "-"} - - {isValueJson && ( - - )} + + + + {message.attributes.value ?? "-"} + + {isValueJson && ( + + )} + + {valueSchemaContent && ( + + + {message.relationships.valueSchema?.meta?.name} + + + + )} + {t("field.key")}} > - - {message.attributes.key ?? "-"} - - {isKeyJson && ( - - )} + + + + {message.attributes.key ?? "-"} + + {isKeyJson && ( + + )} + + {keySchemaContent && ( + + + {message.relationships.keySchema?.meta?.name} + + + + )} + ; + +type Story = StoryObj; + +export const Default: Story = { + args: { + name: "SchemaValue", + schema: JSON.stringify({ + type: "record", + name: "price", + namespace: "com.example", + fields: [ + { name: "symbol", type: "string" }, + { name: "price", type: "string" }, + ], + }), + }, +}; diff --git a/ui/components/MessagesTable/components/SchemaValue.tsx b/ui/components/MessagesTable/components/SchemaValue.tsx new file mode 100644 index 000000000..2bd2d9e27 --- /dev/null +++ b/ui/components/MessagesTable/components/SchemaValue.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { + Button, + ClipboardCopyButton, + CodeBlock, + CodeBlockAction, + CodeBlockCode, +} from "@/libs/patternfly/react-core"; +import { DownloadIcon } from "@patternfly/react-icons"; +import { useState } from "react"; + +export function SchemaValue({ + schema, + name, +}: { + schema: string; + name: string; +}) { + const [copyStatus, setCopyStatus] = useState("Copy schema"); + + const copyToClipboard = () => { + navigator.clipboard + .writeText(schema) + .then(() => { + setCopyStatus("Successfully copied"); + setTimeout(() => setCopyStatus("Copy schema"), 2000); + }) + .catch((error) => { + console.error("Error copying text to clipboard:", error); + }); + }; + + const onClickDownload = () => { + const blob = new Blob([schema], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name}.text`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const actions = ( + + + {copyStatus} + +