feat(mqtt): add payload search to MQTT console

This commit is contained in:
jearhe authored and t8y2 committed 2026-09-06 10:57:06 +08:00
1 parent a1192fc651
commit 53be33c86c
11 files changed
+235 -7

No files matched your search

@@ -30,6 +30,7 @@ const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
const messagesPaused = ref(false);
const displayEncoding = ref<PayloadEncoding>("plaintext");
const topicSearch = ref("");
const payloadSearch = ref("");
const showSubscriptionDialog = ref(false);
const savingSubscription = ref(false);
const formTopic = ref("");
@@ -69,6 +70,16 @@ function buildTopicTree(topics: MqttSavedTopic[]): MqttTopicNode {
const topicTree = computed(() => buildTopicTree(savedTopics.value));
function messagePayloadText(msg: MqttMessage): string {
return msg.payloadText ?? decodePayload(msg.payloadBase64, "plaintext");
}
const filteredMessages = computed(() => {
const query = payloadSearch.value.trim().toLowerCase();
if (!query) return messages.value;
return messages.value.filter((msg) => messagePayloadText(msg).toLowerCase().includes(query));
});
async function refreshData() {
try {
const [info, active, configs, msgs] = await Promise.all([
@@ -241,7 +252,7 @@ function toggleMessagesPaused() {
}
function formatMessagePayload(msg: MqttMessage): string {
if (displayEncoding.value === "plaintext" && msg.payloadText != null) return msg.payloadText;
if (displayEncoding.value === "plaintext") return messagePayloadText(msg);
return decodePayload(msg.payloadBase64, displayEncoding.value);
}
@@ -325,11 +336,21 @@ onUnmounted(stopPolling);
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2 text-xs font-semibold uppercase text-muted-foreground">
<span v-if="selectedTopic">{{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }}</span>
<span v-else>{{ t("connection.mqttAllMessages") }}</span>
<div class="flex items-center gap-2">
<span class="font-normal">{{ messages.length }}</span>
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-3 py-2 text-xs font-semibold uppercase text-muted-foreground">
<span v-if="selectedTopic" class="min-w-0 truncate">{{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }}</span>
<span v-else class="min-w-0 truncate">{{ t("connection.mqttAllMessages") }}</span>
<div class="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2">
<input
v-model="payloadSearch"
data-testid="mqtt-payload-search"
class="h-6 min-w-[10rem] flex-1 rounded border bg-transparent px-1.5 text-[11px] font-normal normal-case outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.mqttSearchPayloadPlaceholder')"
:aria-label="t('connection.mqttSearchPayloadPlaceholder')"
/>
<span data-testid="mqtt-message-count" class="font-normal" aria-live="polite">
<template v-if="payloadSearch.trim()">{{ filteredMessages.length }} / {{ messages.length }}</template>
<template v-else>{{ messages.length }}</template>
</span>
<select v-model="displayEncoding" class="h-6 rounded border bg-transparent px-1.5 text-[11px] text-muted-foreground outline-none">
<option v-for="enc in PAYLOAD_ENCODINGS" :key="enc" :value="enc">{{ PAYLOAD_ENCODING_LABELS[enc] }}</option>
</select>
@@ -355,8 +376,9 @@ onUnmounted(stopPolling);
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-auto">
<div v-if="messages.length === 0" class="p-4 text-center text-xs text-muted-foreground">{{ t("connection.mqttNoMessages") }}</div>
<div v-else-if="filteredMessages.length === 0" data-testid="mqtt-no-matching-messages" class="p-4 text-center text-xs text-muted-foreground">{{ t("connection.mqttNoMatchingMessages") }}</div>
<div
v-for="(msg, i) in messages"
v-for="(msg, i) in filteredMessages"
:key="i"
class="w-full cursor-pointer border-b px-3 py-2 text-xs"
:class="
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n, { loadLocaleMessages } from "@/i18n";
import MqttAdminConsole from "@/components/mqtt/MqttAdminConsole.vue";
import type { MqttBrokerInfo, MqttMessage } from "@/types/mqtt";
const { mqttGetMessagesMock } = vi.hoisted(() => ({
mqttGetMessagesMock: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
mqttGetBrokerInfo: vi.fn(
async (): Promise<MqttBrokerInfo> => ({
brokerUrl: "mqtt://localhost:1883",
clientId: "dbx-test",
connected: true,
protocolVersion: "5.0",
subscriptionCount: 1,
}),
),
mqttListTopics: vi.fn(async () => [["device/status", "atmostonce"]]),
mqttListSavedTopicConfigs: vi.fn(async () => [{ topic: "device/status", qos: "atmostonce", enabled: true, noLocal: false }]),
mqttGetMessages: mqttGetMessagesMock,
mqttSubscribe: vi.fn(),
mqttUnsubscribe: vi.fn(),
mqttSaveTopicConfig: vi.fn(),
mqttDeleteTopicConfig: vi.fn(),
mqttClearMessages: vi.fn(),
}));
const mountedApps: App[] = [];
function messageAt(payload: string, topic = "device/status", payloadText: string | null | undefined = payload): MqttMessage {
return {
topic,
payloadBase64: btoa(payload),
payloadText,
qos: 0,
retain: false,
receivedAtMs: Date.now(),
direction: "received",
};
}
async function mountConsole() {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(MqttAdminConsole, {
connectionId: "mqtt-connection-1",
initialTopic: "device/status",
});
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return container;
}
async function settleConsole() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
async function setPayloadSearch(container: HTMLElement, value: string) {
const input = container.querySelector<HTMLInputElement>('[data-testid="mqtt-payload-search"]');
expect(input).not.toBeNull();
input!.value = value;
input!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
}
beforeEach(async () => {
mqttGetMessagesMock.mockReset().mockResolvedValue([]);
await loadLocaleMessages("zh-CN");
i18n.global.locale.value = "zh-CN";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("MQTT 控制台 payload 搜索 (issue #8183)", () => {
it("搜索为空时显示所有已加载消息", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("payload-alpha"), messageAt("payload-beta")]);
const container = await mountConsole();
await settleConsole();
expect(container.textContent).toContain("payload-alpha");
expect(container.textContent).toContain("payload-beta");
expect(container.querySelector('[data-testid="mqtt-message-count"]')?.textContent?.trim()).toBe("2");
});
it("输入 payload 关键字后只显示匹配消息,不搜索 Topic", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("sensor-temperature-25", "sensor/temperature"), messageAt("sensor-humidity-60")]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, "humidity");
expect(container.textContent).toContain("sensor-humidity-60");
expect(container.textContent).not.toContain("sensor-temperature-25");
expect(container.textContent).not.toContain("sensor/temperature");
});
it("payload 搜索大小写不敏感并忽略首尾空格", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("Device ERROR Timeout"), messageAt("Device healthy")]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, " error ");
expect(container.textContent).toContain("Device ERROR Timeout");
expect(container.textContent).not.toContain("Device healthy");
expect(container.querySelector('[data-testid="mqtt-message-count"]')?.textContent?.trim()).toBe("1 / 2");
});
it("清空搜索后恢复完整消息列表", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("payload-alpha"), messageAt("payload-beta")]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, "alpha");
expect(container.textContent).not.toContain("payload-beta");
await setPayloadSearch(container, "");
expect(container.textContent).toContain("payload-alpha");
expect(container.textContent).toContain("payload-beta");
expect(container.querySelector('[data-testid="mqtt-message-count"]')?.textContent?.trim()).toBe("2");
});
it("没有匹配的 payload 时显示专用空状态", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("payload-alpha")]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, "missing");
expect(container.querySelector('[data-testid="mqtt-no-matching-messages"]')).not.toBeNull();
expect(container.textContent).not.toContain("payload-alpha");
expect(container.querySelector('[data-testid="mqtt-message-count"]')?.textContent?.trim()).toBe("0 / 1");
});
it("搜索只进行本地过滤,不触发额外的消息请求", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("payload-alpha"), messageAt("payload-beta")]);
const container = await mountConsole();
await settleConsole();
const callsAfterLoad = mqttGetMessagesMock.mock.calls.length;
await setPayloadSearch(container, "beta");
expect(mqttGetMessagesMock).toHaveBeenCalledTimes(callsAfterLoad);
});
it("payloadText 缺失时仍使用 plaintext fallback 搜索", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("fallback-error", "device/status", null)]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, "error");
expect(container.textContent).toContain("fallback-error");
expect(container.querySelector('[data-testid="mqtt-no-matching-messages"]')).toBeNull();
});
it("轮询更新消息后继续应用 payload 搜索", async () => {
mqttGetMessagesMock.mockResolvedValueOnce([messageAt("initial-error"), messageAt("initial-ok")]).mockResolvedValueOnce([messageAt("next-error"), messageAt("next-ok")]);
const container = await mountConsole();
await settleConsole();
await setPayloadSearch(container, "error");
await vi.advanceTimersByTimeAsync(3000);
await nextTick();
expect(container.textContent).toContain("next-error");
expect(container.textContent).not.toContain("next-ok");
});
});