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");
});
});
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -1161,6 +1161,8 @@ export default withEnglishFallback({
mqttNewSubscription: "購読を新規作成",
mqttEditSubscription: "購読を編集",
mqttSearchSubscriptionsPlaceholder: "購読フィルターを検索...",
mqttSearchPayloadPlaceholder: "メッセージのペイロードを検索...",
mqttNoMatchingMessages: "このペイロードフィルターに一致する読み込み済みメッセージはありません",
mqttNoSavedSubscriptions: "保存された購読はありません。「購読を新規作成」をクリックしてください。",
mqttTopicFilter: "Topic Filter",
mqttTopicFilterPlaceholder: "例: device/+/status",
+2
View File
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
cancelConnecting: "연결 취소",
connectCancelled: "연결이 취소되었습니다",
sshTotpCancelled: "일회용 코드 입력이 취소되었습니다",
mqttSearchPayloadPlaceholder: "메시지 Payload 검색...",
mqttNoMatchingMessages: "현재 Payload 필터 조건과 일치하는 로드된 메시지가 없습니다.",
},
editor: {
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -825,6 +825,8 @@ export default withEnglishFallback({
mqttNewSubscription: "新建订阅",
mqttEditSubscription: "编辑订阅",
mqttSearchSubscriptionsPlaceholder: "搜索订阅过滤器...",
mqttSearchPayloadPlaceholder: "搜索消息 Payload...",
mqttNoMatchingMessages: "没有已加载消息符合当前 Payload 筛选条件",
mqttNoSavedSubscriptions: "暂无订阅配置,请点击“新建订阅”添加。",
mqttTopicFilter: "Topic Filter",
mqttTopicFilterPlaceholder: "例如 device/+/status",
+2
View File
@@ -1163,6 +1163,8 @@ export default withEnglishFallback({
mqttNewSubscription: "新增訂閱",
mqttEditSubscription: "編輯訂閱",
mqttSearchSubscriptionsPlaceholder: "搜尋訂閱篩選器...",
mqttSearchPayloadPlaceholder: "搜尋訊息 Payload...",
mqttNoMatchingMessages: "沒有已載入訊息符合目前 Payload 篩選條件",
mqttNoSavedSubscriptions: "尚無訂閱設定,請點擊「新增訂閱」加入。",
mqttTopicFilter: "Topic Filter",
mqttTopicFilterPlaceholder: "例如 device/+/status",