feat(kafka): copy message payload and headers from message browser
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user