From 53be33c86cacb2cf2d26bc2c0f264bc41ddca918 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 6 Sep 2026 10:41:47 +0800 Subject: [PATCH] feat(mqtt): add payload search to MQTT console --- .../src/components/mqtt/MqttAdminConsole.vue | 36 +++- .../MqttAdminConsolePayloadSearch.spec.ts | 188 ++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + 11 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadSearch.spec.ts diff --git a/apps/desktop/src/components/mqtt/MqttAdminConsole.vue b/apps/desktop/src/components/mqtt/MqttAdminConsole.vue index fff1ecefa5..ed87660619 100644 --- a/apps/desktop/src/components/mqtt/MqttAdminConsole.vue +++ b/apps/desktop/src/components/mqtt/MqttAdminConsole.vue @@ -30,6 +30,7 @@ const pollingTimer = ref | null>(null); const messagesPaused = ref(false); const displayEncoding = ref("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);
-
- {{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }} - {{ t("connection.mqttAllMessages") }} -
- {{ messages.length }} +
+ {{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }} + {{ t("connection.mqttAllMessages") }} +
+ + + + + @@ -355,8 +376,9 @@ onUnmounted(stopPolling);
{{ t("connection.mqttNoMessages") }}
+
{{ t("connection.mqttNoMatchingMessages") }}
({ + mqttGetMessagesMock: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + mqttGetBrokerInfo: vi.fn( + async (): Promise => ({ + 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('[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"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d49afaba9e..5dbbd17d54 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -901,6 +901,8 @@ export default { mqttNewSubscription: "New subscription", mqttEditSubscription: "Edit subscription", mqttSearchSubscriptionsPlaceholder: "Search subscription filters...", + mqttSearchPayloadPlaceholder: "Search message payload...", + mqttNoMatchingMessages: "No loaded messages match this payload filter", mqttNoSavedSubscriptions: 'No saved subscriptions. Click "New subscription" to add one.', mqttTopicFilter: "Topic Filter", mqttTopicFilterPlaceholder: "e.g. device/+/status", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 12fc58568e..069ed266e6 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1163,6 +1163,8 @@ export default withEnglishFallback({ mqttNewSubscription: "Nueva suscripción", mqttEditSubscription: "Editar suscripción", mqttSearchSubscriptionsPlaceholder: "Buscar filtros de suscripción...", + mqttSearchPayloadPlaceholder: "Buscar en el payload del mensaje...", + mqttNoMatchingMessages: "Ningún mensaje cargado coincide con este filtro de payload", mqttNoSavedSubscriptions: "No hay suscripciones guardadas. Haz clic en «Nueva suscripción» para añadir una.", mqttTopicFilter: "Filtro de Topic", mqttTopicFilterPlaceholder: "p. ej. device/+/status", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 1c71f0a107..a5d47776d7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1161,6 +1161,8 @@ export default withEnglishFallback({ mqttNewSubscription: "Nuova sottoscrizione", mqttEditSubscription: "Modifica sottoscrizione", mqttSearchSubscriptionsPlaceholder: "Cerca filtri di sottoscrizione...", + mqttSearchPayloadPlaceholder: "Cerca nel payload del messaggio...", + mqttNoMatchingMessages: "Nessun messaggio caricato corrisponde a questo filtro del payload", mqttNoSavedSubscriptions: "Nessuna sottoscrizione salvata. Fai clic su «Nuova sottoscrizione» per aggiungerne una.", mqttTopicFilter: "Filtro Topic", mqttTopicFilterPlaceholder: "es. device/+/status", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 49e0fbbed1..55a30b9c67 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1161,6 +1161,8 @@ export default withEnglishFallback({ mqttNewSubscription: "購読を新規作成", mqttEditSubscription: "購読を編集", mqttSearchSubscriptionsPlaceholder: "購読フィルターを検索...", + mqttSearchPayloadPlaceholder: "メッセージのペイロードを検索...", + mqttNoMatchingMessages: "このペイロードフィルターに一致する読み込み済みメッセージはありません", mqttNoSavedSubscriptions: "保存された購読はありません。「購読を新規作成」をクリックしてください。", mqttTopicFilter: "Topic Filter", mqttTopicFilterPlaceholder: "例: device/+/status", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 677c3d7323..6f192cb0a6 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1111,6 +1111,8 @@ export default withEnglishFallback({ cancelConnecting: "연결 취소", connectCancelled: "연결이 취소되었습니다", sshTotpCancelled: "일회용 코드 입력이 취소되었습니다", + mqttSearchPayloadPlaceholder: "메시지 Payload 검색...", + mqttNoMatchingMessages: "현재 Payload 필터 조건과 일치하는 로드된 메시지가 없습니다.", }, editor: { duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c8a6a8e22d..f6ac8459e2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1162,6 +1162,8 @@ export default withEnglishFallback({ mqttNewSubscription: "Nova inscrição", mqttEditSubscription: "Editar inscrição", mqttSearchSubscriptionsPlaceholder: "Pesquisar filtros de inscrição...", + mqttSearchPayloadPlaceholder: "Pesquisar no payload da mensagem...", + mqttNoMatchingMessages: "Nenhuma mensagem carregada corresponde a este filtro de payload", mqttNoSavedSubscriptions: "Nenhuma inscrição salva. Clique em «Nova inscrição» para adicionar uma.", mqttTopicFilter: "Filtro de Topic", mqttTopicFilterPlaceholder: "ex.: device/+/status", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 0ac7a4f700..612839fb30 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -902,6 +902,8 @@ export default withEnglishFallback({ mqttNewSubscription: "Yeni abonelik", mqttEditSubscription: "Aboneliği düzenle", mqttSearchSubscriptionsPlaceholder: "Abonelik filtrelerinde ara...", + mqttSearchPayloadPlaceholder: "Mesaj payload'unda ara...", + mqttNoMatchingMessages: "Bu payload filtresiyle eşleşen yüklenmiş mesaj yok", mqttNoSavedSubscriptions: 'Kayıtlı abonelik yok. Eklemek için "Yeni abonelik" düğmesine tıklayın.', mqttTopicFilter: "Konu Filtresi", mqttTopicFilterPlaceholder: "örn. device/+/status", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6c56881be5..97008f9158 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -825,6 +825,8 @@ export default withEnglishFallback({ mqttNewSubscription: "新建订阅", mqttEditSubscription: "编辑订阅", mqttSearchSubscriptionsPlaceholder: "搜索订阅过滤器...", + mqttSearchPayloadPlaceholder: "搜索消息 Payload...", + mqttNoMatchingMessages: "没有已加载消息符合当前 Payload 筛选条件", mqttNoSavedSubscriptions: "暂无订阅配置,请点击“新建订阅”添加。", mqttTopicFilter: "Topic Filter", mqttTopicFilterPlaceholder: "例如 device/+/status", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 56e8ebd014..03fe9e6ecc 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1163,6 +1163,8 @@ export default withEnglishFallback({ mqttNewSubscription: "新增訂閱", mqttEditSubscription: "編輯訂閱", mqttSearchSubscriptionsPlaceholder: "搜尋訂閱篩選器...", + mqttSearchPayloadPlaceholder: "搜尋訊息 Payload...", + mqttNoMatchingMessages: "沒有已載入訊息符合目前 Payload 篩選條件", mqttNoSavedSubscriptions: "尚無訂閱設定,請點擊「新增訂閱」加入。", mqttTopicFilter: "Topic Filter", mqttTopicFilterPlaceholder: "例如 device/+/status",