feat(kafka): copy message payload and headers from message browser

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-11 13:07:10 +08:00
1 parent 78b8f77e0d
commit 89d65e96ed
3 files changed
+172 -10

No files matched your search

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Copy } from "@lucide/vue";
import type { MqSystemKind, PeekedMessage, PeekMessagesOptions, TopicRef } from "@/types/mq";
import { mqPeekMessages } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
import { useToast } from "@/composables/useToast";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type MessageBrowserAppearance = "form" | "monitoring";
@@ -21,6 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
appearance: "form",
});
const { t } = useI18n();
const { toast } = useToast();
const loading = ref(false);
const error = ref<string>();
@@ -115,6 +120,23 @@ function messagePayload(message: PeekedMessage): string {
return message.payloadText ?? message.payloadBase64;
}
async function copyMessagePayload(message: PeekedMessage) {
await copyMessageText(messagePayload(message));
}
async function copyMessageHeaders(message: PeekedMessage) {
await copyMessageText(JSON.stringify(message.headers, null, 2));
}
async function copyMessageText(text: string) {
try {
await copyToClipboard(text);
toast(t("grid.copied"));
} catch (cause: unknown) {
toast(t("grid.copyFailed", { message: formatError(cause) }), 5000);
}
}
function formatMessageTimestamp(value?: string): string {
if (!value) return "-";
const numeric = Number(value);
@@ -215,9 +237,27 @@ watch(kafkaStartPosition, () => {
<span v-if="message.key">{{ t("mqMessages.metaKey", { key: message.key }) }}</span>
<span>{{ formatMessageTimestamp(message.publishTime) }}</span>
</div>
<pre class="message-payload">{{ messagePayload(message) }}</pre>
<div class="message-payload-section">
<div class="message-payload-heading">
<span>{{ t("mqMessages.messageContent") }}</span>
<Button type="button" variant="outline" size="sm" class="message-copy-action h-7 gap-1.5 px-2 text-xs" :aria-label="`${t('grid.copy')} ${t('mqMessages.messageContent')}`" data-testid="copy-message-payload" @click="copyMessagePayload(message)">
<Copy :size="14" aria-hidden="true" />
{{ t("grid.copy") }}
</Button>
</div>
<pre data-native-clipboard class="message-payload">{{ messagePayload(message) }}</pre>
</div>
<div v-if="Object.keys(message.headers || {}).length" class="message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
<div class="message-headers-heading">
<span>{{ t("mqMessages.messageHeaders") }}</span>
<Button type="button" variant="outline" size="sm" class="message-copy-action h-7 gap-1.5 px-2 text-xs" :aria-label="`${t('grid.copy')} ${t('mqMessages.messageHeaders')}`" data-testid="copy-message-headers" @click="copyMessageHeaders(message)">
<Copy :size="14" aria-hidden="true" />
{{ t("grid.copy") }}
</Button>
</div>
<div class="message-headers-values">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
</div>
</div>
</article>
</div>
@@ -411,8 +451,38 @@ watch(kafkaStartPosition, () => {
font-weight: 700;
}
.message-copy-action {
flex-shrink: 0;
}
.message-payload-section,
.message-headers {
margin-top: 8px;
}
.message-payload-heading,
.message-headers-heading,
.message-headers-values {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.message-payload-heading,
.message-headers-heading {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
}
.message-payload-heading .message-copy-action,
.message-headers-heading .message-copy-action {
margin-left: auto;
}
.message-payload {
margin: 8px 0 0;
margin: 6px 0 0;
padding: 10px;
max-height: 160px;
overflow: auto;
@@ -426,14 +496,11 @@ watch(kafkaStartPosition, () => {
word-break: break-word;
}
.message-headers {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
.message-headers-heading {
margin-bottom: 6px;
}
.message-headers span {
.message-headers-values span {
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
@@ -8,14 +8,30 @@ const backend = vi.hoisted(() => ({
mqPeekMessages: vi.fn(),
}));
const clipboard = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
}));
const toast = vi.hoisted(() => ({
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }),
}));
vi.mock("@/lib/backend/api", () => ({
mqPeekMessages: backend.mqPeekMessages,
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: clipboard.copyToClipboard,
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => toast,
}));
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
import MessageBrowser from "@/components/mq/MessageBrowser.vue";
@@ -107,6 +123,9 @@ async function loadMessages(container: ParentNode) {
beforeEach(() => {
backend.mqPeekMessages.mockReset();
clipboard.copyToClipboard.mockReset();
clipboard.copyToClipboard.mockResolvedValue(undefined);
toast.toast.mockReset();
backend.mqPeekMessages.mockResolvedValue([
{
position: 1,
@@ -127,6 +146,81 @@ afterEach(() => {
});
describe("MessageBrowser", () => {
it("copies a message's displayed payload", async () => {
const browser = await mountBrowser();
await loadMessages(browser);
const copyButton = browser.querySelector<HTMLButtonElement>('[data-testid="copy-message-payload"]');
if (!copyButton) throw new Error("Message payload copy button not found");
copyButton.click();
await flushUi();
expect(clipboard.copyToClipboard).toHaveBeenCalledWith("existing message");
expect(copyButton.dataset.variant).toBe("outline");
expect(copyButton.dataset.size).toBe("sm");
expect(copyButton.getAttribute("aria-label")).toBe("grid.copy mqMessages.messageContent");
expect(toast.toast).toHaveBeenCalledWith("grid.copied");
expect(browser.querySelector('[data-testid="copy-message-headers"]')).toBeNull();
});
it("copies the base64 payload when no text payload is available", async () => {
backend.mqPeekMessages.mockResolvedValueOnce([
{
position: 1,
messageId: "binary",
payloadBase64: "AAE=",
properties: { partition: "0" },
headers: {},
},
]);
const browser = await mountBrowser();
await loadMessages(browser);
const copyButton = browser.querySelector<HTMLButtonElement>('[data-testid="copy-message-payload"]');
if (!copyButton) throw new Error("Message payload copy button not found");
copyButton.click();
await flushUi();
expect(clipboard.copyToClipboard).toHaveBeenCalledWith("AAE=");
});
it("copies message headers as formatted JSON", async () => {
backend.mqPeekMessages.mockResolvedValueOnce([
{
position: 1,
messageId: "17",
payloadBase64: "",
payloadText: "existing message",
properties: { partition: "0" },
headers: { source: "billing", traceId: "abc-123" },
},
]);
const browser = await mountBrowser();
await loadMessages(browser);
const copyButton = browser.querySelector<HTMLButtonElement>('[data-testid="copy-message-headers"]');
if (!copyButton) throw new Error("Message headers copy button not found");
copyButton.click();
await flushUi();
expect(clipboard.copyToClipboard).toHaveBeenCalledWith('{\n "source": "billing",\n "traceId": "abc-123"\n}');
expect(copyButton.getAttribute("aria-label")).toBe("grid.copy mqMessages.messageHeaders");
});
it("shows a copy failure toast without breaking the message browser", async () => {
clipboard.copyToClipboard.mockRejectedValueOnce(new Error("Clipboard unavailable"));
const browser = await mountBrowser();
await loadMessages(browser);
const copyButton = browser.querySelector<HTMLButtonElement>('[data-testid="copy-message-payload"]');
if (!copyButton) throw new Error("Message payload copy button not found");
copyButton.click();
await flushUi();
expect(toast.toast).toHaveBeenCalledWith('grid.copyFailed:{"message":"Clipboard unavailable"}', 5000);
expect(browser.textContent).toContain("existing message");
});
it("loads Kafka's latest messages by default", async () => {
const browser = await mountBrowser();
@@ -32,6 +32,7 @@ vi.mock("@lucide/vue", async () => {
BarChart3: Icon,
Boxes: Icon,
CheckCircle2: Icon,
Copy: Icon,
Database: Icon,
Download: Icon,
Gauge: Icon,