fix(kafka): improve message browsing and topic navigation

This commit is contained in:
miracle
2026-08-14 00:19:09 +08:00
committed by GitHub
parent f6518ff3a0
commit e77b547cea
24 changed files with 751 additions and 114 deletions
@@ -1140,8 +1140,12 @@ public final class KafkaAgent {
return peekMessagesResult(Collections.emptyList(), false);
}
int messagesPerPartition = peekMessagesPerPartition(count, readablePartitions.size());
int scanLimit = peekScanLimit(count, readablePartitions.size());
int messagesPerPartition = startPosition == PeekStartPosition.LATEST
? latestPeekMessagesPerPartition(count, readablePartitions.size())
: peekMessagesPerPartition(count, readablePartitions.size());
int scanLimit = peekScanLimit(count, readablePartitions.size(), startPosition);
boolean latestBudgetLimited = startPosition == PeekStartPosition.LATEST
&& latestPeekBudgetLimited(count, readablePartitions.size());
Map<TopicPartition, Long> snapshotEndOffsets = new LinkedHashMap<>();
if (startPosition == PeekStartPosition.LATEST) {
for (TopicPartition tp : readablePartitions) {
@@ -1176,6 +1180,7 @@ public final class KafkaAgent {
deadlineNs,
pollTimeout
);
collection.incomplete = collection.incomplete || latestBudgetLimited;
} else {
PeekCollectionCompletionChecker snapshotComplete = state -> allPeekPartitionsComplete(
readablePartitions,
@@ -1194,11 +1199,9 @@ public final class KafkaAgent {
pollTimeout
);
}
List<Map<String, Object>> messages = collection.messages;
sortPeekedMessages(messages, startPosition);
if (messages.size() > count) {
messages = new ArrayList<>(messages.subList(0, count));
}
List<Map<String, Object>> messages = sortAndLimitPeekedMessages(
collection.messages, count, startPosition
);
return peekMessagesResult(messages, collection.incomplete);
}
}
@@ -1311,9 +1314,10 @@ public final class KafkaAgent {
continue;
}
for (ConsumerRecord<String, byte[]> record : records) {
if (++collection.scannedRecords > maxScanRecords) {
if (collection.scannedRecords >= maxScanRecords) {
return false;
}
collection.scannedRecords++;
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
if (remaining <= 0 || !recordFilter.include(record)) {
@@ -1429,10 +1433,11 @@ public final class KafkaAgent {
: pollTimeout;
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
for (ConsumerRecord<String, byte[]> record : records) {
if (++collection.scannedRecords > maxScanRecords) {
if (collection.scannedRecords >= maxScanRecords) {
commitLatestPeekRange(rangeMessages, collection);
return false;
}
collection.scannedRecords++;
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
if (remaining <= 0 || !recordFilter.include(record)) {
@@ -1568,19 +1573,25 @@ public final class KafkaAgent {
Integer partition,
Duration timeout
) {
if (partition != null) {
return resolvePeekPartitions(topic, partition, Collections.emptyList());
}
List<PartitionInfo> infos = consumer.partitionsFor(topic, timeout);
if (infos == null || infos.isEmpty()) {
return Collections.emptyList();
}
List<Integer> available = infos.stream().map(PartitionInfo::partition).collect(Collectors.toList());
return resolvePeekPartitions(topic, null, available);
return resolvePeekPartitions(topic, partition, available);
}
static List<TopicPartition> resolvePeekPartitions(String topic, Integer partition, List<Integer> availablePartitions) {
if (partition != null) {
if (availablePartitions == null || !availablePartitions.contains(partition)) {
String available = availablePartitions == null || availablePartitions.isEmpty()
? "none"
: availablePartitions.stream().sorted().map(String::valueOf).collect(Collectors.joining(", "));
throw new IllegalArgumentException(
"Kafka partition " + partition + " does not exist for topic '" + topic
+ "'. Available partitions: " + available
);
}
return Collections.singletonList(new TopicPartition(topic, partition));
}
if (availablePartitions == null || availablePartitions.isEmpty()) {
@@ -1657,17 +1668,52 @@ public final class KafkaAgent {
}
static int peekScanLimit(int count, int readablePartitionCount) {
int fetchCount = recentPeekFetchCount(
peekMessagesPerPartition(count, readablePartitionCount), readablePartitionCount
);
if (fetchCount > MAX_PEEK_SCAN_RECORDS) {
return peekScanLimit(count, readablePartitionCount, PeekStartPosition.EARLIEST);
}
static int peekScanLimit(int count, int readablePartitionCount, PeekStartPosition startPosition) {
if (startPosition == PeekStartPosition.LATEST
&& readablePartitionCount > MAX_PEEK_SCAN_RECORDS) {
throw new IllegalArgumentException(
"Kafka message browse would scan more than " + MAX_PEEK_SCAN_RECORDS + " records"
"Kafka topic has more than " + MAX_PEEK_SCAN_RECORDS
+ " readable partitions; select a partition to browse latest messages"
);
}
return MAX_PEEK_SCAN_RECORDS;
}
/**
* Latest is a topic-level query. Below the scan budget, every partition contributes the
* requested count so the global merge is exact. Above it, the fixed budget is shared across
* partitions and the response is marked incomplete.
*/
static int latestPeekMessagesPerPartition(int count, int readablePartitionCount) {
int safePartitionCount = Math.max(1, readablePartitionCount);
if (safePartitionCount > MAX_PEEK_SCAN_RECORDS) {
throw new IllegalArgumentException(
"Kafka topic has more than " + MAX_PEEK_SCAN_RECORDS
+ " readable partitions; select a partition to browse latest messages"
);
}
return Math.min(count, MAX_PEEK_SCAN_RECORDS / safePartitionCount);
}
static boolean latestPeekBudgetLimited(int count, int readablePartitionCount) {
return latestPeekMessagesPerPartition(count, readablePartitionCount) < count;
}
static List<Map<String, Object>> sortAndLimitPeekedMessages(
List<Map<String, Object>> messages,
int count,
PeekStartPosition startPosition
) {
sortPeekedMessages(messages, startPosition);
if (messages.size() <= count) {
return messages;
}
return new ArrayList<>(messages.subList(0, count));
}
static void sortPeekedMessages(List<Map<String, Object>> messages) {
sortPeekedMessages(messages, PeekStartPosition.EARLIEST);
}
@@ -1698,7 +1744,9 @@ public final class KafkaAgent {
}
long leftOffset = ((Number) left.getOrDefault("offset", 0L)).longValue();
long rightOffset = ((Number) right.getOrDefault("offset", 0L)).longValue();
return Long.compare(leftOffset, rightOffset);
return startPosition == PeekStartPosition.LATEST
? Long.compare(rightOffset, leftOffset)
: Long.compare(leftOffset, rightOffset);
};
messages.sort(comparator);
}
@@ -2142,11 +2190,6 @@ public final class KafkaAgent {
return currentStartOffset - Math.min(distanceToBeginning, expandedWindowWidth);
}
static int recentPeekFetchCount(int messagesPerPartition, int partitionCount) {
long total = (long) messagesPerPartition * Math.max(1, partitionCount);
return (int) Math.min(Integer.MAX_VALUE, total);
}
private static String stringOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? null : element.getAsString();
@@ -568,15 +568,23 @@ class KafkaAgentTest {
}
@Test
void boundsThePerPartitionMessageQuotaBeforeTrimmingTheResult() {
assertEquals(12, KafkaAgent.recentPeekFetchCount(4, 3));
void latestPeekReportsWhetherTheGlobalBudgetCanCoverEveryPartitionQuota() {
assertFalse(KafkaAgent.latestPeekBudgetLimited(20, 50));
assertTrue(KafkaAgent.latestPeekBudgetLimited(20, 51));
assertTrue(KafkaAgent.latestPeekBudgetLimited(100, 11));
assertEquals(4, KafkaAgent.peekMessagesPerPartition(10, 3));
}
@Test
void peekRejectsAWindowThatExceedsTheScanLimit() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.peekScanLimit(100, 1_001));
void latestPeekSharesTheGlobalScanBudgetAcrossManyPartitions() {
assertEquals(20, KafkaAgent.latestPeekMessagesPerPartition(20, 3));
assertEquals(19, KafkaAgent.latestPeekMessagesPerPartition(20, 51));
assertEquals(16, KafkaAgent.latestPeekMessagesPerPartition(20, 60));
assertEquals(10, KafkaAgent.latestPeekMessagesPerPartition(20, 100));
assertEquals(90, KafkaAgent.latestPeekMessagesPerPartition(100, 11));
assertEquals(1_000, KafkaAgent.peekScanLimit(
100, 11, KafkaAgent.PeekStartPosition.LATEST
));
}
@Test
@@ -648,6 +656,25 @@ class KafkaAgentTest {
assertEquals("events", partitions.get(0).topic());
}
@Test
void resolvePeekPartitionsRejectsMissingPartitionBeforeOffsetLookup() {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.resolvePeekPartitions("events", 5, List.of(0, 1, 2))
);
assertEquals(
"Kafka partition 5 does not exist for topic 'events'. Available partitions: 0, 1, 2",
error.getMessage()
);
}
@Test
void resolvePeekPartitionsKeepsRequestedPartitionAfterMetadataLookup() {
var partitions = KafkaAgent.resolvePeekPartitions("events", 1, List.of(0, 1, 2));
assertEquals(List.of(1), partitions.stream().map(TopicPartition::partition).toList());
}
@Test
void resolvePeekPartitionsUsesAllPartitionsWhenUnspecified() {
var partitions = KafkaAgent.resolvePeekPartitions("events", null, List.of(2, 0, 1));
@@ -680,11 +707,47 @@ class KafkaAgentTest {
assertEquals(20L, messages.get(0).get("timestamp"));
assertEquals(0, messages.get(1).get("partition"));
assertEquals(2L, messages.get(1).get("offset"));
assertEquals(5L, messages.get(2).get("offset"));
assertEquals(5L, messages.get(1).get("offset"));
assertEquals(2L, messages.get(2).get("offset"));
assertEquals(1, messages.get(3).get("partition"));
}
@Test
void latestPeekKeepsTheFullQuotaWhenItFitsTheGlobalScanBudget() {
assertEquals(1_000, KafkaAgent.peekScanLimit(
100, 10, KafkaAgent.PeekStartPosition.LATEST
));
assertEquals(100, KafkaAgent.latestPeekMessagesPerPartition(100, 10));
assertEquals(20, KafkaAgent.latestPeekMessagesPerPartition(20, 50));
}
@Test
void latestPeekRejectsTopicsWhosePartitionCountAloneExceedsTheScanBudget() {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.latestPeekMessagesPerPartition(20, 1_001)
);
assertTrue(error.getMessage().contains("select a partition"));
}
@Test
void latestPeekGloballyMergesInterleavedPartitionTimelinesBeforeLimiting() {
var messages = new java.util.ArrayList<Map<String, Object>>();
messages.add(Map.of("timestamp", 101L, "partition", 0, "offset", 8L));
messages.add(Map.of("timestamp", 105L, "partition", 1, "offset", 3L));
messages.add(Map.of("timestamp", 103L, "partition", 2, "offset", 9L));
messages.add(Map.of("timestamp", 104L, "partition", 0, "offset", 9L));
messages.add(Map.of("timestamp", 102L, "partition", 1, "offset", 2L));
List<Map<String, Object>> latest = KafkaAgent.sortAndLimitPeekedMessages(
messages, 3, KafkaAgent.PeekStartPosition.LATEST
);
assertEquals(List.of(105L, 104L, 103L), latest.stream()
.map(message -> ((Number) message.get("timestamp")).longValue())
.toList());
}
@Test
void sortPeekedMessagesOrdersOffsetModeByOffsetAscending() {
var messages = new java.util.ArrayList<Map<String, Object>>();
@@ -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 },
+1 -1
View File
@@ -6779,7 +6779,7 @@ export default {
kafkaLatest: "Recent N messages (default)",
kafkaEarliest: "Earliest messages",
kafkaOffset: "From offset",
kafkaLatestHint: "Reads a balanced window near the end of each partition and merges by timestamp newest first.",
kafkaLatestHint: "Reads up to N candidates from each partition, then returns the newest N by timestamp across the topic.",
kafkaEarliestHint: "Reads history forward from each selected partition.",
kafkaOffsetHint: "Reads forward from the specified offset in the chosen partition, or every partition when left empty.",
messagesLoading: "Loading messages...",
+1 -1
View File
@@ -6719,7 +6719,7 @@ export default withEnglishFallback({
kafkaLatest: "Últimos N mensajes (predeterminado)",
kafkaEarliest: "Mensajes más antiguos",
kafkaOffset: "Desde offset",
kafkaLatestHint: "Lee una ventana equilibrada cerca del final de cada partición y combina por marca de tiempo con los más nuevos primero.",
kafkaLatestHint: "Lee hasta N candidatos por partición y devuelve los N más recientes del tema por marca de tiempo.",
kafkaEarliestHint: "Lee el historial hacia adelante desde cada partición seleccionada.",
kafkaOffsetHint: "Lee hacia adelante desde el offset indicado en la partición elegida, o en todas si se deja vacía.",
messagesLoading: "Cargando mensajes...",
+1 -1
View File
@@ -6719,7 +6719,7 @@ export default withEnglishFallback({
kafkaLatest: "Ultimi N messaggi (predefinito)",
kafkaEarliest: "Primi messaggi",
kafkaOffset: "Da offset",
kafkaLatestHint: "Legge una finestra bilanciata vicino alla fine di ogni partizione e unisce per timestamp con i più recenti prima.",
kafkaLatestHint: "Legge fino a N candidati per partizione e restituisce i N messaggi più recenti del topic in base al timestamp.",
kafkaEarliestHint: "Legge la cronologia in avanti da ogni partizione selezionata.",
kafkaOffsetHint: "Legge in avanti dall'offset indicato nella partizione scelta, o in tutte se lasciata vuota.",
messagesLoading: "Caricamento messaggi...",
+1 -1
View File
@@ -6772,7 +6772,7 @@ export default withEnglishFallback({
kafkaLatest: "最新 N 件(デフォルト)",
kafkaEarliest: "最も古いメッセージ",
kafkaOffset: "Offset から読み取る",
kafkaLatestHint: "各パーティションの末尾付近から均等に読み取り、タイムスタンプの新しい順に統合します。",
kafkaLatestHint: "各パーティションから最大 N 件の候補を読み取り、トピック全体でタイムスタンプが新しい N 件を返します。",
kafkaEarliestHint: "選択した各パーティションの先頭から昇順に読み取ります。",
kafkaOffsetHint: "指定 Offset から、選択したパーティション(空欄ならすべて)を昇順に読み取ります。",
messagesLoading: "メッセージを読み込み中...",
+1 -1
View File
@@ -6255,7 +6255,7 @@ export default withEnglishFallback({
kafkaLatest: "최근 N개 메시지(기본값)",
kafkaEarliest: "가장 이른 메시지",
kafkaOffset: "오프셋부터 읽기",
kafkaLatestHint: "각 파티션 끝 부근에서 균등하게 읽고 타임스탬프 내림차순으로 병합합니다.",
kafkaLatestHint: "파티션마다 최대 N개의 후보를 읽은 뒤 토픽 전체에서 타임스탬프가 가장 최신인 N개를 반환합니다.",
kafkaEarliestHint: "선택한 각 파티션의 처음부터 오름차순으로 읽습니다.",
kafkaOffsetHint: "지정한 오프셋부터 선택한 파티션(비워 두면 전체)을 오름차순으로 읽습니다.",
messagesLoading: "메시지를 불러오는 중...",
+1 -1
View File
@@ -6721,7 +6721,7 @@ export default withEnglishFallback({
kafkaLatest: "Últimas N mensagens (padrão)",
kafkaEarliest: "Mensagens mais antigas",
kafkaOffset: "A partir do offset",
kafkaLatestHint: "Lê uma janela equilibrada perto do fim de cada partição e mescla por data e hora com as mais novas primeiro.",
kafkaLatestHint: "Lê até N candidatos por partição e retorna as N mensagens mais recentes do tópico por data e hora.",
kafkaEarliestHint: "Lê o histórico em ordem crescente de cada partição selecionada.",
kafkaOffsetHint: "Lê em ordem crescente a partir do offset informado na partição escolhida, ou em todas se deixada vazia.",
messagesLoading: "Carregando mensagens...",
+1 -1
View File
@@ -6766,7 +6766,7 @@ export default withEnglishFallback({
kafkaLatest: "最近 N 条(默认)",
kafkaEarliest: "最早消息",
kafkaOffset: "从指定 Offset 读取",
kafkaLatestHint: "从各分区末尾附近均衡读取,按时间倒序合并。",
kafkaLatestHint: "每个分区最多读取 N 条候选消息,再按时间全局倒序返回最新 N 条。",
kafkaEarliestHint: "从每个选定分区的最早可读消息开始正序读取。",
kafkaOffsetHint: "从指定 Offset 开始正序读取;填写分区时仅读该分区,留空则对全部分区分别读取。",
messagesLoading: "消息加载中...",
+1 -1
View File
@@ -6712,7 +6712,7 @@ export default withEnglishFallback({
kafkaLatest: "最近 N 則(預設)",
kafkaEarliest: "最早訊息",
kafkaOffset: "從指定 Offset 讀取",
kafkaLatestHint: "從各分區末尾附近平均讀取,依時間倒序合併。",
kafkaLatestHint: "每個分區最多讀取 N 筆候選訊息,再依時間全域倒序回傳最新 N 筆。",
kafkaEarliestHint: "從每個選定分區最早可讀的訊息開始正序讀取。",
kafkaOffsetHint: "從指定 Offset 開始正序讀取;填寫分區時只讀該分區,留空則對全部分區分別讀取。",
messagesLoading: "訊息載入中...",
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { extractKafkaPartitionRows, isKafkaStatsPayload } from "@/lib/mq/kafkaTopicStats";
describe("Kafka topic stats", () => {
it("normalizes and sorts partition rows", () => {
expect(
extractKafkaPartitionRows({
partitionStats: [{ partition: 2, beginOffset: 8, endOffset: 20, messageCount: 12, leader: 3, replicas: [3, 4], isr: [3] }, { partition: 0, beginOffset: 1, endOffset: 4, messageCount: 3 }, null],
}),
).toEqual([
{ partition: 0, beginOffset: 1, endOffset: 4, messageCount: 3, leader: -1, replicas: [], isr: [] },
{ partition: 2, beginOffset: 8, endOffset: 20, messageCount: 12, leader: 3, replicas: [3, 4], isr: [3] },
]);
});
it("recognizes Kafka summaries and rejects unrelated payloads", () => {
expect(isKafkaStatsPayload({ partitionStats: [] })).toBe(true);
expect(isKafkaStatsPayload({ partitions: 2, replicationFactor: 1, totalMessages: 4 })).toBe(true);
expect(isKafkaStatsPayload({ partitions: 2 })).toBe(false);
expect(extractKafkaPartitionRows(undefined)).toEqual([]);
});
});
@@ -77,6 +77,13 @@ describe("mqConsoleDefaults", () => {
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsClientConnections).toBeFalsy();
});
it("exposes Kafka message browsing through peek capability without query capability", () => {
const caps = { ...defaultMqCapabilitiesForSystemKind("kafka"), supportsSendMessage: false };
expect(caps.supportsPeekMessages).toBe(true);
expect(caps.supportsMessageQuery).toBe(false);
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: caps })).toContain("messages");
});
it("exposes the namespaces tab for RabbitMQ vhost management", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: caps })).toEqual(["namespaces", "topics", "subscriptions", "monitoring", "clients", "messages", "broker", "policies", "permissions"]);
@@ -0,0 +1,49 @@
export interface KafkaPartitionStatsRow {
partition: number;
beginOffset: number;
endOffset: number;
messageCount: number;
leader: number;
replicas: number[];
isr: number[];
}
export 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((left, right) => left.partition - right.partition);
}
export function isKafkaStatsPayload(raw: unknown): boolean {
const root = objectRecord(raw);
return Array.isArray(root.partitionStats) || (numberField(root.partitions) !== undefined && numberField(root.replicationFactor) !== undefined && numberField(root.totalMessages) !== undefined);
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function arrayObjects(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && !Array.isArray(item)) : [];
}
function numberField(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function numberArrayField(value: unknown): number[] {
return Array.isArray(value) ? value.map(numberField).filter((item): item is number => item !== undefined) : [];
}
+1 -1
View File
@@ -177,7 +177,7 @@ export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; cap
if (capabilities.supportsSubscriptions) tabs.push("subscriptions");
tabs.push("monitoring");
tabs.push("clients");
if (capabilities.supportsSendMessage) tabs.push("messages");
if (capabilities.supportsSendMessage || (systemKind === "kafka" && capabilities.supportsPeekMessages)) tabs.push("messages");
tabs.push("broker");
// RabbitMQ lights this tab via virtual-host policies instead of Pulsar rates/quotas.
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention || capabilities.supportsPolicies) {