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