fix(kafka): improve message browsing and topic navigation
This commit is contained in:
1 parent
f6518ff3a0
commit
e77b547cea
24 files changed
+751
-114
No files matched your search
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { TopicInfo, TopicRef, TopicStats } from "@/types/mq";
|
||||
import { mqGetTopicStats } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { extractKafkaPartitionRows } from "@/lib/mq/kafkaTopicStats";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
import MessageBrowser from "./MessageBrowser.vue";
|
||||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
topic?: TopicInfo;
|
||||
readOnly?: boolean;
|
||||
canSendMessage?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
topicSelected: [topic: TopicInfo | undefined];
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
|
||||
const topics = ref<TopicInfo[]>([]);
|
||||
const topicName = ref(props.topic?.shortName ?? "");
|
||||
const stats = ref<TopicStats>();
|
||||
const statsLoading = ref(false);
|
||||
const statsError = ref<string>();
|
||||
let statsRequestVersion = 0;
|
||||
|
||||
const selectedTopic = computed(() => topics.value.find((item) => item.shortName === topicName.value));
|
||||
const selectedTopicRef = computed<TopicRef | null>(() => {
|
||||
const name = topicName.value.trim();
|
||||
if (!name || !props.tenant || !props.namespace) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: name,
|
||||
persistent: selectedTopic.value?.persistent ?? true,
|
||||
partitioned: selectedTopic.value?.partitioned,
|
||||
};
|
||||
});
|
||||
const partitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
|
||||
|
||||
function handleTopicsLoaded(loaded: TopicInfo[]) {
|
||||
topics.value = loaded;
|
||||
if (topicName.value && loaded.some((item) => item.shortName === topicName.value)) {
|
||||
return;
|
||||
}
|
||||
if (props.topic && loaded.some((item) => item.shortName === props.topic?.shortName)) {
|
||||
topicName.value = props.topic.shortName;
|
||||
} else if (loaded.length === 1) {
|
||||
topicName.value = loaded[0].shortName;
|
||||
} else {
|
||||
topicName.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const topic = selectedTopicRef.value;
|
||||
const requestVersion = ++statsRequestVersion;
|
||||
stats.value = undefined;
|
||||
statsError.value = undefined;
|
||||
if (!topic) return;
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const result = await mqGetTopicStats(props.connectionId, topic);
|
||||
if (requestVersion === statsRequestVersion) stats.value = result;
|
||||
} catch (cause: unknown) {
|
||||
if (requestVersion === statsRequestVersion) statsError.value = formatError(cause);
|
||||
} finally {
|
||||
if (requestVersion === statsRequestVersion) statsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.connectionId, () => props.tenant, () => props.namespace, topicName], () => void loadStats(), { immediate: true });
|
||||
watch([() => props.connectionId, () => props.tenant, () => props.namespace], () => {
|
||||
topics.value = [];
|
||||
topicName.value = props.topic?.shortName ?? "";
|
||||
});
|
||||
watch(
|
||||
() => props.topic?.shortName,
|
||||
(name) => {
|
||||
if (name) topicName.value = name;
|
||||
},
|
||||
);
|
||||
watch(selectedTopic, (topic) => emit("topicSelected", topic));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kafka-messages-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>{{ t("mqMessages.queryTitle") }}</h3>
|
||||
<button type="button" class="btn-secondary" :disabled="!tenant || !namespace" @click="topicSelectRef?.loadTopics()">
|
||||
{{ t("mqMessages.refreshTopicList") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqMessages.selectNamespaceOrTopicFirst") }}</div>
|
||||
<div v-else class="kafka-messages-content">
|
||||
<section class="kafka-topic-section">
|
||||
<label>
|
||||
<span>{{ t("mqMessages.queryTopicLabel") }}</span>
|
||||
<RocketMqTopicSelect ref="topicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :show-type-filter="false" show-hint @loaded="handleTopicsLoaded" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedTopicRef" class="partition-overview" data-testid="kafka-partition-overview">
|
||||
<div class="section-heading">
|
||||
<h4>{{ t("mqMonitoring.kafkaPartitionDetails") }}</h4>
|
||||
<button type="button" class="btn-sm" :disabled="statsLoading" @click="loadStats">{{ t("common.refresh") }}</button>
|
||||
</div>
|
||||
<div v-if="statsError" class="panel-error">{{ statsError }}</div>
|
||||
<div v-else-if="statsLoading" class="section-empty">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="!partitionRows.length" class="section-empty">{{ t("mqMonitoring.noKafkaPartitionMetrics") }}</div>
|
||||
<div v-else class="partition-table-wrap">
|
||||
<table class="partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqMonitoring.tablePartition") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in partitionRows" :key="row.partition">
|
||||
<td>{{ row.partition }}</td>
|
||||
<td>{{ row.beginOffset.toLocaleString() }}</td>
|
||||
<td>{{ row.endOffset.toLocaleString() }}</td>
|
||||
<td>{{ row.messageCount.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MessageBrowser :connection-id="connectionId" :topic="selectedTopicRef" mq-system-kind="kafka" />
|
||||
<SendMessagePanel v-if="canSendMessage && selectedTopic" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="selectedTopic" :read-only="readOnly" mq-system-kind="kafka" is-flat-mq-cluster :supports-peek-messages="false" fixed-topic embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.kafka-messages-panel,
|
||||
.kafka-messages-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kafka-messages-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kafka-topic-section,
|
||||
.partition-overview {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.kafka-topic-section label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-heading h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.partition-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.partition-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.partition-table th,
|
||||
.partition-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partition-table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-empty {
|
||||
padding: 14px;
|
||||
color: var(--color-text-tertiary);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -144,10 +144,19 @@ function formatMessageTimestamp(value?: string): string {
|
||||
return new Date(numeric).toLocaleString();
|
||||
}
|
||||
|
||||
watch([() => props.connectionId, () => props.mqSystemKind, () => JSON.stringify(props.topic ?? null)], () => {
|
||||
watch([() => props.connectionId, () => props.mqSystemKind], () => {
|
||||
invalidateMessageRequest();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => JSON.stringify(props.topic ?? null),
|
||||
() => {
|
||||
partition.value = "";
|
||||
offset.value = "";
|
||||
invalidateMessageRequest();
|
||||
},
|
||||
);
|
||||
|
||||
watch(kafkaStartPosition, () => {
|
||||
// Keep offset values for switching back, but never retain results from another start mode.
|
||||
invalidateMessageRequest();
|
||||
@@ -184,7 +193,7 @@ watch(kafkaStartPosition, () => {
|
||||
<span>{{ t("mqMessages.count") }}</span>
|
||||
<input v-model.number="count" data-testid="peek-count" type="number" min="1" max="100" :disabled="loading" />
|
||||
</label>
|
||||
<label v-if="isKafka">
|
||||
<label v-if="isKafka && !isMonitoring">
|
||||
<span>{{ t("mqMessages.startPosition") }}</span>
|
||||
<Select v-model="kafkaStartPosition" :disabled="loading">
|
||||
<SelectTrigger data-testid="kafka-peek-start-position" class="message-browser-start-position">
|
||||
@@ -201,7 +210,7 @@ watch(kafkaStartPosition, () => {
|
||||
<span>{{ t("mqMessages.partition") }}</span>
|
||||
<input v-model="partition" data-testid="kafka-peek-partition" type="number" min="0" :placeholder="t('mqMessages.partitionPlaceholderAll')" :disabled="loading" />
|
||||
</label>
|
||||
<label v-if="isKafkaOffsetMode">
|
||||
<label v-if="isKafkaOffsetMode && !isMonitoring">
|
||||
<span>{{ t("mqMessages.offset") }}</span>
|
||||
<input v-model="offset" data-testid="kafka-peek-offset" type="number" min="0" :placeholder="t('mqMessages.offsetPlaceholderRequired')" :disabled="loading" />
|
||||
</label>
|
||||
|
||||
@@ -10,6 +10,7 @@ import VChart from "vue-echarts";
|
||||
import { Activity, AlertTriangle, BarChart3, Boxes, CheckCircle2, Database, Download, Gauge, Hash, HardDrive, Layers3, Loader2, Package, RadioTower, RefreshCw, Send, ShieldCheck, Table2, Upload, Users } from "@lucide/vue";
|
||||
import type { MqSystemKind, TopicRef, TopicInfo, TopicStats, BacklogStats } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqGetBacklog } from "@/lib/backend/api";
|
||||
import { extractKafkaPartitionRows, isKafkaStatsPayload, type KafkaPartitionStatsRow } from "@/lib/mq/kafkaTopicStats";
|
||||
import MessageBrowser from "./MessageBrowser.vue";
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
|
||||
@@ -54,17 +55,10 @@ interface RocketMqPartitionStatsRow {
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
interface KafkaPartitionStatsRow {
|
||||
partition: number;
|
||||
beginOffset: number;
|
||||
endOffset: number;
|
||||
messageCount: number;
|
||||
leader: number;
|
||||
replicas: number[];
|
||||
isr: number[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
"navigate-tab": [payload: { tab: "messages"; topic?: TopicInfo }];
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const stats = ref<TopicStats>();
|
||||
@@ -281,26 +275,6 @@ function extractRocketMqPartitionRows(raw: unknown): RocketMqPartitionStatsRow[]
|
||||
.sort((a, b) => a.partition - b.partition || a.brokerName.localeCompare(b.brokerName));
|
||||
}
|
||||
|
||||
function extractKafkaPartitionRows(raw: unknown): KafkaPartitionStatsRow[] {
|
||||
return arrayObjects(objectRecord(raw).partitionStats)
|
||||
.map((body) => ({
|
||||
partition: numberField(body.partition) ?? 0,
|
||||
beginOffset: numberField(body.beginOffset) ?? 0,
|
||||
endOffset: numberField(body.endOffset) ?? 0,
|
||||
messageCount: numberField(body.messageCount) ?? 0,
|
||||
leader: numberField(body.leader) ?? -1,
|
||||
replicas: numberArrayField(body.replicas),
|
||||
isr: numberArrayField(body.isr),
|
||||
}))
|
||||
.sort((a, b) => a.partition - b.partition);
|
||||
}
|
||||
|
||||
function isKafkaStatsPayload(raw: unknown): boolean {
|
||||
if (props.mqSystemKind === "rocketmq") return false;
|
||||
const root = objectRecord(raw);
|
||||
return Array.isArray(root.partitionStats) || (numberField(root.partitions) !== undefined && numberField(root.replicationFactor) !== undefined && numberField(root.totalMessages) !== undefined);
|
||||
}
|
||||
|
||||
function isKafkaPartitionHealthy(row: KafkaPartitionStatsRow): boolean {
|
||||
return row.leader >= 0 && (row.replicas.length === 0 || row.isr.length >= row.replicas.length);
|
||||
}
|
||||
@@ -336,10 +310,6 @@ function numberField(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function numberArrayField(value: unknown): number[] {
|
||||
return Array.isArray(value) ? value.map(numberField).filter((item): item is number => item !== undefined) : [];
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -629,6 +599,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="monitoring-message-heading">
|
||||
<button type="button" class="btn-sm" @click="emit('navigate-tab', { tab: 'messages', topic })">{{ t("mqMessages.queryTitle") }}</button>
|
||||
</div>
|
||||
<MessageBrowser :connection-id="connectionId" :topic="getTopicRef()" mq-system-kind="kafka" appearance="monitoring" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1494,6 +1467,14 @@ onUnmounted(() => {
|
||||
color: var(--monitor-muted);
|
||||
}
|
||||
|
||||
.monitoring-message-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes monitor-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -35,6 +35,7 @@ import MessageTracePanel from "./MessageTracePanel.vue";
|
||||
import RocketMqMessagesPanel from "./RocketMqMessagesPanel.vue";
|
||||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||||
import MessageQueryPanel from "./MessageQueryPanel.vue";
|
||||
import KafkaMessagesPanel from "./KafkaMessagesPanel.vue";
|
||||
import BrokerPanel from "./BrokerPanel.vue";
|
||||
import RabbitMqClientsPanel from "./rabbitmq/RabbitMqClientsPanel.vue";
|
||||
import RabbitMqPermissionsPanel from "./rabbitmq/RabbitMqPermissionsPanel.vue";
|
||||
@@ -103,6 +104,8 @@ const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?
|
||||
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
|
||||
const isRabbitMqCluster = computed(() => mqSystemKind.value === "rabbitmq");
|
||||
const isRocketMqCluster = computed(() => mqSystemKind.value === "rocketmq");
|
||||
const isKafkaCluster = computed(() => mqSystemKind.value === "kafka");
|
||||
const canBrowseKafkaMessages = computed(() => isKafkaCluster.value && canPeekMessages.value);
|
||||
const rocketmqClusterLabel = computed(() => {
|
||||
if (!isRocketMqCluster.value) return undefined;
|
||||
const fromOptions = clusterOptions.value[0];
|
||||
@@ -473,7 +476,7 @@ onMounted(async () => {
|
||||
@subscription-selected="handleSubscriptionSelected"
|
||||
/>
|
||||
<RabbitMqMonitoringPanel v-else-if="activeTab === 'monitoring' && isRabbitMqCluster && canClusterMonitor" :connection-id="connectionId" />
|
||||
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" />
|
||||
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" @navigate-tab="handleNavigateTab" />
|
||||
<RabbitMqClientsPanel v-else-if="activeTab === 'clients' && isRabbitMqCluster && canManageClientConnections" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||||
<ProducerConsumerPanel
|
||||
v-else-if="activeTab === 'clients'"
|
||||
@@ -510,6 +513,16 @@ onMounted(async () => {
|
||||
:prefer-dlq-topic="preferDlqTopic"
|
||||
/>
|
||||
<MessageTracePanel v-else-if="activeTab === 'trace' && isRocketMqCluster && canMessageTrace" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||||
<KafkaMessagesPanel
|
||||
v-else-if="activeTab === 'messages' && canBrowseKafkaMessages"
|
||||
:connection-id="connectionId"
|
||||
:tenant="effectiveTenant"
|
||||
:namespace="effectiveNamespace"
|
||||
:topic="selectedTopic"
|
||||
:read-only="readOnly"
|
||||
:can-send-message="canSendMessage"
|
||||
@topic-selected="handleProducerTopicSelected"
|
||||
/>
|
||||
<MessageQueryPanel v-else-if="activeTab === 'messages' && canMessageQuery && !isRocketMqCluster" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||||
<SendMessagePanel
|
||||
v-else-if="activeTab === 'messages' && canSendMessage && !isRocketMqCluster && !canMessageQuery"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
|
||||
import { extractKafkaPartitionRows } from "@/lib/mq/kafkaTopicStats";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
@@ -41,16 +42,6 @@ interface PartitionClientRow {
|
||||
subscriptions: SubscriptionInfo[];
|
||||
}
|
||||
|
||||
interface KafkaPartitionRow {
|
||||
partition: number;
|
||||
beginOffset: number;
|
||||
endOffset: number;
|
||||
messageCount: number;
|
||||
leader: number;
|
||||
replicas: number[];
|
||||
isr: number[];
|
||||
}
|
||||
|
||||
const producers = ref<ProducerInfo[]>([]);
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const stats = ref<TopicStats>();
|
||||
@@ -437,20 +428,6 @@ function extractPartitionClientRows(raw: unknown): PartitionClientRow[] {
|
||||
});
|
||||
}
|
||||
|
||||
function extractKafkaPartitionRows(raw: unknown): KafkaPartitionRow[] {
|
||||
return arrayObjects(objectRecord(raw).partitionStats)
|
||||
.map((body) => ({
|
||||
partition: numberField(body.partition) ?? 0,
|
||||
beginOffset: numberField(body.beginOffset) ?? 0,
|
||||
endOffset: numberField(body.endOffset) ?? 0,
|
||||
messageCount: numberField(body.messageCount) ?? 0,
|
||||
leader: numberField(body.leader) ?? -1,
|
||||
replicas: numberArrayField(body.replicas),
|
||||
isr: numberArrayField(body.isr),
|
||||
}))
|
||||
.sort((a, b) => a.partition - b.partition);
|
||||
}
|
||||
|
||||
function mergeSubscriptionOptions(base: SubscriptionInfo[], partitions: PartitionClientRow[]): SubscriptionInfo[] {
|
||||
const byName = new Map<string, SubscriptionInfo>();
|
||||
for (const sub of base) {
|
||||
@@ -531,10 +508,6 @@ function numberField(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberArrayField(value: unknown): number[] {
|
||||
return Array.isArray(value) ? value.map(numberField).filter((item): item is number => item !== undefined) : [];
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
mqSystemKind?: MqSystemKind;
|
||||
isFlatMqCluster?: boolean;
|
||||
supportsPeekMessages?: boolean;
|
||||
fixedTopic?: boolean;
|
||||
/** Hide outer toolbar when embedded in a dialog. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
@@ -93,6 +94,11 @@ async function guardWritable() {
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
if (props.fixedTopic) {
|
||||
availableTopics.value = props.topic ? [props.topic] : [];
|
||||
topicName.value = props.topic?.shortName ?? "";
|
||||
return;
|
||||
}
|
||||
if (isRocketMqCluster.value) {
|
||||
await rocketMqTopicSelectRef.value?.loadTopics();
|
||||
return;
|
||||
@@ -227,6 +233,9 @@ watch(
|
||||
watch(
|
||||
() => props.topic,
|
||||
(newTopic) => {
|
||||
if (props.fixedTopic) {
|
||||
availableTopics.value = newTopic ? [newTopic] : [];
|
||||
}
|
||||
if (newTopic) {
|
||||
topicName.value = newTopic.shortName;
|
||||
}
|
||||
@@ -255,7 +264,8 @@ watch(
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqMessages.targetTopic") }} <span class="required">*</span></label>
|
||||
<RocketMqTopicSelect v-if="isRocketMqCluster" ref="rocketMqTopicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" grouping="business" :show-type-filter="false" :disabled="readOnly" @loaded="handleRocketMqTopicsLoaded" />
|
||||
<input v-if="fixedTopic" :value="topicName" type="text" class="topic-input" disabled />
|
||||
<RocketMqTopicSelect v-else-if="isRocketMqCluster" ref="rocketMqTopicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" grouping="business" :show-type-filter="false" :disabled="readOnly" @loaded="handleRocketMqTopicsLoaded" />
|
||||
<template v-else>
|
||||
<div class="topic-select-row">
|
||||
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? t('mqMessages.topicLoading') : t('mqMessages.topicSearchPlaceholder')" autocomplete="off" />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @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(),
|
||||
mqGetTopicStats: vi.fn(),
|
||||
mqPeekMessages: vi.fn(),
|
||||
mqSendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Icon = defineComponent({ name: "Icon", setup: () => () => h("i") });
|
||||
return { Copy: Icon, Search: Icon };
|
||||
});
|
||||
|
||||
import KafkaMessagesPanel from "@/components/mq/KafkaMessagesPanel.vue";
|
||||
|
||||
const TOPIC = { name: "events", shortName: "events", persistent: true, partitioned: true, partitions: 2 };
|
||||
const STATS = {
|
||||
msgRateIn: 0,
|
||||
msgRateOut: 0,
|
||||
msgThroughputIn: 0,
|
||||
msgThroughputOut: 0,
|
||||
storageSize: 0,
|
||||
backlogSize: 0,
|
||||
msgInCounter: 12,
|
||||
msgOutCounter: 0,
|
||||
subscriptionCount: 0,
|
||||
producerCount: 0,
|
||||
raw: {
|
||||
partitionStats: [
|
||||
{ partition: 1, beginOffset: 4, endOffset: 10, messageCount: 6 },
|
||||
{ partition: 0, beginOffset: 2, endOffset: 8, messageCount: 6 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function mountPanel(onTopicSelected = vi.fn()) {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(KafkaMessagesPanel, {
|
||||
connectionId: "mq-1",
|
||||
tenant: "_flat_mq",
|
||||
namespace: "_flat_mq",
|
||||
topic: TOPIC,
|
||||
canSendMessage: true,
|
||||
onTopicSelected,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
async function selectTopic(panel: HTMLElement, name: string) {
|
||||
const trigger = panel.querySelector<HTMLButtonElement>(".topic-combobox-trigger");
|
||||
if (!trigger) throw new Error("Topic selector trigger not found");
|
||||
trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
await flushUi();
|
||||
const option = [...panel.querySelectorAll<HTMLButtonElement>(".topic-combobox-option")].find((button) => button.textContent?.trim() === name);
|
||||
if (!option) throw new Error(`Topic option ${name} not found`);
|
||||
option.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
await flushUi();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
backend.mqListTopics.mockReset().mockResolvedValue([TOPIC, { ...TOPIC, name: "payments", shortName: "payments" }]);
|
||||
backend.mqGetTopicStats.mockReset().mockResolvedValue(STATS);
|
||||
backend.mqPeekMessages.mockReset().mockResolvedValue([]);
|
||||
backend.mqSendMessage.mockReset().mockResolvedValue({ topic: TOPIC.shortName, partition: 0, offset: 1 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
});
|
||||
|
||||
describe("KafkaMessagesPanel", () => {
|
||||
it("keeps topic selection, partition offsets, and newest-message browsing together", async () => {
|
||||
const panel = await mountPanel();
|
||||
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBe(1);
|
||||
await expect.poll(() => backend.mqGetTopicStats.mock.calls.length).toBeGreaterThan(0);
|
||||
await flushUi();
|
||||
|
||||
const overview = panel.querySelector('[data-testid="kafka-partition-overview"]');
|
||||
expect(overview?.textContent).toContain("mqMonitoring.tableBeginOffset");
|
||||
expect(overview?.textContent).toContain("mqMonitoring.tableLogEndOffset");
|
||||
expect(overview?.textContent).toContain("10");
|
||||
expect(panel.querySelector('[data-testid="message-browser"]')).not.toBeNull();
|
||||
expect(panel.querySelector(".send-message-panel")).not.toBeNull();
|
||||
expect(panel.querySelector<HTMLInputElement>(".send-message-panel .topic-input")?.value).toBe("events");
|
||||
expect(backend.mqListTopics).toHaveBeenCalledTimes(1);
|
||||
|
||||
const loadButton = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("mqMessages.loadMessages"));
|
||||
if (!loadButton) throw new Error("Load messages button not found");
|
||||
loadButton.click();
|
||||
await flushUi();
|
||||
|
||||
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "latest" });
|
||||
});
|
||||
|
||||
it("keeps message browsing available when partition stats fail", async () => {
|
||||
backend.mqGetTopicStats.mockRejectedValueOnce(new Error("stats unavailable"));
|
||||
const panel = await mountPanel();
|
||||
await expect.poll(() => panel.textContent).toContain("stats unavailable");
|
||||
|
||||
expect(panel.querySelector('[data-testid="message-browser"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the user's selected topic across list refreshes and refresh failures", async () => {
|
||||
const onTopicSelected = vi.fn();
|
||||
const panel = await mountPanel(onTopicSelected);
|
||||
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBe(1);
|
||||
await selectTopic(panel, "payments");
|
||||
await expect.poll(() => backend.mqGetTopicStats.mock.calls.some((call) => call[1]?.topic === "payments")).toBe(true);
|
||||
await expect.poll(() => onTopicSelected.mock.calls.some((call) => call[0]?.shortName === "payments")).toBe(true);
|
||||
expect(panel.querySelector<HTMLInputElement>(".send-message-panel .topic-input")?.value).toBe("payments");
|
||||
expect(backend.mqListTopics).toHaveBeenCalledTimes(1);
|
||||
|
||||
const refreshButton = panel.querySelector<HTMLButtonElement>(".panel-toolbar .btn-secondary");
|
||||
if (!refreshButton) throw new Error("Topic refresh button not found");
|
||||
refreshButton.click();
|
||||
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBe(2);
|
||||
expect(panel.querySelector(".committed-value")?.textContent).toBe("payments");
|
||||
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
backend.mqListTopics.mockRejectedValueOnce(new Error("list unavailable"));
|
||||
refreshButton.click();
|
||||
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBe(3);
|
||||
expect(panel.querySelector(".committed-value")?.textContent).toBe("payments");
|
||||
expect(panel.querySelector('[data-testid="message-browser"]')).not.toBeNull();
|
||||
warning.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ const KAFKA_STATS = {
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
const navigateTab = vi.fn();
|
||||
|
||||
async function flushUi() {
|
||||
await Promise.resolve();
|
||||
@@ -97,6 +98,7 @@ async function mountPanel() {
|
||||
namespace: "default",
|
||||
topic: TOPIC,
|
||||
mqSystemKind: "kafka",
|
||||
onNavigateTab: navigateTab,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
@@ -107,6 +109,7 @@ beforeEach(() => {
|
||||
backend.mqGetTopicStats.mockReset();
|
||||
backend.mqGetBacklog.mockReset();
|
||||
backend.mqPeekMessages.mockReset();
|
||||
navigateTab.mockReset();
|
||||
backend.mqGetTopicStats.mockResolvedValue(KAFKA_STATS);
|
||||
backend.mqPeekMessages.mockResolvedValue([]);
|
||||
});
|
||||
@@ -128,7 +131,13 @@ describe("MonitoringPanel Kafka message browser", () => {
|
||||
expect(panel.querySelector("textarea")).toBeNull();
|
||||
expect(browser.classList.contains("message-browser")).toBe(true);
|
||||
expect(browser.classList.contains("is-monitoring")).toBe(true);
|
||||
expect(browser.querySelector('[data-testid="kafka-peek-start-position"]')).not.toBeNull();
|
||||
expect(browser.querySelector('[data-testid="kafka-peek-start-position"]')).toBeNull();
|
||||
expect(browser.querySelector('[data-testid="kafka-peek-partition"]')).not.toBeNull();
|
||||
|
||||
const fullQueryButton = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("mqMessages.queryTitle"));
|
||||
if (!fullQueryButton) throw new Error("Full Kafka query button not found");
|
||||
fullQueryButton.click();
|
||||
expect(navigateTab).toHaveBeenCalledWith({ tab: "messages", topic: TOPIC });
|
||||
|
||||
loadButton.click();
|
||||
await flushUi();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type ComponentPublicInstance } from "vue";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqListTopics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { defineComponent: defineIcon, h: render } = await import("vue");
|
||||
const Icon = defineIcon({ name: "Icon", setup: () => () => render("i") });
|
||||
return { Search: Icon };
|
||||
});
|
||||
|
||||
import RocketMqTopicSelect from "@/components/mq/shared/RocketMqTopicSelect.vue";
|
||||
|
||||
type TopicSelectExposed = ComponentPublicInstance & { loading: boolean };
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
backend.mqListTopics.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
});
|
||||
|
||||
describe("RocketMqTopicSelect request state", () => {
|
||||
it("clears loading when the request context becomes incomplete", async () => {
|
||||
backend.mqListTopics.mockReturnValue(new Promise(() => undefined));
|
||||
const tenant = ref<string>();
|
||||
const selector = ref<TopicSelectExposed>();
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(RocketMqTopicSelect, {
|
||||
ref: selector,
|
||||
modelValue: "",
|
||||
connectionId: "mq-1",
|
||||
tenant: tenant.value,
|
||||
namespace: "default",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
tenant.value = "tenant-a";
|
||||
app = createApp(Wrapper);
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
expect(selector.value?.loading).toBe(true);
|
||||
|
||||
tenant.value = undefined;
|
||||
await flushUi();
|
||||
|
||||
expect(selector.value?.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ const dropdownOpen = ref(false);
|
||||
const highlightIndex = ref(-1);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
let loadRequestVersion = 0;
|
||||
|
||||
const listboxId = computed(() => `mq-topic-listbox-${props.connectionId}-${props.tenant ?? ""}-${props.namespace ?? ""}`);
|
||||
|
||||
@@ -180,14 +181,16 @@ function onDocumentPointerDown(event: PointerEvent) {
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
const requestVersion = ++loadRequestVersion;
|
||||
if (!props.tenant || !props.namespace) {
|
||||
topics.value = [];
|
||||
loading.value = false;
|
||||
emit("loaded", []);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
topics.value = await mqListTopics(
|
||||
const loaded = await mqListTopics(
|
||||
props.connectionId,
|
||||
{
|
||||
tenant: props.tenant,
|
||||
@@ -195,13 +198,13 @@ async function loadTopics() {
|
||||
},
|
||||
{ includeNonPersistent: false },
|
||||
);
|
||||
emit("loaded", topics.value);
|
||||
if (requestVersion !== loadRequestVersion) return;
|
||||
topics.value = loaded;
|
||||
emit("loaded", loaded);
|
||||
} catch (e: unknown) {
|
||||
console.warn("[DBX] Failed to load RocketMQ topics:", e);
|
||||
topics.value = [];
|
||||
emit("loaded", []);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestVersion === loadRequestVersion) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +230,12 @@ watch(dropdownOpen, (open) => {
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace, props.connectionId],
|
||||
() => {
|
||||
(_context, previousContext) => {
|
||||
if (previousContext) {
|
||||
loadRequestVersion += 1;
|
||||
topics.value = [];
|
||||
emit("loaded", []);
|
||||
}
|
||||
void loadTopics();
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
Reference in new issue
Block a user