feat(rocketmq): improve connections, topics, and message queries
This commit is contained in:
22 files changed
+1166
-407
No files matched your search
@@ -158,20 +158,25 @@ async function runQuery() {
|
||||
if (!id) throw new Error(t("mqMessages.msgIdRequired"));
|
||||
const result = await mqViewMessage(props.connectionId, topic, id);
|
||||
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
||||
} else if (activeQueryMode.value === "key") {
|
||||
const key = queryKey.value.trim();
|
||||
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
|
||||
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
|
||||
// Do not validate hidden Topic-mode time fields — Key uses a fixed 0..now window.
|
||||
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
|
||||
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
||||
} else {
|
||||
const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : 0;
|
||||
const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.now();
|
||||
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32));
|
||||
if (activeQueryMode.value === "key") {
|
||||
const key = queryKey.value.trim();
|
||||
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
|
||||
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
|
||||
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
|
||||
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
||||
} else {
|
||||
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
|
||||
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
||||
const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : Date.parse(defaultTimeRange().begin);
|
||||
const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.parse(defaultTimeRange().end);
|
||||
if (!Number.isFinite(begin) || !Number.isFinite(end)) {
|
||||
throw new Error(t("mqMessages.invalidTimeRange"));
|
||||
}
|
||||
if (begin >= end) {
|
||||
throw new Error(t("mqMessages.endTimeMustBeAfterBegin"));
|
||||
}
|
||||
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32));
|
||||
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
|
||||
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
queryError.value = formatError(e);
|
||||
@@ -401,20 +406,22 @@ watch(topicName, () => {
|
||||
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
|
||||
</label>
|
||||
|
||||
<label v-else-if="activeQueryMode === 'topic'" class="filter-field">
|
||||
<span>{{ t("mqMessages.beginTime") }}</span>
|
||||
<input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" />
|
||||
</label>
|
||||
<template v-else-if="activeQueryMode === 'topic'">
|
||||
<label class="filter-field">
|
||||
<span>{{ t("mqMessages.beginTime") }}</span>
|
||||
<input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" />
|
||||
</label>
|
||||
|
||||
<label v-else-if="activeQueryMode === 'topic'" class="filter-field">
|
||||
<span>{{ t("mqMessages.endTime") }}</span>
|
||||
<input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" />
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span>{{ t("mqMessages.endTime") }}</span>
|
||||
<input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" />
|
||||
</label>
|
||||
|
||||
<label v-else-if="activeQueryMode === 'topic'" class="filter-field filter-narrow">
|
||||
<span>{{ t("mqMessages.maxNum") }}</span>
|
||||
<input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" />
|
||||
</label>
|
||||
<label class="filter-field filter-narrow">
|
||||
<span>{{ t("mqMessages.maxNum") }}</span>
|
||||
<input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="btn-primary" :disabled="queryLoading || !selectedTopicRef" @click="runQuery">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
|
||||
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
|
||||
import type { ClusterInfo } from "@/types/mq";
|
||||
@@ -14,6 +16,13 @@ import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
const TOPIC_ROW_HEIGHT = 44;
|
||||
|
||||
type VirtualTopicRow = {
|
||||
id: string;
|
||||
topic: TopicInfo;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
@@ -110,6 +119,38 @@ const filteredTopics = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Stable ids for RecycleScroller; avoids mounting all topic rows at once. */
|
||||
const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
|
||||
filteredTopics.value.map((topic) => ({
|
||||
id: showNamespaceColumn.value ? `${topic.namespace ?? ""}:${topic.name}` : topic.name,
|
||||
topic,
|
||||
})),
|
||||
);
|
||||
|
||||
const topicsGridTemplate = computed(() => {
|
||||
const cols: string[] = ["minmax(180px, 1.6fr)"];
|
||||
if (showNamespaceColumn.value) cols.push("minmax(100px, 0.8fr)");
|
||||
cols.push("120px");
|
||||
if (!isRocketMqCluster.value) cols.push("140px");
|
||||
// RocketMQ keeps tiled row actions (status/route/consumers/…) — reserve a wider actions column.
|
||||
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(200px, 1fr)");
|
||||
return cols.join(" ");
|
||||
});
|
||||
|
||||
/** Min content width from grid track mins + gaps + padding; enables shared horizontal scroll. */
|
||||
const topicsTableMinWidthPx = computed(() => {
|
||||
let min = 180 + 120; // name + type
|
||||
if (showNamespaceColumn.value) min += 100;
|
||||
if (!isRocketMqCluster.value) min += 140;
|
||||
min += isRocketMqCluster.value ? 560 : 200;
|
||||
let colCount = 3;
|
||||
if (showNamespaceColumn.value) colCount += 1;
|
||||
if (!isRocketMqCluster.value) colCount += 1;
|
||||
min += (colCount - 1) * 8; // column-gap
|
||||
min += 24; // horizontal padding
|
||||
return min;
|
||||
});
|
||||
|
||||
const userTopicCount = computed(() => {
|
||||
if (isRocketMqCluster.value) {
|
||||
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length;
|
||||
@@ -117,6 +158,10 @@ const userTopicCount = computed(() => {
|
||||
return topics.value.filter((topic) => !topic.internal).length;
|
||||
});
|
||||
|
||||
function topicRowSelected(topic: TopicInfo): boolean {
|
||||
return selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace;
|
||||
}
|
||||
|
||||
function topicTypeLabel(topic: TopicInfo): string {
|
||||
if (isRocketMqCluster.value) {
|
||||
const type = resolveRocketMqMessageType(topic);
|
||||
@@ -470,61 +515,68 @@ watch(newPartitions, () => {
|
||||
</div>
|
||||
|
||||
<div v-else class="topics-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqTopics.name") }}</th>
|
||||
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
|
||||
<th>{{ t("mqTopics.type") }}</th>
|
||||
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
|
||||
<th>{{ t("mqTopics.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="topic in filteredTopics" :key="showNamespaceColumn ? `${topic.namespace ?? ''}:${topic.name}` : topic.name" :class="{ selected: selectedTopic?.name === topic.name && selectedTopic?.namespace === topic.namespace }" @click="selectTopic(topic)">
|
||||
<td class="topic-name">
|
||||
<div class="topic-name-cell">
|
||||
<span>{{ topic.shortName }}</span>
|
||||
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||||
<!-- Shared horizontal scroller keeps header/body columns aligned when the grid min-width exceeds the panel. -->
|
||||
<div class="topics-table-hscroll" :style="{ '--topics-table-min-width': `${topicsTableMinWidthPx}px` }">
|
||||
<div class="topics-table-header" :style="{ gridTemplateColumns: topicsGridTemplate }">
|
||||
<div class="topics-col">{{ t("mqTopics.name") }}</div>
|
||||
<div v-if="showNamespaceColumn" class="topics-col">{{ t("mqAdmin.namespace") }}</div>
|
||||
<div class="topics-col">{{ t("mqTopics.type") }}</div>
|
||||
<div v-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
|
||||
<div class="topics-col">{{ t("mqTopics.actions") }}</div>
|
||||
</div>
|
||||
<RecycleScroller class="topics-scroller" :items="virtualTopicRows" :item-size="TOPIC_ROW_HEIGHT" :buffer="200" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div class="topics-row" :class="{ selected: topicRowSelected(row.topic) }" :style="{ gridTemplateColumns: topicsGridTemplate, height: `${TOPIC_ROW_HEIGHT}px` }" @click="selectTopic(row.topic)">
|
||||
<div class="topics-col topic-name">
|
||||
<div class="topic-name-cell">
|
||||
<span class="topic-name-text" :title="row.topic.shortName">{{ row.topic.shortName }}</span>
|
||||
<span v-if="!row.topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="showNamespaceColumn">{{ topic.namespace || "-" }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="topicTypeBadgeClass(topic)">
|
||||
{{ topicTypeLabel(topic) }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isRocketMqCluster">
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="actions" @click.stop>
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
|
||||
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
|
||||
<template v-else>
|
||||
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
|
||||
<div v-if="showNamespaceColumn" class="topics-col">{{ row.topic.namespace || "-" }}</div>
|
||||
<div class="topics-col">
|
||||
<span class="badge" :class="topicTypeBadgeClass(row.topic)">
|
||||
{{ topicTypeLabel(row.topic) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!isRocketMqCluster" class="topics-col">
|
||||
<span v-if="row.topic.partitioned">{{ row.topic.partitions ? t("mqTopics.partitionCount", { count: row.topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</div>
|
||||
<div class="topics-col actions" @click.stop>
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button type="button" class="btn-sm" @click="openRocketMqDialog('status', row.topic)">{{ t("mqTopics.actionStatus") }}</button>
|
||||
<button type="button" class="btn-sm" @click="openRocketMqDialog('route', row.topic)">{{ t("mqTopics.actionRoute") }}</button>
|
||||
<button type="button" class="btn-sm" @click="openRocketMqDialog('consumers', row.topic)">{{ t("mqTopics.actionConsumers") }}</button>
|
||||
<button v-if="isDlqTopic(row.topic)" type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
|
||||
<template v-else>
|
||||
<button type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
|
||||
<button type="button" class="btn-sm" :disabled="readOnly" @click="navigateToMessages(row.topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
|
||||
</template>
|
||||
<button type="button" class="btn-sm" @click="openRocketMqDialog('config', row.topic)">{{ t("mqTopics.actionConfig") }}</button>
|
||||
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('reset', row.topic)">{{ t("mqTopics.actionReset") }}</button>
|
||||
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('skip', row.topic)">{{ t("mqTopics.actionSkip") }}</button>
|
||||
<button type="button" class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(row.topic)" @click="handleDelete(row.topic)">{{ t("mqTopics.delete") }}</button>
|
||||
</template>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
|
||||
{{ t("mqTopics.adjustPartitions") }}
|
||||
</button>
|
||||
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic) || (showNamespaceColumn && !topic.namespace)" :title="showNamespaceColumn && !topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" class="btn-sm btn-danger">
|
||||
{{ t("mqTopics.delete") }}
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-else>
|
||||
<button v-if="row.topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(row.topic)" type="button" @click="openPartitionsDialog(row.topic)" :disabled="readOnly || !row.topic.partitions" class="btn-sm">
|
||||
{{ t("mqTopics.adjustPartitions") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleDelete(row.topic)"
|
||||
:disabled="readOnly || isTopicProtected(row.topic) || (showNamespaceColumn && !row.topic.namespace)"
|
||||
:title="showNamespaceColumn && !row.topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined"
|
||||
class="btn-sm btn-danger"
|
||||
>
|
||||
{{ t("mqTopics.delete") }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
@@ -809,87 +861,94 @@ watch(newPartitions, () => {
|
||||
.topics-table {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--topics-surface);
|
||||
}
|
||||
|
||||
.topics-table::before {
|
||||
content: "";
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: block;
|
||||
.topics-table-hscroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.topics-table-header,
|
||||
.topics-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
padding: 0 12px;
|
||||
min-width: var(--topics-table-min-width, 100%);
|
||||
}
|
||||
|
||||
.topics-table-header {
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
margin-bottom: -38px;
|
||||
background: var(--topics-header-bg);
|
||||
z-index: 9;
|
||||
box-shadow:
|
||||
0 1px 0 var(--topics-border),
|
||||
0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
table {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--topics-header-bg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
/* Match RecycleScroller classic-scrollbar gutter so header/body columns stay aligned. */
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
background: var(--topics-header-bg);
|
||||
border-bottom: 1px solid var(--topics-border);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 0 var(--topics-border),
|
||||
0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
.topics-table-header .topics-col {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.topics-scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
min-width: var(--topics-table-min-width, 100%);
|
||||
/* RecycleScroller owns overflow-y; reserve gutter for classic scrollbars (Windows). */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.topics-row {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
tbody tr.selected td {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--topics-border-light);
|
||||
background: var(--topics-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topics-row:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.topics-row.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.topics-col {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topic-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topic-name {
|
||||
@@ -927,9 +986,11 @@ td {
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
min-width: 420px;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-row-inline {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqListTopics: vi.fn(),
|
||||
mqQueryMessagesByKey: vi.fn(),
|
||||
mqQueryMessagesByTopic: vi.fn(),
|
||||
mqViewMessage: vi.fn(),
|
||||
mqPeekMessages: vi.fn(),
|
||||
mqSendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
mqListTopics: backend.mqListTopics,
|
||||
mqQueryMessagesByKey: backend.mqQueryMessagesByKey,
|
||||
mqQueryMessagesByTopic: backend.mqQueryMessagesByTopic,
|
||||
mqViewMessage: backend.mqViewMessage,
|
||||
mqPeekMessages: backend.mqPeekMessages,
|
||||
mqSendMessage: backend.mqSendMessage,
|
||||
}));
|
||||
|
||||
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
|
||||
|
||||
const TOPIC = {
|
||||
name: "Orders",
|
||||
shortName: "Orders",
|
||||
partitioned: true,
|
||||
persistent: true,
|
||||
messageType: "NORMAL",
|
||||
};
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function buttonByExactText(container: ParentNode, text: string): HTMLButtonElement {
|
||||
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === text);
|
||||
if (!button) throw new Error(`Button not found: ${text}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function mountPanel() {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(MessageQueryPanel, {
|
||||
connectionId: "mq-1",
|
||||
tenant: "_rocketmq",
|
||||
namespace: "default",
|
||||
topic: TOPIC,
|
||||
mqSystemKind: "rocketmq",
|
||||
embedded: true,
|
||||
});
|
||||
app.config.globalProperties.$t = (key: string) => key;
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
backend.mqListTopics.mockReset();
|
||||
backend.mqQueryMessagesByKey.mockReset();
|
||||
backend.mqQueryMessagesByTopic.mockReset();
|
||||
backend.mqViewMessage.mockReset();
|
||||
backend.mqPeekMessages.mockReset();
|
||||
backend.mqSendMessage.mockReset();
|
||||
backend.mqListTopics.mockResolvedValue([TOPIC]);
|
||||
backend.mqQueryMessagesByKey.mockResolvedValue({ messages: [] });
|
||||
backend.mqQueryMessagesByTopic.mockResolvedValue({ messages: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
});
|
||||
|
||||
async function waitForQueryEnabled(panel: HTMLElement) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const queryButton = buttonByExactText(panel, "mqMessages.query");
|
||||
if (!queryButton.disabled) return queryButton;
|
||||
await flushUi();
|
||||
}
|
||||
throw new Error("Query button stayed disabled (topic not selected)");
|
||||
}
|
||||
|
||||
describe("MessageQueryPanel Key mode time validation", () => {
|
||||
it("does not block Key query with a stale invalid Topic time range", async () => {
|
||||
const panel = await mountPanel();
|
||||
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBeGreaterThan(0);
|
||||
await flushUi();
|
||||
|
||||
const beginInput = panel.querySelector<HTMLInputElement>('input[type="datetime-local"]');
|
||||
const endInputs = panel.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]');
|
||||
if (!beginInput || endInputs.length < 2) throw new Error("Topic time inputs not found");
|
||||
// Invalid range: begin after end (hidden after switching to Key).
|
||||
await setInputValue(beginInput, "2026-08-03T18:00");
|
||||
await setInputValue(endInputs[1], "2026-08-03T10:00");
|
||||
|
||||
buttonByExactText(panel, "mqMessages.queryTabKey").click();
|
||||
await flushUi();
|
||||
|
||||
const keyInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.queryKeyPlaceholder"]');
|
||||
if (!keyInput) throw new Error("Key input not found");
|
||||
await setInputValue(keyInput, "order-1");
|
||||
|
||||
const queryButton = await waitForQueryEnabled(panel);
|
||||
queryButton.click();
|
||||
await flushUi();
|
||||
await expect.poll(() => backend.mqQueryMessagesByKey.mock.calls.length).toBe(1);
|
||||
|
||||
expect(panel.textContent).not.toContain("mqMessages.invalidTimeRange");
|
||||
expect(panel.textContent).not.toContain("mqMessages.endTimeMustBeAfterBegin");
|
||||
expect(backend.mqQueryMessagesByTopic).not.toHaveBeenCalled();
|
||||
const [, topicRef, key, begin, end, maxNum] = backend.mqQueryMessagesByKey.mock.calls[0];
|
||||
expect(topicRef).toMatchObject({ topic: "Orders" });
|
||||
expect(key).toBe("order-1");
|
||||
expect(begin).toBe(0);
|
||||
expect(typeof end).toBe("number");
|
||||
expect(end).toBeGreaterThan(0);
|
||||
expect(maxNum).toBe(64);
|
||||
});
|
||||
});
|
||||
@@ -6052,6 +6052,8 @@ export default {
|
||||
beginTime: "Begin time",
|
||||
endTime: "End time",
|
||||
maxNum: "Max messages",
|
||||
invalidTimeRange: "Begin time or end time is invalid",
|
||||
endTimeMustBeAfterBegin: "End time must be later than begin time",
|
||||
query: "Query",
|
||||
querying: "Querying...",
|
||||
messageOverview: "Overview",
|
||||
|
||||
@@ -5910,6 +5910,8 @@ export default withEnglishFallback({
|
||||
beginTime: "Hora de inicio",
|
||||
endTime: "Hora de fin",
|
||||
maxNum: "Número máximo",
|
||||
invalidTimeRange: "Hora de inicio o fin no válida",
|
||||
endTimeMustBeAfterBegin: "La hora de fin debe ser posterior a la hora de inicio",
|
||||
query: "Consultar",
|
||||
querying: "Consultando...",
|
||||
messageOverview: "Información básica",
|
||||
|
||||
@@ -5910,6 +5910,8 @@ export default withEnglishFallback({
|
||||
beginTime: "Ora inizio",
|
||||
endTime: "Ora fine",
|
||||
maxNum: "Numero massimo",
|
||||
invalidTimeRange: "Ora di inizio o fine non valida",
|
||||
endTimeMustBeAfterBegin: "L'ora di fine deve essere successiva all'ora di inizio",
|
||||
query: "Cerca",
|
||||
querying: "Ricerca in corso...",
|
||||
messageOverview: "Informazioni di base",
|
||||
|
||||
@@ -5965,6 +5965,8 @@ export default withEnglishFallback({
|
||||
beginTime: "開始時間",
|
||||
endTime: "終了時間",
|
||||
maxNum: "最大件数",
|
||||
invalidTimeRange: "開始時刻または終了時刻が無効です",
|
||||
endTimeMustBeAfterBegin: "終了時刻は開始時刻より後である必要があります",
|
||||
query: "検索",
|
||||
querying: "検索中...",
|
||||
messageOverview: "基本情報",
|
||||
|
||||
@@ -5644,6 +5644,8 @@ export default withEnglishFallback({
|
||||
beginTime: "시작 시간",
|
||||
endTime: "종료 시간",
|
||||
maxNum: "최대 메시지 수",
|
||||
invalidTimeRange: "시작 시간 또는 종료 시간이 유효하지 않습니다",
|
||||
endTimeMustBeAfterBegin: "종료 시간은 시작 시간보다 늦어야 합니다",
|
||||
query: "쿼리",
|
||||
querying: "쿼리 중...",
|
||||
messageOverview: "개요",
|
||||
|
||||
@@ -5912,6 +5912,8 @@ export default withEnglishFallback({
|
||||
beginTime: "Hora inicial",
|
||||
endTime: "Hora final",
|
||||
maxNum: "Número máximo",
|
||||
invalidTimeRange: "Hora inicial ou final inválida",
|
||||
endTimeMustBeAfterBegin: "A hora final deve ser posterior à hora inicial",
|
||||
query: "Pesquisar",
|
||||
querying: "Pesquisando...",
|
||||
messageOverview: "Informações básicas",
|
||||
|
||||
@@ -6051,6 +6051,8 @@ export default withEnglishFallback({
|
||||
beginTime: "开始时间",
|
||||
endTime: "结束时间",
|
||||
maxNum: "最大条数",
|
||||
invalidTimeRange: "开始时间或结束时间无效",
|
||||
endTimeMustBeAfterBegin: "结束时间必须晚于开始时间",
|
||||
query: "查询",
|
||||
querying: "查询中...",
|
||||
messageOverview: "基本信息",
|
||||
|
||||
@@ -5905,6 +5905,8 @@ export default withEnglishFallback({
|
||||
beginTime: "開始時間",
|
||||
endTime: "結束時間",
|
||||
maxNum: "最大條數",
|
||||
invalidTimeRange: "開始時間或結束時間無效",
|
||||
endTimeMustBeAfterBegin: "結束時間必須晚於開始時間",
|
||||
query: "查詢",
|
||||
querying: "查詢中...",
|
||||
messageOverview: "基本資訊",
|
||||
|
||||
Reference in new issue
Block a user