feat(kafka): add consumer group backlog view
This commit is contained in:
1 parent
dc8b083c63
commit
f88167c3d0
28 files changed
+1274
-2
No files matched your search
@@ -146,6 +146,7 @@ public final class KafkaAgent {
|
|||||||
case "mq_alter_topic_config" -> alterTopicConfig(params);
|
case "mq_alter_topic_config" -> alterTopicConfig(params);
|
||||||
// Consumer groups
|
// Consumer groups
|
||||||
case "mq_list_consumer_groups" -> listConsumerGroups(params);
|
case "mq_list_consumer_groups" -> listConsumerGroups(params);
|
||||||
|
case "mq_get_consumer_group_snapshot" -> getConsumerGroupSnapshot(params);
|
||||||
case "mq_describe_consumer_group" -> describeConsumerGroup(params);
|
case "mq_describe_consumer_group" -> describeConsumerGroup(params);
|
||||||
case "mq_delete_consumer_group" -> deleteConsumerGroup(params);
|
case "mq_delete_consumer_group" -> deleteConsumerGroup(params);
|
||||||
case "mq_reset_consumer_group_offsets" -> resetConsumerGroupOffsets(params);
|
case "mq_reset_consumer_group_offsets" -> resetConsumerGroupOffsets(params);
|
||||||
@@ -971,6 +972,208 @@ public final class KafkaAgent {
|
|||||||
return Collections.singletonMap("groups", result);
|
return Collections.singletonMap("groups", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Object getConsumerGroupSnapshot(JsonObject params) throws Exception {
|
||||||
|
AdminClient admin = requireAdmin();
|
||||||
|
int timeout = requestTimeout(params);
|
||||||
|
Collection<ConsumerGroupListing> listings = admin.listConsumerGroups(
|
||||||
|
new ListConsumerGroupsOptions().timeoutMs(timeout))
|
||||||
|
.all().get(timeout, TimeUnit.MILLISECONDS);
|
||||||
|
|
||||||
|
List<ConsumerGroupListing> sortedListings = new ArrayList<>(listings);
|
||||||
|
sortedListings.sort(Comparator.comparing(ConsumerGroupListing::groupId));
|
||||||
|
if (sortedListings.isEmpty()) {
|
||||||
|
return Collections.singletonMap("groups", Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> groupIds = sortedListings.stream()
|
||||||
|
.map(ConsumerGroupListing::groupId)
|
||||||
|
.toList();
|
||||||
|
Map<String, ConsumerGroupDescription> descriptions = new HashMap<>();
|
||||||
|
Map<String, Map<TopicPartition, OffsetAndMetadata>> committedOffsets = new HashMap<>();
|
||||||
|
Map<String, Boolean> committedOffsetsAvailable = new HashMap<>();
|
||||||
|
Map<String, LinkedHashSet<String>> errors = new HashMap<>();
|
||||||
|
|
||||||
|
DescribeConsumerGroupsResult described = admin.describeConsumerGroups(
|
||||||
|
groupIds,
|
||||||
|
new DescribeConsumerGroupsOptions().timeoutMs(timeout)
|
||||||
|
);
|
||||||
|
for (String groupId : groupIds) {
|
||||||
|
try {
|
||||||
|
descriptions.put(
|
||||||
|
groupId,
|
||||||
|
described.describedGroups().get(groupId).get(timeout, TimeUnit.MILLISECONDS)
|
||||||
|
);
|
||||||
|
} catch (Exception error) {
|
||||||
|
snapshotErrors(errors, groupId).add("Consumer group details unavailable: " + normalizeErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, ListConsumerGroupOffsetsSpec> offsetSpecs = new LinkedHashMap<>();
|
||||||
|
for (String groupId : groupIds) {
|
||||||
|
offsetSpecs.put(groupId, new ListConsumerGroupOffsetsSpec());
|
||||||
|
}
|
||||||
|
ListConsumerGroupOffsetsResult offsetsResult = admin.listConsumerGroupOffsets(
|
||||||
|
offsetSpecs,
|
||||||
|
new ListConsumerGroupOffsetsOptions().timeoutMs(timeout)
|
||||||
|
);
|
||||||
|
for (String groupId : groupIds) {
|
||||||
|
try {
|
||||||
|
Map<TopicPartition, OffsetAndMetadata> groupOffsets = offsetsResult
|
||||||
|
.partitionsToOffsetAndMetadata(groupId)
|
||||||
|
.get(timeout, TimeUnit.MILLISECONDS);
|
||||||
|
committedOffsets.put(groupId, groupOffsets == null ? Collections.emptyMap() : groupOffsets);
|
||||||
|
committedOffsetsAvailable.put(groupId, true);
|
||||||
|
} catch (Exception error) {
|
||||||
|
committedOffsets.put(groupId, Collections.emptyMap());
|
||||||
|
committedOffsetsAvailable.put(groupId, false);
|
||||||
|
snapshotErrors(errors, groupId).add("Committed offsets unavailable: " + normalizeErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Set<TopicPartition>> assignedPartitions = new HashMap<>();
|
||||||
|
Set<TopicPartition> allPartitions = new LinkedHashSet<>();
|
||||||
|
for (String groupId : groupIds) {
|
||||||
|
Set<TopicPartition> assigned = new LinkedHashSet<>();
|
||||||
|
ConsumerGroupDescription description = descriptions.get(groupId);
|
||||||
|
if (description != null) {
|
||||||
|
for (MemberDescription member : description.members()) {
|
||||||
|
assigned.addAll(member.assignment().topicPartitions());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignedPartitions.put(groupId, assigned);
|
||||||
|
allPartitions.addAll(assigned);
|
||||||
|
allPartitions.addAll(committedOffsets.getOrDefault(groupId, Collections.emptyMap()).keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<TopicPartition, Long> endOffsets = new HashMap<>();
|
||||||
|
Map<TopicPartition, String> endOffsetErrors = new HashMap<>();
|
||||||
|
if (!allPartitions.isEmpty()) {
|
||||||
|
Map<TopicPartition, OffsetSpec> endOffsetSpecs = new LinkedHashMap<>();
|
||||||
|
for (TopicPartition topicPartition : allPartitions) {
|
||||||
|
endOffsetSpecs.put(topicPartition, OffsetSpec.latest());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ListOffsetsResult latestOffsets = admin.listOffsets(
|
||||||
|
endOffsetSpecs,
|
||||||
|
new ListOffsetsOptions().timeoutMs(timeout)
|
||||||
|
);
|
||||||
|
for (TopicPartition topicPartition : allPartitions) {
|
||||||
|
try {
|
||||||
|
ListOffsetsResult.ListOffsetsResultInfo info = latestOffsets
|
||||||
|
.partitionResult(topicPartition)
|
||||||
|
.get(timeout, TimeUnit.MILLISECONDS);
|
||||||
|
endOffsets.put(topicPartition, info.offset());
|
||||||
|
} catch (Exception error) {
|
||||||
|
endOffsetErrors.put(topicPartition, normalizeErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception error) {
|
||||||
|
String message = normalizeErrorMessage(error);
|
||||||
|
for (TopicPartition topicPartition : allPartitions) {
|
||||||
|
endOffsetErrors.put(topicPartition, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> groups = new ArrayList<>();
|
||||||
|
for (ConsumerGroupListing listing : sortedListings) {
|
||||||
|
String groupId = listing.groupId();
|
||||||
|
ConsumerGroupDescription description = descriptions.get(groupId);
|
||||||
|
Set<TopicPartition> groupPartitions = new LinkedHashSet<>(
|
||||||
|
assignedPartitions.getOrDefault(groupId, Collections.emptySet())
|
||||||
|
);
|
||||||
|
groupPartitions.addAll(committedOffsets.getOrDefault(groupId, Collections.emptyMap()).keySet());
|
||||||
|
long unavailableEndOffsetCount = groupPartitions.stream()
|
||||||
|
.filter(endOffsetErrors::containsKey)
|
||||||
|
.count();
|
||||||
|
if (unavailableEndOffsetCount > 0) {
|
||||||
|
snapshotErrors(errors, groupId).add(
|
||||||
|
"End offsets unavailable for " + unavailableEndOffsetCount + " partition(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
groups.add(consumerGroupSnapshotRow(
|
||||||
|
groupId,
|
||||||
|
description != null
|
||||||
|
? description.state().name()
|
||||||
|
: listing.state().map(Enum::name).orElse("UNKNOWN"),
|
||||||
|
listing.isSimpleConsumerGroup(),
|
||||||
|
description == null ? null : description.members().size(),
|
||||||
|
assignedPartitions.getOrDefault(groupId, Collections.emptySet()),
|
||||||
|
committedOffsets.getOrDefault(groupId, Collections.emptyMap()),
|
||||||
|
committedOffsetsAvailable.getOrDefault(groupId, false),
|
||||||
|
endOffsets,
|
||||||
|
errors.getOrDefault(groupId, new LinkedHashSet<>())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Collections.singletonMap("groups", groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LinkedHashSet<String> snapshotErrors(
|
||||||
|
Map<String, LinkedHashSet<String>> errors,
|
||||||
|
String groupId
|
||||||
|
) {
|
||||||
|
return errors.computeIfAbsent(groupId, ignored -> new LinkedHashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> consumerGroupSnapshotRow(
|
||||||
|
String groupId,
|
||||||
|
String state,
|
||||||
|
boolean simpleGroup,
|
||||||
|
Integer memberCount,
|
||||||
|
Collection<TopicPartition> assignedPartitions,
|
||||||
|
Map<TopicPartition, OffsetAndMetadata> committedOffsets,
|
||||||
|
boolean committedOffsetsAvailable,
|
||||||
|
Map<TopicPartition, Long> endOffsets,
|
||||||
|
Collection<String> errors
|
||||||
|
) {
|
||||||
|
Set<TopicPartition> topicPartitions = new LinkedHashSet<>(assignedPartitions);
|
||||||
|
topicPartitions.addAll(committedOffsets.keySet());
|
||||||
|
List<TopicPartition> sortedPartitions = new ArrayList<>(topicPartitions);
|
||||||
|
sortedPartitions.sort(Comparator
|
||||||
|
.comparing(TopicPartition::topic)
|
||||||
|
.thenComparingInt(TopicPartition::partition));
|
||||||
|
|
||||||
|
Set<String> topics = new TreeSet<>();
|
||||||
|
List<Map<String, Object>> partitions = new ArrayList<>();
|
||||||
|
long totalLag = 0;
|
||||||
|
boolean lagAvailable = committedOffsetsAvailable && !sortedPartitions.isEmpty();
|
||||||
|
for (TopicPartition topicPartition : sortedPartitions) {
|
||||||
|
topics.add(topicPartition.topic());
|
||||||
|
OffsetAndMetadata committed = committedOffsets.get(topicPartition);
|
||||||
|
Long currentOffset = committed == null ? null : committed.offset();
|
||||||
|
Long endOffset = endOffsets.get(topicPartition);
|
||||||
|
Long lag = currentOffset == null || endOffset == null
|
||||||
|
? null
|
||||||
|
: Math.max(0, endOffset - currentOffset);
|
||||||
|
if (lag == null) {
|
||||||
|
lagAvailable = false;
|
||||||
|
} else {
|
||||||
|
totalLag += lag;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> partition = new LinkedHashMap<>();
|
||||||
|
partition.put("topic", topicPartition.topic());
|
||||||
|
partition.put("partition", topicPartition.partition());
|
||||||
|
partition.put("currentOffset", currentOffset);
|
||||||
|
partition.put("endOffset", endOffset);
|
||||||
|
partition.put("lag", lag);
|
||||||
|
partitions.add(partition);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> group = new LinkedHashMap<>();
|
||||||
|
group.put("groupId", groupId);
|
||||||
|
group.put("state", state);
|
||||||
|
group.put("simpleGroup", simpleGroup);
|
||||||
|
group.put("memberCount", memberCount);
|
||||||
|
group.put("topics", new ArrayList<>(topics));
|
||||||
|
group.put("totalLag", lagAvailable ? totalLag : null);
|
||||||
|
group.put("lagAvailable", lagAvailable);
|
||||||
|
group.put("partitions", partitions);
|
||||||
|
group.put("error", errors.isEmpty() ? null : String.join("; ", errors));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
private static Object describeConsumerGroup(JsonObject params) throws Exception {
|
private static Object describeConsumerGroup(JsonObject params) throws Exception {
|
||||||
AdminClient admin = requireAdmin();
|
AdminClient admin = requireAdmin();
|
||||||
int timeout = requestTimeout(params);
|
int timeout = requestTimeout(params);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import java.util.concurrent.CountDownLatch;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecords;
|
import org.apache.kafka.clients.consumer.ConsumerRecords;
|
||||||
|
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
|
||||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||||
import org.apache.kafka.clients.admin.AlterConfigOp;
|
import org.apache.kafka.clients.admin.AlterConfigOp;
|
||||||
import org.apache.kafka.clients.admin.Config;
|
import org.apache.kafka.clients.admin.Config;
|
||||||
@@ -56,6 +57,87 @@ class KafkaAgentTest {
|
|||||||
@TempDir
|
@TempDir
|
||||||
Path tempDir;
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumerGroupSnapshotCalculatesLagAndSortsTopicsAndPartitions() {
|
||||||
|
TopicPartition alphaOne = new TopicPartition("alpha", 1);
|
||||||
|
TopicPartition alphaZero = new TopicPartition("alpha", 0);
|
||||||
|
TopicPartition betaZero = new TopicPartition("beta", 0);
|
||||||
|
|
||||||
|
Map<String, Object> group = KafkaAgent.consumerGroupSnapshotRow(
|
||||||
|
"orders-service",
|
||||||
|
"STABLE",
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
Arrays.asList(betaZero, alphaOne, alphaZero),
|
||||||
|
Map.of(
|
||||||
|
alphaZero, new OffsetAndMetadata(8),
|
||||||
|
alphaOne, new OffsetAndMetadata(20),
|
||||||
|
betaZero, new OffsetAndMetadata(5)
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
Map.of(alphaZero, 10L, alphaOne, 24L, betaZero, 6L),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(List.of("alpha", "beta"), group.get("topics"));
|
||||||
|
assertEquals(7L, group.get("totalLag"));
|
||||||
|
assertEquals(true, group.get("lagAvailable"));
|
||||||
|
assertEquals(2, group.get("memberCount"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> partitions = (List<Map<String, Object>>) group.get("partitions");
|
||||||
|
assertEquals(
|
||||||
|
List.of("alpha:0", "alpha:1", "beta:0"),
|
||||||
|
partitions.stream()
|
||||||
|
.map(partition -> partition.get("topic") + ":" + partition.get("partition"))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumerGroupSnapshotDoesNotReportUnknownLagAsZero() {
|
||||||
|
TopicPartition assignedOnly = new TopicPartition("orders", 0);
|
||||||
|
Map<String, Object> group = KafkaAgent.consumerGroupSnapshotRow(
|
||||||
|
"new-consumer",
|
||||||
|
"EMPTY",
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
Collections.singletonList(assignedOnly),
|
||||||
|
Collections.emptyMap(),
|
||||||
|
true,
|
||||||
|
Collections.singletonMap(assignedOnly, 42L),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(false, group.get("lagAvailable"));
|
||||||
|
assertNull(group.get("totalLag"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> partitions = (List<Map<String, Object>>) group.get("partitions");
|
||||||
|
assertNull(partitions.get(0).get("currentOffset"));
|
||||||
|
assertNull(partitions.get(0).get("lag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumerGroupSnapshotPreservesPartialFailuresWithoutInventingLag() {
|
||||||
|
TopicPartition partition = new TopicPartition("orders", 0);
|
||||||
|
Map<String, Object> group = KafkaAgent.consumerGroupSnapshotRow(
|
||||||
|
"orders-service",
|
||||||
|
"UNKNOWN",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.singletonMap(partition, new OffsetAndMetadata(9)),
|
||||||
|
true,
|
||||||
|
Collections.emptyMap(),
|
||||||
|
Collections.singletonList("End offsets unavailable for 1 partition(s)")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(false, group.get("lagAvailable"));
|
||||||
|
assertNull(group.get("totalLag"));
|
||||||
|
assertNull(group.get("memberCount"));
|
||||||
|
assertEquals("End offsets unavailable for 1 partition(s)", group.get("error"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void explicitConsumerGroupOffsetsAcceptOneBoundedPartitionOffset() {
|
void explicitConsumerGroupOffsetsAcceptOneBoundedPartitionOffset() {
|
||||||
JsonObject params = JsonParser.parseString("""
|
JsonObject params = JsonParser.parseString("""
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { formatError } from "@/lib/backend/errorUtils";
|
||||||
|
import { mqGetKafkaConsumerGroupSnapshot } from "@/lib/backend/api";
|
||||||
|
import type { KafkaConsumerGroupSummary } from "@/types/mq";
|
||||||
|
import MqPanelToolbar from "./shared/MqPanelToolbar.vue";
|
||||||
|
import MqSearchInput from "./shared/MqSearchInput.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigateSubscriptions: [topic: string];
|
||||||
|
}>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const groups = ref<KafkaConsumerGroupSummary[]>([]);
|
||||||
|
const selectedGroupId = ref<string>();
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string>();
|
||||||
|
let loadSequence = 0;
|
||||||
|
|
||||||
|
const sortedGroups = computed(() => {
|
||||||
|
return [...groups.value].sort((left, right) => {
|
||||||
|
if (left.lagAvailable !== right.lagAvailable) return left.lagAvailable ? -1 : 1;
|
||||||
|
if (left.lagAvailable && right.lagAvailable && left.totalLag !== right.totalLag) {
|
||||||
|
return (right.totalLag ?? 0) - (left.totalLag ?? 0);
|
||||||
|
}
|
||||||
|
return left.groupId.localeCompare(right.groupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||||
|
if (!keyword) return sortedGroups.value;
|
||||||
|
return sortedGroups.value.filter((group) => {
|
||||||
|
return group.groupId.toLowerCase().includes(keyword) || group.topics.some((topic) => topic.toLowerCase().includes(keyword));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedGroup = computed(() => groups.value.find((group) => group.groupId === selectedGroupId.value));
|
||||||
|
|
||||||
|
watch(filteredGroups, (nextGroups) => {
|
||||||
|
if (!nextGroups.length || nextGroups.some((group) => group.groupId === selectedGroupId.value)) return;
|
||||||
|
selectedGroupId.value = nextGroups[0].groupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncSelection(nextGroups: KafkaConsumerGroupSummary[]) {
|
||||||
|
const currentId = selectedGroupId.value;
|
||||||
|
if (currentId && nextGroups.some((group) => group.groupId === currentId)) return;
|
||||||
|
selectedGroupId.value = [...nextGroups].sort((left, right) => {
|
||||||
|
if (left.lagAvailable !== right.lagAvailable) return left.lagAvailable ? -1 : 1;
|
||||||
|
return (right.totalLag ?? 0) - (left.totalLag ?? 0) || left.groupId.localeCompare(right.groupId);
|
||||||
|
})[0]?.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
const sequence = ++loadSequence;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = undefined;
|
||||||
|
try {
|
||||||
|
const snapshot = await mqGetKafkaConsumerGroupSnapshot(props.connectionId);
|
||||||
|
if (sequence !== loadSequence) return;
|
||||||
|
groups.value = snapshot.groups;
|
||||||
|
syncSelection(snapshot.groups);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (sequence === loadSequence) error.value = formatError(e);
|
||||||
|
} finally {
|
||||||
|
if (sequence === loadSequence) loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | undefined): string {
|
||||||
|
return value === undefined ? "-" : value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTopicSubscriptions(topic: string) {
|
||||||
|
emit("navigateSubscriptions", topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.connectionId,
|
||||||
|
() => {
|
||||||
|
groups.value = [];
|
||||||
|
selectedGroupId.value = undefined;
|
||||||
|
searchKeyword.value = "";
|
||||||
|
void loadGroups();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="kafka-consumer-groups-panel">
|
||||||
|
<MqPanelToolbar :title="t('mqKafkaConsumerGroups.title')">
|
||||||
|
<template #left-extra>
|
||||||
|
<MqSearchInput v-model="searchKeyword" :placeholder="t('mqKafkaConsumerGroups.searchPlaceholder')" :disabled="loading && !groups.length" />
|
||||||
|
<span v-if="groups.length" class="mq-result-count" data-testid="kafka-consumer-group-count">{{ filteredGroups.length }} / {{ groups.length }}</span>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<button class="mq-btn-secondary" :disabled="loading" @click="loadGroups">
|
||||||
|
{{ loading ? t("mqKafkaConsumerGroups.refreshing") : t("mqKafkaConsumerGroups.refresh") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</MqPanelToolbar>
|
||||||
|
|
||||||
|
<div v-if="error" class="panel-error" role="alert">{{ error }}</div>
|
||||||
|
<div v-else-if="loading && !groups.length" class="panel-placeholder">{{ t("mqKafkaConsumerGroups.loading") }}</div>
|
||||||
|
<div v-else-if="!groups.length" class="panel-placeholder">{{ t("mqKafkaConsumerGroups.empty") }}</div>
|
||||||
|
<div v-else-if="!filteredGroups.length" class="panel-placeholder">{{ t("mqKafkaConsumerGroups.noMatches") }}</div>
|
||||||
|
|
||||||
|
<div v-else class="consumer-groups-content">
|
||||||
|
<div class="groups-table-wrap">
|
||||||
|
<table data-testid="kafka-consumer-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t("mqKafkaConsumerGroups.groupId") }}</th>
|
||||||
|
<th>{{ t("mqKafkaConsumerGroups.state") }}</th>
|
||||||
|
<th>{{ t("mqKafkaConsumerGroups.members") }}</th>
|
||||||
|
<th>{{ t("mqKafkaConsumerGroups.topics") }}</th>
|
||||||
|
<th class="number-cell">{{ t("mqKafkaConsumerGroups.totalLag") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in filteredGroups" :key="group.groupId" :class="{ selected: group.groupId === selectedGroupId }" :data-group-id="group.groupId" @click="selectedGroupId = group.groupId">
|
||||||
|
<td class="group-id-cell">
|
||||||
|
<span>{{ group.groupId }}</span>
|
||||||
|
<span v-if="group.error" class="partial-warning" :title="group.error" :aria-label="t('mqKafkaConsumerGroups.partialData')">!</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="state-badge">{{ group.state || "UNKNOWN" }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatNumber(group.memberCount) }}</td>
|
||||||
|
<td class="topics-cell" :title="group.topics.join(', ')">{{ group.topics.length ? group.topics.join(", ") : "-" }}</td>
|
||||||
|
<td class="number-cell" :class="{ 'lag-positive': group.lagAvailable && (group.totalLag ?? 0) > 0 }">
|
||||||
|
{{ group.lagAvailable ? formatNumber(group.totalLag) : "-" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="selectedGroup" class="group-detail" data-testid="kafka-consumer-group-detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<h4>{{ selectedGroup.groupId }}</h4>
|
||||||
|
<p>{{ t("mqKafkaConsumerGroups.partitionDetail") }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedGroup.error" class="detail-warning" :title="selectedGroup.error">{{ t("mqKafkaConsumerGroups.partialData") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedGroup.partitions.length" class="detail-empty">{{ t("mqKafkaConsumerGroups.noCommittedOffsets") }}</div>
|
||||||
|
<div v-else class="partition-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t("mqKafkaConsumerGroups.topic") }}</th>
|
||||||
|
<th class="number-cell">{{ t("mqKafkaConsumerGroups.partition") }}</th>
|
||||||
|
<th class="number-cell">{{ t("mqKafkaConsumerGroups.committedOffset") }}</th>
|
||||||
|
<th class="number-cell">{{ t("mqKafkaConsumerGroups.logEndOffset") }}</th>
|
||||||
|
<th class="number-cell">{{ t("mqKafkaConsumerGroups.lag") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="partition in selectedGroup.partitions" :key="`${partition.topic}:${partition.partition}`">
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="topic-link"
|
||||||
|
:data-topic="partition.topic"
|
||||||
|
:title="t('mqKafkaConsumerGroups.openTopicSubscriptions', { topic: partition.topic })"
|
||||||
|
:aria-label="t('mqKafkaConsumerGroups.openTopicSubscriptions', { topic: partition.topic })"
|
||||||
|
@click="openTopicSubscriptions(partition.topic)"
|
||||||
|
>
|
||||||
|
{{ partition.topic }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="number-cell">{{ partition.partition }}</td>
|
||||||
|
<td class="number-cell">{{ formatNumber(partition.currentOffset) }}</td>
|
||||||
|
<td class="number-cell">{{ formatNumber(partition.endOffset) }}</td>
|
||||||
|
<td class="number-cell" :class="{ 'lag-positive': (partition.lag ?? 0) > 0 }">{{ formatNumber(partition.lag) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import "./shared/mqPanel.css";
|
||||||
|
|
||||||
|
.kafka-consumer-groups-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-placeholder,
|
||||||
|
.panel-error {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumer-groups-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(180px, 46%) minmax(180px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-table-wrap,
|
||||||
|
.partition-table-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-table-wrap {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light, var(--color-border));
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-table-wrap tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-table-wrap tbody tr:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-table-wrap tbody tr.selected {
|
||||||
|
background: var(--color-primary-alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-id-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-id-cell > span:first-child,
|
||||||
|
.topics-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--dbx-radius-fixed-4);
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partial-warning {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-warning);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-cell {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lag-positive {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h4,
|
||||||
|
.detail-header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header p {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-link:hover {
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-link:focus-visible {
|
||||||
|
border-radius: var(--dbx-radius-fixed-4);
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 620px) {
|
||||||
|
.consumer-groups-content {
|
||||||
|
grid-template-rows: minmax(150px, 42%) minmax(150px, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,7 @@ import TenantsPanel from "./TenantsPanel.vue";
|
|||||||
import NamespacesPanel from "./NamespacesPanel.vue";
|
import NamespacesPanel from "./NamespacesPanel.vue";
|
||||||
import TopicsPanel from "./TopicsPanel.vue";
|
import TopicsPanel from "./TopicsPanel.vue";
|
||||||
import SubscriptionsPanel from "./SubscriptionsPanel.vue";
|
import SubscriptionsPanel from "./SubscriptionsPanel.vue";
|
||||||
|
import KafkaConsumerGroupsPanel from "./KafkaConsumerGroupsPanel.vue";
|
||||||
import MonitoringPanel from "./MonitoringPanel.vue";
|
import MonitoringPanel from "./MonitoringPanel.vue";
|
||||||
import ProducerConsumerPanel from "./ProducerConsumerPanel.vue";
|
import ProducerConsumerPanel from "./ProducerConsumerPanel.vue";
|
||||||
import PoliciesPanel from "./PoliciesPanel.vue";
|
import PoliciesPanel from "./PoliciesPanel.vue";
|
||||||
@@ -174,6 +175,7 @@ function tabLabelKey(tab: MqTab): string {
|
|||||||
namespaces: "mqAdmin.tabNamespaces",
|
namespaces: "mqAdmin.tabNamespaces",
|
||||||
topics: "mqAdmin.tabTopics",
|
topics: "mqAdmin.tabTopics",
|
||||||
subscriptions: "mqAdmin.tabSubscriptions",
|
subscriptions: "mqAdmin.tabSubscriptions",
|
||||||
|
consumerGroups: "mqAdmin.tabConsumerGroups",
|
||||||
monitoring: "mqAdmin.tabMonitoring",
|
monitoring: "mqAdmin.tabMonitoring",
|
||||||
clients: "mqAdmin.tabClients",
|
clients: "mqAdmin.tabClients",
|
||||||
producers: "mqAdmin.tabProducers",
|
producers: "mqAdmin.tabProducers",
|
||||||
@@ -304,6 +306,17 @@ function handleNavigateTab(payload: { tab: MqTab; topic?: TopicInfo; subscriptio
|
|||||||
setActiveTab(payload.tab);
|
setActiveTab(payload.tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKafkaConsumerGroupTopicSelected(topic: string) {
|
||||||
|
selectedTopic.value = {
|
||||||
|
name: topic,
|
||||||
|
shortName: topic,
|
||||||
|
partitioned: false,
|
||||||
|
persistent: true,
|
||||||
|
};
|
||||||
|
selectedSubscriptionName.value = undefined;
|
||||||
|
setActiveTab("subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubscriptionSelected(subscription: string) {
|
function handleSubscriptionSelected(subscription: string) {
|
||||||
selectedSubscriptionName.value = subscription;
|
selectedSubscriptionName.value = subscription;
|
||||||
activeTab.value = isRocketMqCluster.value ? "producers" : "clients";
|
activeTab.value = isRocketMqCluster.value ? "producers" : "clients";
|
||||||
@@ -472,6 +485,7 @@ onMounted(async () => {
|
|||||||
:supports-expire-messages="canExpireMessages"
|
:supports-expire-messages="canExpireMessages"
|
||||||
@subscription-selected="handleSubscriptionSelected"
|
@subscription-selected="handleSubscriptionSelected"
|
||||||
/>
|
/>
|
||||||
|
<KafkaConsumerGroupsPanel v-else-if="activeTab === 'consumerGroups' && isKafkaCluster" :connection-id="connectionId" @navigate-subscriptions="handleKafkaConsumerGroupTopicSelected" />
|
||||||
<RabbitMqMonitoringPanel v-else-if="activeTab === 'monitoring' && isRabbitMqCluster && canClusterMonitor" :connection-id="connectionId" />
|
<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" @navigate-tab="handleNavigateTab" />
|
<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" />
|
<RabbitMqClientsPanel v-else-if="activeTab === 'clients' && isRabbitMqCluster && canManageClientConnections" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApp, h, nextTick, ref, type App } from "vue";
|
||||||
|
import type { KafkaConsumerGroupSnapshot } from "@/types/mq";
|
||||||
|
|
||||||
|
const backend = vi.hoisted(() => ({
|
||||||
|
mqGetKafkaConsumerGroupSnapshot: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("vue-i18n", () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/backend/api", () => backend);
|
||||||
|
|
||||||
|
import KafkaConsumerGroupsPanel from "@/components/mq/KafkaConsumerGroupsPanel.vue";
|
||||||
|
|
||||||
|
const SNAPSHOT: KafkaConsumerGroupSnapshot = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: "payments-worker",
|
||||||
|
state: "STABLE",
|
||||||
|
simpleGroup: false,
|
||||||
|
memberCount: 1,
|
||||||
|
topics: ["payments"],
|
||||||
|
totalLag: 2,
|
||||||
|
lagAvailable: true,
|
||||||
|
partitions: [{ topic: "payments", partition: 0, currentOffset: 8, endOffset: 10, lag: 2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupId: "orders-worker",
|
||||||
|
state: "STABLE",
|
||||||
|
simpleGroup: false,
|
||||||
|
memberCount: 2,
|
||||||
|
topics: ["orders", "orders-archive"],
|
||||||
|
totalLag: 10,
|
||||||
|
lagAvailable: true,
|
||||||
|
partitions: [
|
||||||
|
{ topic: "orders", partition: 0, currentOffset: 5, endOffset: 12, lag: 7 },
|
||||||
|
{ topic: "orders", partition: 1, currentOffset: 6, endOffset: 9, lag: 3 },
|
||||||
|
{ topic: "orders-archive", partition: 0, currentOffset: 4, endOffset: 4, lag: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupId: "unknown-worker",
|
||||||
|
state: "UNKNOWN",
|
||||||
|
simpleGroup: false,
|
||||||
|
topics: ["audit"],
|
||||||
|
lagAvailable: false,
|
||||||
|
partitions: [{ topic: "audit", partition: 0, currentOffset: 4 }],
|
||||||
|
error: "End offsets unavailable for 1 partition(s)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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(onNavigateSubscriptions?: (topic: string) => void) {
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
app = createApp(KafkaConsumerGroupsPanel, { connectionId: "kafka-1", onNavigateSubscriptions });
|
||||||
|
app.mount(root);
|
||||||
|
await flushUi();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
backend.mqGetKafkaConsumerGroupSnapshot.mockReset().mockResolvedValue(SNAPSHOT);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
app?.unmount();
|
||||||
|
app = null;
|
||||||
|
root?.remove();
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KafkaConsumerGroupsPanel", () => {
|
||||||
|
it("loads cluster-wide groups without a selected topic and sorts available lag descending", async () => {
|
||||||
|
const panel = await mountPanel();
|
||||||
|
await vi.waitFor(() => expect(backend.mqGetKafkaConsumerGroupSnapshot).toHaveBeenCalledWith("kafka-1"));
|
||||||
|
|
||||||
|
const rows = [...panel.querySelectorAll<HTMLTableRowElement>("[data-group-id]")];
|
||||||
|
expect(rows.map((row) => row.dataset.groupId)).toEqual(["orders-worker", "payments-worker", "unknown-worker"]);
|
||||||
|
expect(panel.querySelector('[data-testid="kafka-consumer-group-count"]')?.textContent?.trim()).toBe("3 / 3");
|
||||||
|
expect(panel.querySelector('[data-testid="kafka-consumer-group-detail"]')?.textContent).toContain("orders");
|
||||||
|
expect(panel.querySelector('[data-testid="kafka-consumer-group-detail"]')?.textContent).toContain("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the exact topic selected from partition details using keyboard-accessible buttons", async () => {
|
||||||
|
const navigateSubscriptions = vi.fn();
|
||||||
|
const panel = await mountPanel(navigateSubscriptions);
|
||||||
|
await vi.waitFor(() => expect(panel.querySelectorAll("[data-topic]")).toHaveLength(3));
|
||||||
|
|
||||||
|
const ordersLink = panel.querySelector<HTMLButtonElement>('[data-topic="orders"]');
|
||||||
|
const archiveLink = panel.querySelector<HTMLButtonElement>('[data-topic="orders-archive"]');
|
||||||
|
expect(ordersLink?.tagName).toBe("BUTTON");
|
||||||
|
expect(ordersLink?.getAttribute("aria-label")).toBe("mqKafkaConsumerGroups.openTopicSubscriptions");
|
||||||
|
|
||||||
|
ordersLink?.click();
|
||||||
|
archiveLink?.click();
|
||||||
|
expect(navigateSubscriptions).toHaveBeenNthCalledWith(1, "orders");
|
||||||
|
expect(navigateSubscriptions).toHaveBeenNthCalledWith(2, "orders-archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches by topic and keeps unavailable lag visibly distinct from zero", async () => {
|
||||||
|
const panel = await mountPanel();
|
||||||
|
await vi.waitFor(() => expect(panel.querySelectorAll("[data-group-id]")).toHaveLength(3));
|
||||||
|
|
||||||
|
const search = panel.querySelector<HTMLInputElement>('input[type="search"]');
|
||||||
|
expect(search).toBeTruthy();
|
||||||
|
search!.value = "audit";
|
||||||
|
search!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await flushUi();
|
||||||
|
|
||||||
|
const rows = [...panel.querySelectorAll<HTMLTableRowElement>("[data-group-id]")];
|
||||||
|
expect(rows.map((row) => row.dataset.groupId)).toEqual(["unknown-worker"]);
|
||||||
|
expect(rows[0].classList.contains("selected")).toBe(true);
|
||||||
|
expect(rows[0].querySelector("td:last-child")?.textContent?.trim()).toBe("-");
|
||||||
|
expect(rows[0].querySelector(".partial-warning")?.getAttribute("title")).toContain("End offsets unavailable");
|
||||||
|
expect(panel.querySelector('[data-testid="kafka-consumer-group-detail"]')?.textContent).toContain("audit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty and request-error states", async () => {
|
||||||
|
backend.mqGetKafkaConsumerGroupSnapshot.mockResolvedValueOnce({ groups: [] });
|
||||||
|
const emptyPanel = await mountPanel();
|
||||||
|
await vi.waitFor(() => expect(emptyPanel.textContent).toContain("mqKafkaConsumerGroups.empty"));
|
||||||
|
|
||||||
|
app?.unmount();
|
||||||
|
root?.remove();
|
||||||
|
app = null;
|
||||||
|
root = null;
|
||||||
|
backend.mqGetKafkaConsumerGroupSnapshot.mockRejectedValueOnce(new Error("snapshot unavailable"));
|
||||||
|
const errorPanel = await mountPanel();
|
||||||
|
await vi.waitFor(() => expect(errorPanel.textContent).toContain("snapshot unavailable"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale refresh responses", async () => {
|
||||||
|
const first = deferred<KafkaConsumerGroupSnapshot>();
|
||||||
|
backend.mqGetKafkaConsumerGroupSnapshot.mockReturnValueOnce(first.promise).mockResolvedValueOnce({
|
||||||
|
groups: [{ ...SNAPSHOT.groups[0], groupId: "fresh-worker" }],
|
||||||
|
});
|
||||||
|
const connectionId = ref("kafka-1");
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
app = createApp({
|
||||||
|
setup: () => () => h(KafkaConsumerGroupsPanel, { connectionId: connectionId.value }),
|
||||||
|
});
|
||||||
|
app.mount(root);
|
||||||
|
await flushUi();
|
||||||
|
|
||||||
|
connectionId.value = "kafka-2";
|
||||||
|
await flushUi();
|
||||||
|
const panel = root;
|
||||||
|
await vi.waitFor(() => expect(panel.textContent).toContain("fresh-worker"));
|
||||||
|
|
||||||
|
first.resolve(SNAPSHOT);
|
||||||
|
await flushUi();
|
||||||
|
expect(panel.textContent).toContain("fresh-worker");
|
||||||
|
expect(panel.textContent).not.toContain("orders-worker");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7126,6 +7126,7 @@ export default {
|
|||||||
tabNamespaces: "Namespaces",
|
tabNamespaces: "Namespaces",
|
||||||
tabTopics: "Topics",
|
tabTopics: "Topics",
|
||||||
tabSubscriptions: "Subscriptions",
|
tabSubscriptions: "Subscriptions",
|
||||||
|
tabConsumerGroups: "Consumer Groups",
|
||||||
tabMonitoring: "Monitoring",
|
tabMonitoring: "Monitoring",
|
||||||
tabClients: "Clients",
|
tabClients: "Clients",
|
||||||
tabMessages: "Messages",
|
tabMessages: "Messages",
|
||||||
@@ -7510,6 +7511,29 @@ export default {
|
|||||||
subscriptionNameRequired: "Subscription name is required",
|
subscriptionNameRequired: "Subscription name is required",
|
||||||
peekMessageKey: "key={key}",
|
peekMessageKey: "key={key}",
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "Consumer Groups",
|
||||||
|
searchPlaceholder: "Search group or topic",
|
||||||
|
refresh: "Refresh",
|
||||||
|
refreshing: "Refreshing...",
|
||||||
|
loading: "Loading consumer groups...",
|
||||||
|
empty: "No consumer groups",
|
||||||
|
noMatches: "No matching consumer groups",
|
||||||
|
groupId: "Group ID",
|
||||||
|
state: "State",
|
||||||
|
members: "Members",
|
||||||
|
topics: "Topics",
|
||||||
|
totalLag: "Total lag",
|
||||||
|
partialData: "Some metrics are unavailable",
|
||||||
|
partitionDetail: "Partition lag details",
|
||||||
|
noCommittedOffsets: "No committed offsets or assigned partitions",
|
||||||
|
topic: "Topic",
|
||||||
|
openTopicSubscriptions: "Open subscriptions for {topic}",
|
||||||
|
partition: "Partition",
|
||||||
|
committedOffset: "Committed offset",
|
||||||
|
logEndOffset: "Log end offset",
|
||||||
|
lag: "Lag",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka does not support unloading topics",
|
unloadTopicUnsupportedKafka: "Kafka does not support unloading topics",
|
||||||
title: "Producers / Consumers",
|
title: "Producers / Consumers",
|
||||||
|
|||||||
@@ -7065,6 +7065,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "Namespaces",
|
tabNamespaces: "Namespaces",
|
||||||
tabTopics: "Temas",
|
tabTopics: "Temas",
|
||||||
tabSubscriptions: "Suscripciones",
|
tabSubscriptions: "Suscripciones",
|
||||||
|
tabConsumerGroups: "Grupos de consumidores",
|
||||||
tabMonitoring: "Monitoreo",
|
tabMonitoring: "Monitoreo",
|
||||||
tabClients: "Clientes",
|
tabClients: "Clientes",
|
||||||
tabMessages: "Mensajes",
|
tabMessages: "Mensajes",
|
||||||
@@ -7445,6 +7446,29 @@ export default withEnglishFallback({
|
|||||||
broadcasting: "Difusión",
|
broadcasting: "Difusión",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "Grupos de consumidores",
|
||||||
|
searchPlaceholder: "Buscar grupo o tema",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
refreshing: "Actualizando...",
|
||||||
|
loading: "Cargando grupos de consumidores...",
|
||||||
|
empty: "No hay grupos de consumidores",
|
||||||
|
noMatches: "No hay grupos coincidentes",
|
||||||
|
groupId: "ID del grupo",
|
||||||
|
state: "Estado",
|
||||||
|
members: "Miembros",
|
||||||
|
topics: "Temas",
|
||||||
|
totalLag: "Retraso total",
|
||||||
|
partialData: "Algunas métricas no están disponibles",
|
||||||
|
partitionDetail: "Detalles del retraso por partición",
|
||||||
|
noCommittedOffsets: "No hay offsets confirmados ni particiones asignadas",
|
||||||
|
topic: "Tema",
|
||||||
|
openTopicSubscriptions: "Abrir suscripciones de {topic}",
|
||||||
|
partition: "Partición",
|
||||||
|
committedOffset: "Offset confirmado",
|
||||||
|
logEndOffset: "Offset final del log",
|
||||||
|
lag: "Retraso",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka no admite la descarga de temas",
|
unloadTopicUnsupportedKafka: "Kafka no admite la descarga de temas",
|
||||||
title: "Productores / Consumidores",
|
title: "Productores / Consumidores",
|
||||||
|
|||||||
@@ -7065,6 +7065,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "Namespace",
|
tabNamespaces: "Namespace",
|
||||||
tabTopics: "Topic",
|
tabTopics: "Topic",
|
||||||
tabSubscriptions: "Sottoscrizioni",
|
tabSubscriptions: "Sottoscrizioni",
|
||||||
|
tabConsumerGroups: "Gruppi di consumatori",
|
||||||
tabMonitoring: "Monitoraggio",
|
tabMonitoring: "Monitoraggio",
|
||||||
tabClients: "Clienti",
|
tabClients: "Clienti",
|
||||||
tabMessages: "Messaggi",
|
tabMessages: "Messaggi",
|
||||||
@@ -7445,6 +7446,29 @@ export default withEnglishFallback({
|
|||||||
broadcasting: "Broadcast",
|
broadcasting: "Broadcast",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "Gruppi di consumatori",
|
||||||
|
searchPlaceholder: "Cerca gruppo o argomento",
|
||||||
|
refresh: "Aggiorna",
|
||||||
|
refreshing: "Aggiornamento...",
|
||||||
|
loading: "Caricamento gruppi di consumatori...",
|
||||||
|
empty: "Nessun gruppo di consumatori",
|
||||||
|
noMatches: "Nessun gruppo corrispondente",
|
||||||
|
groupId: "ID gruppo",
|
||||||
|
state: "Stato",
|
||||||
|
members: "Membri",
|
||||||
|
topics: "Argomenti",
|
||||||
|
totalLag: "Ritardo totale",
|
||||||
|
partialData: "Alcune metriche non sono disponibili",
|
||||||
|
partitionDetail: "Dettagli ritardo partizioni",
|
||||||
|
noCommittedOffsets: "Nessun offset confermato o partizione assegnata",
|
||||||
|
topic: "Argomento",
|
||||||
|
openTopicSubscriptions: "Apri le sottoscrizioni di {topic}",
|
||||||
|
partition: "Partizione",
|
||||||
|
committedOffset: "Offset confermato",
|
||||||
|
logEndOffset: "Offset finale del log",
|
||||||
|
lag: "Ritardo",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka non supporta lo scaricamento dei topic",
|
unloadTopicUnsupportedKafka: "Kafka non supporta lo scaricamento dei topic",
|
||||||
title: "Produttori / Consumatori",
|
title: "Produttori / Consumatori",
|
||||||
|
|||||||
@@ -7118,6 +7118,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "名前空間",
|
tabNamespaces: "名前空間",
|
||||||
tabTopics: "トピック",
|
tabTopics: "トピック",
|
||||||
tabSubscriptions: "サブスクリプション",
|
tabSubscriptions: "サブスクリプション",
|
||||||
|
tabConsumerGroups: "コンシューマーグループ",
|
||||||
tabMonitoring: "モニタリング",
|
tabMonitoring: "モニタリング",
|
||||||
tabClients: "クライアント",
|
tabClients: "クライアント",
|
||||||
tabMessages: "メッセージ",
|
tabMessages: "メッセージ",
|
||||||
@@ -7498,6 +7499,29 @@ export default withEnglishFallback({
|
|||||||
broadcasting: "ブロードキャスト",
|
broadcasting: "ブロードキャスト",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "コンシューマーグループ",
|
||||||
|
searchPlaceholder: "グループまたはトピックを検索",
|
||||||
|
refresh: "更新",
|
||||||
|
refreshing: "更新中...",
|
||||||
|
loading: "コンシューマーグループを読み込み中...",
|
||||||
|
empty: "コンシューマーグループがありません",
|
||||||
|
noMatches: "一致するコンシューマーグループがありません",
|
||||||
|
groupId: "グループ ID",
|
||||||
|
state: "状態",
|
||||||
|
members: "メンバー",
|
||||||
|
topics: "トピック",
|
||||||
|
totalLag: "合計ラグ",
|
||||||
|
partialData: "一部のメトリクスを取得できません",
|
||||||
|
partitionDetail: "パーティション別ラグ",
|
||||||
|
noCommittedOffsets: "コミット済みオフセットまたは割り当て済みパーティションがありません",
|
||||||
|
topic: "トピック",
|
||||||
|
openTopicSubscriptions: "{topic} のサブスクリプションを開く",
|
||||||
|
partition: "パーティション",
|
||||||
|
committedOffset: "コミット済みオフセット",
|
||||||
|
logEndOffset: "ログ末尾オフセット",
|
||||||
|
lag: "ラグ",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka はトピックのアンロードに対応していません",
|
unloadTopicUnsupportedKafka: "Kafka はトピックのアンロードに対応していません",
|
||||||
title: "プロデューサー / コンシューマー",
|
title: "プロデューサー / コンシューマー",
|
||||||
|
|||||||
@@ -6536,6 +6536,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "네임스페이스",
|
tabNamespaces: "네임스페이스",
|
||||||
tabTopics: "토픽",
|
tabTopics: "토픽",
|
||||||
tabSubscriptions: "구독",
|
tabSubscriptions: "구독",
|
||||||
|
tabConsumerGroups: "컨슈머 그룹",
|
||||||
tabMonitoring: "모니터링",
|
tabMonitoring: "모니터링",
|
||||||
tabClients: "클라이언트",
|
tabClients: "클라이언트",
|
||||||
tabMessages: "메시지",
|
tabMessages: "메시지",
|
||||||
@@ -6916,6 +6917,29 @@ export default withEnglishFallback({
|
|||||||
subscriptionNameRequired: "구독 이름은 필수입니다",
|
subscriptionNameRequired: "구독 이름은 필수입니다",
|
||||||
peekMessageKey: "키={key}",
|
peekMessageKey: "키={key}",
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "컨슈머 그룹",
|
||||||
|
searchPlaceholder: "그룹 또는 토픽 검색",
|
||||||
|
refresh: "새로 고침",
|
||||||
|
refreshing: "새로 고치는 중...",
|
||||||
|
loading: "컨슈머 그룹을 불러오는 중...",
|
||||||
|
empty: "컨슈머 그룹이 없습니다",
|
||||||
|
noMatches: "일치하는 컨슈머 그룹이 없습니다",
|
||||||
|
groupId: "그룹 ID",
|
||||||
|
state: "상태",
|
||||||
|
members: "멤버",
|
||||||
|
topics: "토픽",
|
||||||
|
totalLag: "총 지연",
|
||||||
|
partialData: "일부 지표를 사용할 수 없습니다",
|
||||||
|
partitionDetail: "파티션 지연 상세",
|
||||||
|
noCommittedOffsets: "커밋된 오프셋 또는 할당된 파티션이 없습니다",
|
||||||
|
topic: "토픽",
|
||||||
|
openTopicSubscriptions: "{topic} 구독 열기",
|
||||||
|
partition: "파티션",
|
||||||
|
committedOffset: "커밋된 오프셋",
|
||||||
|
logEndOffset: "로그 끝 오프셋",
|
||||||
|
lag: "지연",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka는 토픽 언로드를 지원하지 않습니다",
|
unloadTopicUnsupportedKafka: "Kafka는 토픽 언로드를 지원하지 않습니다",
|
||||||
title: "프로듀서 / 컨슈머",
|
title: "프로듀서 / 컨슈머",
|
||||||
|
|||||||
@@ -7067,6 +7067,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "Namespaces",
|
tabNamespaces: "Namespaces",
|
||||||
tabTopics: "Tópicos",
|
tabTopics: "Tópicos",
|
||||||
tabSubscriptions: "Assinaturas",
|
tabSubscriptions: "Assinaturas",
|
||||||
|
tabConsumerGroups: "Grupos de consumidores",
|
||||||
tabMonitoring: "Monitoramento",
|
tabMonitoring: "Monitoramento",
|
||||||
tabClients: "Clientes",
|
tabClients: "Clientes",
|
||||||
tabMessages: "Mensagens",
|
tabMessages: "Mensagens",
|
||||||
@@ -7447,6 +7448,29 @@ export default withEnglishFallback({
|
|||||||
broadcasting: "Broadcast",
|
broadcasting: "Broadcast",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "Grupos de consumidores",
|
||||||
|
searchPlaceholder: "Pesquisar grupo ou tópico",
|
||||||
|
refresh: "Atualizar",
|
||||||
|
refreshing: "Atualizando...",
|
||||||
|
loading: "Carregando grupos de consumidores...",
|
||||||
|
empty: "Nenhum grupo de consumidores",
|
||||||
|
noMatches: "Nenhum grupo correspondente",
|
||||||
|
groupId: "ID do grupo",
|
||||||
|
state: "Estado",
|
||||||
|
members: "Membros",
|
||||||
|
topics: "Tópicos",
|
||||||
|
totalLag: "Atraso total",
|
||||||
|
partialData: "Algumas métricas não estão disponíveis",
|
||||||
|
partitionDetail: "Detalhes do atraso por partição",
|
||||||
|
noCommittedOffsets: "Nenhum offset confirmado ou partição atribuída",
|
||||||
|
topic: "Tópico",
|
||||||
|
openTopicSubscriptions: "Abrir assinaturas de {topic}",
|
||||||
|
partition: "Partição",
|
||||||
|
committedOffset: "Offset confirmado",
|
||||||
|
logEndOffset: "Offset final do log",
|
||||||
|
lag: "Atraso",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "O Kafka não suporta o descarregamento de tópicos",
|
unloadTopicUnsupportedKafka: "O Kafka não suporta o descarregamento de tópicos",
|
||||||
title: "Produtores / Consumidores",
|
title: "Produtores / Consumidores",
|
||||||
|
|||||||
@@ -7111,6 +7111,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "命名空间",
|
tabNamespaces: "命名空间",
|
||||||
tabTopics: "主题",
|
tabTopics: "主题",
|
||||||
tabSubscriptions: "订阅",
|
tabSubscriptions: "订阅",
|
||||||
|
tabConsumerGroups: "消费组",
|
||||||
tabMonitoring: "监控",
|
tabMonitoring: "监控",
|
||||||
tabClients: "客户端",
|
tabClients: "客户端",
|
||||||
tabMessages: "消息",
|
tabMessages: "消息",
|
||||||
@@ -7495,6 +7496,29 @@ export default withEnglishFallback({
|
|||||||
subscriptionNameRequired: "订阅名称不能为空",
|
subscriptionNameRequired: "订阅名称不能为空",
|
||||||
peekMessageKey: "key={key}",
|
peekMessageKey: "key={key}",
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "消费组",
|
||||||
|
searchPlaceholder: "搜索消费组或 Topic",
|
||||||
|
refresh: "刷新",
|
||||||
|
refreshing: "刷新中...",
|
||||||
|
loading: "正在加载消费组...",
|
||||||
|
empty: "暂无消费组",
|
||||||
|
noMatches: "没有匹配的消费组",
|
||||||
|
groupId: "消费组 ID",
|
||||||
|
state: "状态",
|
||||||
|
members: "在线成员",
|
||||||
|
topics: "Topic",
|
||||||
|
totalLag: "总积压",
|
||||||
|
partialData: "部分指标暂不可用",
|
||||||
|
partitionDetail: "分区积压详情",
|
||||||
|
noCommittedOffsets: "暂无已提交 Offset 或已分配分区",
|
||||||
|
topic: "Topic",
|
||||||
|
openTopicSubscriptions: "打开 {topic} 的订阅",
|
||||||
|
partition: "分区",
|
||||||
|
committedOffset: "已提交 Offset",
|
||||||
|
logEndOffset: "Log End Offset",
|
||||||
|
lag: "积压",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka 不支持卸载主题",
|
unloadTopicUnsupportedKafka: "Kafka 不支持卸载主题",
|
||||||
title: "生产者 / 消费者",
|
title: "生产者 / 消费者",
|
||||||
|
|||||||
@@ -7058,6 +7058,7 @@ export default withEnglishFallback({
|
|||||||
tabNamespaces: "命名空間",
|
tabNamespaces: "命名空間",
|
||||||
tabTopics: "主題",
|
tabTopics: "主題",
|
||||||
tabSubscriptions: "訂閱",
|
tabSubscriptions: "訂閱",
|
||||||
|
tabConsumerGroups: "消費群組",
|
||||||
tabMonitoring: "監控",
|
tabMonitoring: "監控",
|
||||||
tabClients: "用戶端",
|
tabClients: "用戶端",
|
||||||
tabMessages: "訊息",
|
tabMessages: "訊息",
|
||||||
@@ -7438,6 +7439,29 @@ export default withEnglishFallback({
|
|||||||
broadcasting: "廣播",
|
broadcasting: "廣播",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mqKafkaConsumerGroups: {
|
||||||
|
title: "消費群組",
|
||||||
|
searchPlaceholder: "搜尋消費群組或 Topic",
|
||||||
|
refresh: "重新整理",
|
||||||
|
refreshing: "重新整理中...",
|
||||||
|
loading: "正在載入消費群組...",
|
||||||
|
empty: "暫無消費群組",
|
||||||
|
noMatches: "沒有符合的消費群組",
|
||||||
|
groupId: "消費群組 ID",
|
||||||
|
state: "狀態",
|
||||||
|
members: "線上成員",
|
||||||
|
topics: "Topic",
|
||||||
|
totalLag: "總積壓",
|
||||||
|
partialData: "部分指標暫不可用",
|
||||||
|
partitionDetail: "分區積壓詳情",
|
||||||
|
noCommittedOffsets: "暫無已提交 Offset 或已分配分區",
|
||||||
|
topic: "Topic",
|
||||||
|
openTopicSubscriptions: "開啟 {topic} 的訂閱",
|
||||||
|
partition: "分區",
|
||||||
|
committedOffset: "已提交 Offset",
|
||||||
|
logEndOffset: "Log End Offset",
|
||||||
|
lag: "積壓",
|
||||||
|
},
|
||||||
mqClients: {
|
mqClients: {
|
||||||
unloadTopicUnsupportedKafka: "Kafka 不支援卸載主題",
|
unloadTopicUnsupportedKafka: "Kafka 不支援卸載主題",
|
||||||
title: "生產者 / 消費者",
|
title: "生產者 / 消費者",
|
||||||
|
|||||||
@@ -42,4 +42,22 @@ describe("MQ list panels", () => {
|
|||||||
expect(consoleSource).toContain(':namespace="selectedNamespace"');
|
expect(consoleSource).toContain(':namespace="selectedNamespace"');
|
||||||
expect(consoleSource).toContain(':topic="selectedTopic"');
|
expect(consoleSource).toContain(':topic="selectedTopic"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Kafka topic subscriptions and cluster-wide consumer groups on separate panels", () => {
|
||||||
|
const consoleSource = componentSource("MqAdminConsole.vue");
|
||||||
|
|
||||||
|
expect(consoleSource).toContain("KafkaConsumerGroupsPanel");
|
||||||
|
expect(consoleSource).toContain("activeTab === 'subscriptions' && canManageSubscriptions");
|
||||||
|
expect(consoleSource).toContain("activeTab === 'consumerGroups' && isKafkaCluster");
|
||||||
|
expect(consoleSource).not.toContain("activeTab === 'subscriptions' && canManageSubscriptions && isKafkaCluster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a Kafka consumer-group partition topic to its topic-scoped subscriptions", () => {
|
||||||
|
const consoleSource = componentSource("MqAdminConsole.vue");
|
||||||
|
|
||||||
|
expect(consoleSource).toContain("function handleKafkaConsumerGroupTopicSelected(topic: string)");
|
||||||
|
expect(consoleSource).toContain("shortName: topic");
|
||||||
|
expect(consoleSource).toContain('setActiveTab("subscriptions")');
|
||||||
|
expect(consoleSource).toContain('@navigate-subscriptions="handleKafkaConsumerGroupTopicSelected"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -639,6 +639,7 @@ export const mqListClientChannels = forward("mqListClientChannels");
|
|||||||
export const mqCloseClientConnection = forward("mqCloseClientConnection");
|
export const mqCloseClientConnection = forward("mqCloseClientConnection");
|
||||||
export const mqListSubscriptions = forward("mqListSubscriptions");
|
export const mqListSubscriptions = forward("mqListSubscriptions");
|
||||||
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
|
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
|
||||||
|
export const mqGetKafkaConsumerGroupSnapshot = forward("mqGetKafkaConsumerGroupSnapshot");
|
||||||
export const mqCreateSubscription = forward("mqCreateSubscription");
|
export const mqCreateSubscription = forward("mqCreateSubscription");
|
||||||
export const mqDeleteSubscription = forward("mqDeleteSubscription");
|
export const mqDeleteSubscription = forward("mqDeleteSubscription");
|
||||||
export const mqSkipMessages = forward("mqSkipMessages");
|
export const mqSkipMessages = forward("mqSkipMessages");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ListTopicsOpts,
|
ListTopicsOpts,
|
||||||
TopicStats,
|
TopicStats,
|
||||||
SubscriptionInfo,
|
SubscriptionInfo,
|
||||||
|
KafkaConsumerGroupSnapshot,
|
||||||
ResetPosition,
|
ResetPosition,
|
||||||
SkipCount,
|
SkipCount,
|
||||||
ConsumerInfo,
|
ConsumerInfo,
|
||||||
@@ -221,6 +222,10 @@ export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRe
|
|||||||
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
|
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function mqGetKafkaConsumerGroupSnapshot(connectionId: string): Promise<KafkaConsumerGroupSnapshot> {
|
||||||
|
return post("/api/mq/kafka/consumer-groups", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||||
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
|
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ListTopicsOpts,
|
ListTopicsOpts,
|
||||||
TopicStats,
|
TopicStats,
|
||||||
SubscriptionInfo,
|
SubscriptionInfo,
|
||||||
|
KafkaConsumerGroupSnapshot,
|
||||||
ResetPosition,
|
ResetPosition,
|
||||||
SkipCount,
|
SkipCount,
|
||||||
ConsumerInfo,
|
ConsumerInfo,
|
||||||
@@ -215,6 +216,10 @@ export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRe
|
|||||||
return invoke("mq_enrich_subscriptions", { connectionId, topic });
|
return invoke("mq_enrich_subscriptions", { connectionId, topic });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function mqGetKafkaConsumerGroupSnapshot(connectionId: string): Promise<KafkaConsumerGroupSnapshot> {
|
||||||
|
return invoke("mq_get_kafka_consumer_group_snapshot", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||||
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
|
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ describe("mqConsoleDefaults", () => {
|
|||||||
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: caps })).toContain("messages");
|
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: caps })).toContain("messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Kafka topic subscriptions alongside the cluster-wide consumer groups tab", () => {
|
||||||
|
const tabs = resolveAvailableMqTabs({
|
||||||
|
systemKind: "kafka",
|
||||||
|
capabilities: defaultMqCapabilitiesForSystemKind("kafka"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tabs).toContain("subscriptions");
|
||||||
|
expect(tabs).toContain("consumerGroups");
|
||||||
|
expect(tabs.indexOf("consumerGroups")).toBe(tabs.indexOf("subscriptions") + 1);
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes the namespaces tab for RabbitMQ vhost management", () => {
|
it("exposes the namespaces tab for RabbitMQ vhost management", () => {
|
||||||
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
|
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
|
||||||
expect(resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: caps })).toEqual(["namespaces", "topics", "subscriptions", "monitoring", "clients", "messages", "broker", "policies", "permissions"]);
|
expect(resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: caps })).toEqual(["namespaces", "topics", "subscriptions", "monitoring", "clients", "messages", "broker", "policies", "permissions"]);
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function resolveRabbitMqDefaultVhost(config: ConnectionConfig | undefined
|
|||||||
return typeof vhost === "string" && vhost.trim() ? vhost.trim() : "/";
|
return typeof vhost === "string" && vhost.trim() ? vhost.trim() : "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "producers" | "policies" | "permissions" | "messages" | "raw" | "broker" | "dlq" | "trace";
|
export type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "consumerGroups" | "monitoring" | "clients" | "producers" | "policies" | "permissions" | "messages" | "raw" | "broker" | "dlq" | "trace";
|
||||||
|
|
||||||
export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; capabilities: MqCapabilities }): MqTab[] {
|
export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; capabilities: MqCapabilities }): MqTab[] {
|
||||||
const { systemKind, capabilities } = options;
|
const { systemKind, capabilities } = options;
|
||||||
@@ -174,7 +174,10 @@ export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; cap
|
|||||||
if (capabilities.supportsTenants) tabs.push("tenants");
|
if (capabilities.supportsTenants) tabs.push("tenants");
|
||||||
if (capabilities.supportsNamespaces) tabs.push("namespaces");
|
if (capabilities.supportsNamespaces) tabs.push("namespaces");
|
||||||
tabs.push("topics");
|
tabs.push("topics");
|
||||||
if (capabilities.supportsSubscriptions) tabs.push("subscriptions");
|
if (capabilities.supportsSubscriptions) {
|
||||||
|
tabs.push("subscriptions");
|
||||||
|
if (systemKind === "kafka") tabs.push("consumerGroups");
|
||||||
|
}
|
||||||
tabs.push("monitoring");
|
tabs.push("monitoring");
|
||||||
tabs.push("clients");
|
tabs.push("clients");
|
||||||
if (capabilities.supportsSendMessage || (systemKind === "kafka" && capabilities.supportsPeekMessages)) tabs.push("messages");
|
if (capabilities.supportsSendMessage || (systemKind === "kafka" && capabilities.supportsPeekMessages)) tabs.push("messages");
|
||||||
|
|||||||
@@ -222,6 +222,30 @@ export interface SubscriptionInfo {
|
|||||||
backlogUnavailable?: boolean;
|
backlogUnavailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KafkaConsumerGroupPartitionLag {
|
||||||
|
topic: string;
|
||||||
|
partition: number;
|
||||||
|
currentOffset?: number;
|
||||||
|
endOffset?: number;
|
||||||
|
lag?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KafkaConsumerGroupSummary {
|
||||||
|
groupId: string;
|
||||||
|
state: string;
|
||||||
|
simpleGroup: boolean;
|
||||||
|
memberCount?: number;
|
||||||
|
topics: string[];
|
||||||
|
totalLag?: number;
|
||||||
|
lagAvailable: boolean;
|
||||||
|
partitions: KafkaConsumerGroupPartitionLag[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KafkaConsumerGroupSnapshot {
|
||||||
|
groups: KafkaConsumerGroupSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RocketMqConsumerGroupConfig {
|
export interface RocketMqConsumerGroupConfig {
|
||||||
groupName: string;
|
groupName: string;
|
||||||
consumeEnable: boolean;
|
consumeEnable: boolean;
|
||||||
|
|||||||
@@ -301,6 +301,10 @@ impl MessageQueueAdmin for KafkaAdmin {
|
|||||||
Ok(subs)
|
Ok(subs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_kafka_consumer_group_snapshot(&self) -> Result<KafkaConsumerGroupSnapshot, String> {
|
||||||
|
self.call("mq_get_consumer_group_snapshot", consumer_group_snapshot_params(&self.config)).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
|
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
|
||||||
Err("Kafka consumer groups are created automatically when consumers join".to_string())
|
Err("Kafka consumer groups are created automatically when consumers join".to_string())
|
||||||
}
|
}
|
||||||
@@ -657,6 +661,12 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn consumer_group_snapshot_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"timeout_ms": cfg.request_timeout_ms(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn peek_messages_params(
|
fn peek_messages_params(
|
||||||
cfg: &MqAdminConfig,
|
cfg: &MqAdminConfig,
|
||||||
topic: &TopicRef,
|
topic: &TopicRef,
|
||||||
@@ -864,6 +874,28 @@ mod tests {
|
|||||||
assert_eq!(params.get("offset").and_then(|value| value.as_i64()), Some(17));
|
assert_eq!(params.get("offset").and_then(|value| value.as_i64()), Some(17));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumer_group_snapshot_params_forward_the_configured_query_timeout() {
|
||||||
|
let mut cfg = kafka_config(serde_json::json!({ "bootstrapServers": "broker:9092" }), MqAuth::None, false);
|
||||||
|
cfg.query_timeout_secs = 17;
|
||||||
|
|
||||||
|
let params = consumer_group_snapshot_params(&cfg);
|
||||||
|
|
||||||
|
assert_eq!(params.get("timeout_ms").and_then(serde_json::Value::as_u64), Some(17_000));
|
||||||
|
assert_eq!(cfg.rpc_timeout(), Some(std::time::Duration::from_secs(17)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumer_group_snapshot_params_keep_a_finite_agent_budget_for_unlimited_rpc_timeout() {
|
||||||
|
let mut cfg = kafka_config(serde_json::json!({ "bootstrapServers": "broker:9092" }), MqAuth::None, false);
|
||||||
|
cfg.query_timeout_secs = 0;
|
||||||
|
|
||||||
|
let params = consumer_group_snapshot_params(&cfg);
|
||||||
|
|
||||||
|
assert_eq!(params.get("timeout_ms").and_then(serde_json::Value::as_u64), Some(3_600_000));
|
||||||
|
assert_eq!(cfg.rpc_timeout(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peek_message_params_omit_legacy_optional_fields() {
|
fn peek_message_params_omit_legacy_optional_fields() {
|
||||||
let cfg = kafka_config(serde_json::json!({ "bootstrapServers": "broker:9092" }), MqAuth::None, false);
|
let cfg = kafka_config(serde_json::json!({ "bootstrapServers": "broker:9092" }), MqAuth::None, false);
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ pub trait MessageQueueAdmin: Send + Sync {
|
|||||||
self.list_subscriptions(topic).await
|
self.list_subscriptions(topic).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kafka-only cluster-wide consumer group snapshot. Other MQ systems keep
|
||||||
|
/// the default unsupported response instead of leaking Kafka concepts into
|
||||||
|
/// their existing subscription APIs.
|
||||||
|
async fn get_kafka_consumer_group_snapshot(&self) -> Result<KafkaConsumerGroupSnapshot, String> {
|
||||||
|
Err("Kafka consumer group snapshots are not supported by this MQ system".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
|
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
|
||||||
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
|
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
|
||||||
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
|
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
|
||||||
|
|||||||
@@ -399,6 +399,14 @@ pub async fn mq_enrich_subscriptions_core(
|
|||||||
adapter.enrich_subscriptions(&topic).await
|
adapter.enrich_subscriptions(&topic).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn mq_get_kafka_consumer_group_snapshot_core(
|
||||||
|
state: &AppState,
|
||||||
|
conn_id: &str,
|
||||||
|
) -> Result<KafkaConsumerGroupSnapshot, String> {
|
||||||
|
let adapter = get_adapter(state, conn_id).await?;
|
||||||
|
adapter.get_kafka_consumer_group_snapshot().await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn mq_create_subscription_core(
|
pub async fn mq_create_subscription_core(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn_id: &str,
|
conn_id: &str,
|
||||||
|
|||||||
@@ -465,6 +465,50 @@ pub struct SubscriptionInfo {
|
|||||||
pub backlog_unavailable: Option<bool>,
|
pub backlog_unavailable: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Kafka topic-partition's committed and log-end offsets for a consumer group.
|
||||||
|
/// Optional offsets distinguish unavailable data from a healthy zero lag.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KafkaConsumerGroupPartitionLag {
|
||||||
|
pub topic: String,
|
||||||
|
pub partition: i32,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_offset: Option<i64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub end_offset: Option<i64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lag: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cluster-wide Kafka consumer group summary with its partition lag details.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KafkaConsumerGroupSummary {
|
||||||
|
pub group_id: String,
|
||||||
|
pub state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub simple_group: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub member_count: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub topics: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total_lag: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lag_available: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub partitions: Vec<KafkaConsumerGroupPartitionLag>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KafkaConsumerGroupSnapshot {
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups: Vec<KafkaConsumerGroupSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ConsumerInfo {
|
pub struct ConsumerInfo {
|
||||||
@@ -521,6 +565,41 @@ mod reset_position_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod kafka_consumer_group_snapshot_tests {
|
||||||
|
use super::KafkaConsumerGroupSnapshot;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_unavailable_offsets_as_none() {
|
||||||
|
let snapshot: KafkaConsumerGroupSnapshot = serde_json::from_value(serde_json::json!({
|
||||||
|
"groups": [{
|
||||||
|
"groupId": "orders-service",
|
||||||
|
"state": "STABLE",
|
||||||
|
"simpleGroup": false,
|
||||||
|
"memberCount": 1,
|
||||||
|
"topics": ["orders"],
|
||||||
|
"totalLag": null,
|
||||||
|
"lagAvailable": false,
|
||||||
|
"partitions": [{
|
||||||
|
"topic": "orders",
|
||||||
|
"partition": 0,
|
||||||
|
"currentOffset": 8,
|
||||||
|
"endOffset": null,
|
||||||
|
"lag": null
|
||||||
|
}],
|
||||||
|
"error": "End offsets unavailable for 1 partition(s)"
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.expect("deserialize Kafka consumer group snapshot");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.groups[0].total_lag, None);
|
||||||
|
assert!(!snapshot.groups[0].lag_available);
|
||||||
|
assert_eq!(snapshot.groups[0].partitions[0].current_offset, Some(8));
|
||||||
|
assert_eq!(snapshot.groups[0].partitions[0].end_offset, None);
|
||||||
|
assert_eq!(snapshot.groups[0].partitions[0].lag, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How many messages to skip on a subscription.
|
/// How many messages to skip on a subscription.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
|||||||
.route("/mq/messages/trace", post(routes::mq::query_message_trace))
|
.route("/mq/messages/trace", post(routes::mq::query_message_trace))
|
||||||
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
|
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
|
||||||
.route("/mq/subscriptions/enrich", post(routes::mq::enrich_subscriptions))
|
.route("/mq/subscriptions/enrich", post(routes::mq::enrich_subscriptions))
|
||||||
|
.route("/mq/kafka/consumer-groups", post(routes::mq::get_kafka_consumer_group_snapshot))
|
||||||
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
|
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
|
||||||
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
|
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
|
||||||
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
|
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
|
||||||
|
|||||||
@@ -763,6 +763,18 @@ pub async fn enrich_subscriptions(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_kafka_consumer_group_snapshot(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<ConnReq>,
|
||||||
|
) -> Result<Json<dbx_core::mq::KafkaConsumerGroupSnapshot>, AppError> {
|
||||||
|
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||||
|
let result = dbx_core::mq::service::mq_get_kafka_consumer_group_snapshot_core(&state.app, &req.connection_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_subscription(
|
pub async fn create_subscription(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|||||||
@@ -276,6 +276,14 @@ pub async fn mq_enrich_subscriptions(
|
|||||||
dbx_core::mq::service::mq_enrich_subscriptions_core(&state, &connection_id, topic).await
|
dbx_core::mq::service::mq_enrich_subscriptions_core(&state, &connection_id, topic).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mq_get_kafka_consumer_group_snapshot(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<dbx_core::mq::KafkaConsumerGroupSnapshot, String> {
|
||||||
|
dbx_core::mq::service::mq_get_kafka_consumer_group_snapshot_core(&state, &connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn mq_create_subscription(
|
pub async fn mq_create_subscription(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
|
|||||||
@@ -2107,6 +2107,8 @@ pub fn run() {
|
|||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
commands::mq_cmd::mq_enrich_subscriptions,
|
commands::mq_cmd::mq_enrich_subscriptions,
|
||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
|
commands::mq_cmd::mq_get_kafka_consumer_group_snapshot,
|
||||||
|
#[cfg(feature = "mq-admin")]
|
||||||
commands::mq_cmd::mq_create_subscription,
|
commands::mq_cmd::mq_create_subscription,
|
||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
commands::mq_cmd::mq_delete_subscription,
|
commands::mq_cmd::mq_delete_subscription,
|
||||||
|
|||||||
Reference in new issue
Block a user