feat(kafka): filter loaded messages
This commit is contained in:
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"]');
|
||||
|
||||
Reference in new issue
Block a user