feat(rocketmq): improve connections, topics, and message queries

This commit is contained in:
AiLi authored and GitHub committed 2026-08-04 01:38:29 +08:00
1 parent 0ece86c478
commit ce7b8c7b74
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">
+177 -116
View File
@@ -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);
});
});
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -5965,6 +5965,8 @@ export default withEnglishFallback({
beginTime: "開始時間",
endTime: "終了時間",
maxNum: "最大件数",
invalidTimeRange: "開始時刻または終了時刻が無効です",
endTimeMustBeAfterBegin: "終了時刻は開始時刻より後である必要があります",
query: "検索",
querying: "検索中...",
messageOverview: "基本情報",
+2
View File
@@ -5644,6 +5644,8 @@ export default withEnglishFallback({
beginTime: "시작 시간",
endTime: "종료 시간",
maxNum: "최대 메시지 수",
invalidTimeRange: "시작 시간 또는 종료 시간이 유효하지 않습니다",
endTimeMustBeAfterBegin: "종료 시간은 시작 시간보다 늦어야 합니다",
query: "쿼리",
querying: "쿼리 중...",
messageOverview: "개요",
+2
View File
@@ -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",
+2
View File
@@ -6051,6 +6051,8 @@ export default withEnglishFallback({
beginTime: "开始时间",
endTime: "结束时间",
maxNum: "最大条数",
invalidTimeRange: "开始时间或结束时间无效",
endTimeMustBeAfterBegin: "结束时间必须晚于开始时间",
query: "查询",
querying: "查询中...",
messageOverview: "基本信息",
+2
View File
@@ -5905,6 +5905,8 @@ export default withEnglishFallback({
beginTime: "開始時間",
endTime: "結束時間",
maxNum: "最大條數",
invalidTimeRange: "開始時間或結束時間無效",
endTimeMustBeAfterBegin: "結束時間必須晚於開始時間",
query: "查詢",
querying: "查詢中...",
messageOverview: "基本資訊",