feat(kafka): filter loaded messages

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-08-24 20:27:26 +08:00
1 parent ae3b991bd5
commit 697a8efe6e
12 files changed
+259 -1

No files matched your search

@@ -6,10 +6,12 @@ import type { MqSystemKind, PeekedMessage, PeekMessagesOptions, TopicRef } from
import { mqPeekMessages } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildKafkaMessageSearchText, kafkaMessageSearchTextMatches, normalizeKafkaMessageSearchQuery } from "@/lib/mq/kafkaMessageSearch";
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";
import MqSearchInput from "@/components/mq/shared/MqSearchInput.vue";
type MessageBrowserAppearance = "form" | "monitoring";
@@ -35,6 +37,7 @@ const partition = ref<string | number>("");
const offset = ref<string | number>("");
const count = ref(20);
const advancedExpanded = ref(false);
const messageSearchQuery = ref("");
type KafkaPeekStartPosition = NonNullable<PeekMessagesOptions["startPosition"]>;
const kafkaStartPosition = ref<KafkaPeekStartPosition>("latest");
let messageRequestVersion = 0;
@@ -42,6 +45,18 @@ let messageRequestVersion = 0;
const isKafka = computed(() => props.mqSystemKind === "kafka");
const isKafkaOffsetMode = computed(() => kafkaStartPosition.value === "offset");
const isMonitoring = computed(() => props.appearance === "monitoring");
const normalizedMessageSearchQuery = computed(() => normalizeKafkaMessageSearchQuery(messageSearchQuery.value));
const searchableMessages = computed(() =>
messages.value.map((message) => ({
message,
searchText: buildKafkaMessageSearchText(message, formatMessageTimestamp(message.publishTime)),
})),
);
const filteredMessages = computed(() => {
const query = normalizedMessageSearchQuery.value;
if (!query) return messages.value;
return searchableMessages.value.filter(({ searchText }) => kafkaMessageSearchTextMatches(searchText, query)).map(({ message }) => message);
});
function peekGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_viewer__";
@@ -145,6 +160,7 @@ function formatMessageTimestamp(value?: string): string {
}
watch([() => props.connectionId, () => props.mqSystemKind], () => {
messageSearchQuery.value = "";
invalidateMessageRequest();
});
@@ -153,6 +169,7 @@ watch(
() => {
partition.value = "";
offset.value = "";
messageSearchQuery.value = "";
invalidateMessageRequest();
},
);
@@ -234,11 +251,19 @@ watch(kafkaStartPosition, () => {
</div>
</template>
<div v-if="isKafka && messages.length" class="message-filter-row" data-testid="kafka-message-filter">
<MqSearchInput v-model="messageSearchQuery" :placeholder="t('mqMessages.filterLoadedPlaceholder')" :aria-label="t('mqMessages.filterLoadedPlaceholder')" :disabled="loading" data-testid="kafka-message-filter-input" />
<span class="mq-result-count" data-testid="kafka-message-filter-count" aria-live="polite">
{{ t("mqMessages.filterLoadedCount", { matched: filteredMessages.length, loaded: messages.length }) }}
</span>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading" class="message-empty">{{ t("mqMessages.messagesLoading") }}</div>
<div v-else-if="!messages.length" class="message-empty">{{ t("mqMessages.noMessages") }}</div>
<div v-else-if="!filteredMessages.length" class="message-empty" data-testid="kafka-message-filter-empty">{{ t("mqMessages.noMatchingMessages") }}</div>
<div v-else class="message-list">
<article v-for="message in messages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="message-row">
<article v-for="message in filteredMessages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="message-row">
<div class="message-meta">
<span>#{{ message.position }}</span>
<span v-if="message.properties?.partition != null">{{ t("mqMessages.metaPartition", { partition: message.properties.partition }) }}</span>
@@ -375,6 +400,18 @@ watch(kafkaStartPosition, () => {
margin-top: 6px;
}
.message-filter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.message-filter-row :deep(.mq-search-input) {
width: min(420px, 100%);
flex: 1;
}
.collapse-toggle {
display: flex;
align-items: center;
@@ -263,6 +263,128 @@ describe("MessageBrowser", () => {
expect(browser.textContent).toContain("partial message");
});
it("filters loaded Kafka messages and shows the matching count", async () => {
backend.mqPeekMessages.mockResolvedValueOnce([
{
position: 1,
messageId: "11",
key: "order-alpha",
payloadBase64: "",
payloadText: "first payload",
properties: { partition: "0" },
headers: {},
},
{
position: 2,
messageId: "12",
key: "order-beta",
payloadBase64: "",
payloadText: "second payload",
properties: { partition: "1" },
headers: { TraceId: "REQUEST-99" },
},
]);
const browser = await mountBrowser();
await loadMessages(browser);
const filter = browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]');
if (!filter) throw new Error("Kafka message filter input not found");
expect(filter.getAttribute("aria-label")).toBe("mqMessages.filterLoadedPlaceholder");
expect(browser.querySelector('[data-testid="kafka-message-filter-count"]')?.textContent).toContain('mqMessages.filterLoadedCount:{"matched":2,"loaded":2}');
await setInputValue(filter, "request-99");
expect(browser.textContent).not.toContain("first payload");
expect(browser.textContent).toContain("second payload");
expect(browser.querySelector('[data-testid="kafka-message-filter-count"]')?.textContent).toContain('mqMessages.filterLoadedCount:{"matched":1,"loaded":2}');
});
it("shows a dedicated empty state without hiding an incomplete warning", async () => {
backend.mqPeekMessages.mockResolvedValueOnce({
messages: [
{
position: 1,
messageId: "partial",
payloadBase64: "",
payloadText: "partial message",
properties: {},
headers: {},
},
],
incomplete: true,
});
const browser = await mountBrowser();
await loadMessages(browser);
const filter = browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]');
if (!filter) throw new Error("Kafka message filter input not found");
await setInputValue(filter, "missing value");
expect(browser.querySelector('[data-testid="peek-incomplete"]')).not.toBeNull();
expect(browser.querySelector('[data-testid="kafka-message-filter-empty"]')?.textContent).toContain("mqMessages.noMatchingMessages");
expect(browser.textContent).not.toContain("partial message");
});
it("keeps a filter across reloads and Kafka start-position changes", async () => {
backend.mqPeekMessages.mockResolvedValue([
{
position: 1,
messageId: "17",
payloadBase64: "",
payloadText: "keep this message",
properties: {},
headers: {},
},
]);
const browser = await mountBrowser();
await loadMessages(browser);
const filter = browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]');
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!filter || !startPosition) throw new Error("Kafka message filter controls not found");
await setInputValue(filter, "keep");
await loadMessages(browser);
expect(browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]')?.value).toBe("keep");
await setSelectValue(startPosition, "earliest");
await loadMessages(browser);
expect(browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]')?.value).toBe("keep");
expect(browser.textContent).toContain("keep this message");
});
it("clears the filter when the topic or connection context changes", async () => {
const { browser, topic, connectionId } = await mountBrowserWithMutableTopic();
await loadMessages(browser);
const filter = browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]');
if (!filter) throw new Error("Kafka message filter input not found");
await setInputValue(filter, "existing");
topic.value = { ...TOPIC, topic: "payments" };
await flushUi();
await loadMessages(browser);
expect(browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]')?.value).toBe("");
const reloadedFilter = browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]');
if (!reloadedFilter) throw new Error("Reloaded Kafka message filter input not found");
await setInputValue(reloadedFilter, "existing");
connectionId.value = "mq-2";
await flushUi();
await loadMessages(browser);
expect(browser.querySelector<HTMLInputElement>('[data-testid="kafka-message-filter-input"]')?.value).toBe("");
});
it("does not expose the loaded-message filter for non-Kafka browsers", async () => {
const browser = await mountBrowser("rabbitmq");
await loadMessages(browser);
expect(browser.querySelector('[data-testid="kafka-message-filter"]')).toBeNull();
});
it("sends explicit earliest and offset read positions", async () => {
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');