Topic -> 消费详情
This commit is contained in:
@@ -116,4 +116,9 @@ public class ConsumerController {
|
|||||||
public Object getSubscribeTopicList(@RequestParam String groupId) {
|
public Object getSubscribeTopicList(@RequestParam String groupId) {
|
||||||
return consumerService.getSubscribeTopicList(groupId);
|
return consumerService.getSubscribeTopicList(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/topic/subscribed")
|
||||||
|
public Object getTopicSubscribedByGroups(@RequestParam String topic) {
|
||||||
|
return consumerService.getTopicSubscribedByGroups(topic);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,6 @@ public interface ConsumerService {
|
|||||||
ResponseData getGroupIdList();
|
ResponseData getGroupIdList();
|
||||||
|
|
||||||
ResponseData getSubscribeTopicList(String groupId);
|
ResponseData getSubscribeTopicList(String groupId);
|
||||||
|
|
||||||
|
ResponseData getTopicSubscribedByGroups(String topic);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public class ConsumerServiceImpl implements ConsumerService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ConsumerConsole consumerConsole;
|
private ConsumerConsole consumerConsole;
|
||||||
|
|
||||||
|
private TopicSubscribedInfo topicSubscribedInfo = new TopicSubscribedInfo();
|
||||||
|
|
||||||
@Override public ResponseData getConsumerGroupList(List<String> groupIds, Set<ConsumerGroupState> states) {
|
@Override public ResponseData getConsumerGroupList(List<String> groupIds, Set<ConsumerGroupState> states) {
|
||||||
String simulateGroup = "inner_xxx_not_exit_group_###" + System.currentTimeMillis();
|
String simulateGroup = "inner_xxx_not_exit_group_###" + System.currentTimeMillis();
|
||||||
Set<String> groupList = new HashSet<>();
|
Set<String> groupList = new HashSet<>();
|
||||||
@@ -142,4 +144,63 @@ public class ConsumerServiceImpl implements ConsumerService {
|
|||||||
@Override public ResponseData getSubscribeTopicList(String groupId) {
|
@Override public ResponseData getSubscribeTopicList(String groupId) {
|
||||||
return ResponseData.create().data(consumerConsole.listSubscribeTopics(groupId).keySet()).success();
|
return ResponseData.create().data(consumerConsole.listSubscribeTopics(groupId).keySet()).success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override public ResponseData getTopicSubscribedByGroups(String topic) {
|
||||||
|
if (topicSubscribedInfo.isNeedRefresh(topic)) {
|
||||||
|
Set<String> groupIdList = consumerConsole.getConsumerGroupIdList(Collections.emptySet());
|
||||||
|
Map<String, Set<String>> cache = new HashMap<>();
|
||||||
|
Map<String, List<TopicPartition>> subscribeTopics = consumerConsole.listSubscribeTopics(groupIdList);
|
||||||
|
|
||||||
|
subscribeTopics.forEach((groupId, tl) -> {
|
||||||
|
tl.forEach(topicPartition -> {
|
||||||
|
String t = topicPartition.topic();
|
||||||
|
if (!cache.containsKey(t)) {
|
||||||
|
cache.put(t, new HashSet<>());
|
||||||
|
}
|
||||||
|
cache.get(t).add(groupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
topicSubscribedInfo.refresh(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> groups = topicSubscribedInfo.getSubscribedGroups(topic);
|
||||||
|
|
||||||
|
Map<String, Object> res = new HashMap<>();
|
||||||
|
Collection<ConsumerConsole.TopicPartitionConsumeInfo> consumerDetail = consumerConsole.getConsumerDetail(groups);
|
||||||
|
List<ConsumerDetailVO> collect = consumerDetail.stream().filter(c -> topic.equals(c.topicPartition().topic())).map(ConsumerDetailVO::from).collect(Collectors.toList());
|
||||||
|
Map<String, List<ConsumerDetailVO>> map = collect.stream().collect(Collectors.groupingBy(ConsumerDetailVO::getGroupId));
|
||||||
|
|
||||||
|
map.forEach((groupId, list) -> {
|
||||||
|
Map<String, Object> sorting = new HashMap<>();
|
||||||
|
Collections.sort(list);
|
||||||
|
sorting.put("data", list);
|
||||||
|
sorting.put("lag", list.stream().map(ConsumerDetailVO::getLag).reduce(Long::sum));
|
||||||
|
res.put(groupId, sorting);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseData.create().data(res).success();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TopicSubscribedInfo {
|
||||||
|
long lastTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
long refreshThreshold = 120 * 1000;
|
||||||
|
|
||||||
|
Map<String, Set<String>> cache = new HashMap<>();
|
||||||
|
|
||||||
|
public void refresh(Map<String, Set<String>> newCache) {
|
||||||
|
cache = newCache;
|
||||||
|
lastTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getSubscribedGroups(String topic) {
|
||||||
|
return cache.getOrDefault(topic, Collections.emptySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNeedRefresh(String topic) {
|
||||||
|
return System.currentTimeMillis() - lastTime > refreshThreshold || !cache.containsKey(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ class ConsumerConsole(config: KafkaConfig) extends KafkaConsole(config: KafkaCon
|
|||||||
}).asInstanceOf[(Boolean, String)]
|
}).asInstanceOf[(Boolean, String)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @return k: topic, v: list[topic].
|
||||||
|
*/
|
||||||
def listSubscribeTopics(groupId: String): util.Map[String, util.List[TopicPartition]] = {
|
def listSubscribeTopics(groupId: String): util.Map[String, util.List[TopicPartition]] = {
|
||||||
val commitOffs = getCommittedOffsets(groupId)
|
val commitOffs = getCommittedOffsets(groupId)
|
||||||
val map: util.Map[String, util.List[TopicPartition]] = new util.HashMap[String, util.List[TopicPartition]]()
|
val map: util.Map[String, util.List[TopicPartition]] = new util.HashMap[String, util.List[TopicPartition]]()
|
||||||
@@ -185,6 +189,32 @@ class ConsumerConsole(config: KafkaConfig) extends KafkaConsole(config: KafkaCon
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @return k: groupId, v: list[topic].
|
||||||
|
*/
|
||||||
|
def listSubscribeTopics(groups: util.Set[String]): util.Map[String, util.List[TopicPartition]] = {
|
||||||
|
val map: util.Map[String, util.List[TopicPartition]] = new util.HashMap[String, util.List[TopicPartition]]()
|
||||||
|
withAdminClientAndCatchError(admin => {
|
||||||
|
for(groupId <- groups.asScala) {
|
||||||
|
val commitOffs = admin.listConsumerGroupOffsets(
|
||||||
|
groupId
|
||||||
|
).partitionsToOffsetAndMetadata.get.asScala
|
||||||
|
|
||||||
|
for (t <- commitOffs.keySet) {
|
||||||
|
if (!map.containsKey(groupId)) {
|
||||||
|
map.put(groupId, new util.ArrayList[TopicPartition]())
|
||||||
|
}
|
||||||
|
map.get(groupId).add(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}, e => {
|
||||||
|
log.error("listSubscribeTopics error.", e)
|
||||||
|
map
|
||||||
|
}).asInstanceOf[util.Map[String, util.List[TopicPartition]]]
|
||||||
|
}
|
||||||
|
|
||||||
private def describeConsumerGroups(groupIds: util.Set[String]): mutable.Map[String, ConsumerGroupDescription] = {
|
private def describeConsumerGroups(groupIds: util.Set[String]): mutable.Map[String, ConsumerGroupDescription] = {
|
||||||
withAdminClientAndCatchError(admin => {
|
withAdminClientAndCatchError(admin => {
|
||||||
admin.describeConsumerGroups(groupIds).describedGroups().asScala.map {
|
admin.describeConsumerGroups(groupIds).describedGroups().asScala.map {
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export const KafkaConsumerApi = {
|
|||||||
url: "/consumer/topic/list",
|
url: "/consumer/topic/list",
|
||||||
method: "get",
|
method: "get",
|
||||||
},
|
},
|
||||||
|
getTopicSubscribedByGroups: {
|
||||||
|
url: "/consumer/topic/subscribed",
|
||||||
|
method: "get",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KafkaClusterApi = {
|
export const KafkaClusterApi = {
|
||||||
|
|||||||
190
ui/src/views/topic/ConsumedDetail.vue
Normal file
190
ui/src/views/topic/ConsumedDetail.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:title="'Topic: ' + topic"
|
||||||
|
:visible="show"
|
||||||
|
:width="1200"
|
||||||
|
:mask="false"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
:footer="null"
|
||||||
|
:maskClosable="false"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div v-for="(v, k) in data" :key="k">
|
||||||
|
<strong>消费组: </strong><span class="color-font">{{ k }}</span
|
||||||
|
><strong> | 积压: </strong><span class="color-font">{{ v.lag }}</span>
|
||||||
|
<hr />
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="v.data"
|
||||||
|
bordered
|
||||||
|
:rowKey="(record) => record.topic + record.partition"
|
||||||
|
>
|
||||||
|
<span slot="clientId" slot-scope="text, record">
|
||||||
|
<span v-if="text"> {{ text }}@{{ record.host }} </span>
|
||||||
|
</span>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import request from "@/utils/request";
|
||||||
|
import { KafkaConsumerApi } from "@/utils/api";
|
||||||
|
import notification from "ant-design-vue/es/notification";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ConsumedDetail",
|
||||||
|
props: {
|
||||||
|
topic: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
columns: columns,
|
||||||
|
show: this.visible,
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
showResetPartitionOffsetDialog: false,
|
||||||
|
select: {
|
||||||
|
topic: "",
|
||||||
|
partition: 0,
|
||||||
|
},
|
||||||
|
resetPartitionOffsetForm: this.$form.createForm(this, {
|
||||||
|
name: "resetPartitionOffsetForm",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible(v) {
|
||||||
|
this.show = v;
|
||||||
|
if (this.show) {
|
||||||
|
this.getConsumerDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getConsumerDetail() {
|
||||||
|
this.loading = true;
|
||||||
|
request({
|
||||||
|
url:
|
||||||
|
KafkaConsumerApi.getTopicSubscribedByGroups.url +
|
||||||
|
"?topic=" +
|
||||||
|
this.topic,
|
||||||
|
method: KafkaConsumerApi.getTopicSubscribedByGroups.method,
|
||||||
|
}).then((res) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (res.code != 0) {
|
||||||
|
notification.error({
|
||||||
|
message: "error",
|
||||||
|
description: res.msg,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.data = res.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleCancel() {
|
||||||
|
this.data = [];
|
||||||
|
this.$emit("closeConsumedDetailDialog", {});
|
||||||
|
},
|
||||||
|
resetTopicOffsetToEndpoint(groupId, topic, type) {
|
||||||
|
this.requestResetOffset({
|
||||||
|
groupId: groupId,
|
||||||
|
topic: topic,
|
||||||
|
level: 1,
|
||||||
|
type: type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestResetOffset(data, callbackOnSuccess) {
|
||||||
|
this.loading = true;
|
||||||
|
request({
|
||||||
|
url: KafkaConsumerApi.resetOffset.url,
|
||||||
|
method: KafkaConsumerApi.resetOffset.method,
|
||||||
|
data: data,
|
||||||
|
}).then((res) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (res.code != 0) {
|
||||||
|
notification.error({
|
||||||
|
message: "error",
|
||||||
|
description: res.msg,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$message.success(res.msg);
|
||||||
|
this.getConsumerDetail();
|
||||||
|
if (callbackOnSuccess) {
|
||||||
|
callbackOnSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openResetPartitionOffsetDialog(topic, partition) {
|
||||||
|
this.showResetPartitionOffsetDialog = true;
|
||||||
|
this.select.topic = topic;
|
||||||
|
this.select.partition = partition;
|
||||||
|
},
|
||||||
|
closeResetPartitionOffsetDialog() {
|
||||||
|
this.showResetPartitionOffsetDialog = false;
|
||||||
|
},
|
||||||
|
resetPartitionOffset() {
|
||||||
|
this.resetPartitionOffsetForm.validateFields((err, values) => {
|
||||||
|
if (!err) {
|
||||||
|
const data = Object.assign({}, values);
|
||||||
|
Object.assign(data, this.select);
|
||||||
|
data.groupId = this.group;
|
||||||
|
data.level = 2;
|
||||||
|
data.type = 4;
|
||||||
|
this.requestResetOffset(data, this.closeResetPartitionOffsetDialog());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "分区",
|
||||||
|
dataIndex: "partition",
|
||||||
|
key: "partition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "客户端",
|
||||||
|
dataIndex: "clientId",
|
||||||
|
key: "clientId",
|
||||||
|
scopedSlots: { customRender: "clientId" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "日志位点",
|
||||||
|
dataIndex: "logEndOffset",
|
||||||
|
key: "logEndOffset",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "消费位点",
|
||||||
|
dataIndex: "consumerOffset",
|
||||||
|
key: "consumerOffset",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "积压",
|
||||||
|
dataIndex: "lag",
|
||||||
|
key: "lag",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-font {
|
||||||
|
color: dodgerblue;
|
||||||
|
}
|
||||||
|
#resetPartitionOffsetModal .ant-input-number {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -77,6 +77,13 @@
|
|||||||
@click="openAddPartitionDialog(record.name)"
|
@click="openAddPartitionDialog(record.name)"
|
||||||
>增加分区
|
>增加分区
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
href="javascript:;"
|
||||||
|
class="operation-btn"
|
||||||
|
@click="openConsumedDetailDialog(record.name)"
|
||||||
|
>消费详情
|
||||||
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</a-table>
|
</a-table>
|
||||||
<PartitionInfo
|
<PartitionInfo
|
||||||
@@ -94,6 +101,12 @@
|
|||||||
:topic="selectDetail.resourceName"
|
:topic="selectDetail.resourceName"
|
||||||
@closeAddPartitionDialog="closeAddPartitionDialog"
|
@closeAddPartitionDialog="closeAddPartitionDialog"
|
||||||
></AddPartition>
|
></AddPartition>
|
||||||
|
<ConsumedDetail
|
||||||
|
:visible="showConsumedDetailDialog"
|
||||||
|
:topic="selectDetail.resourceName"
|
||||||
|
@closeConsumedDetailDialog="closeConsumedDetailDialog"
|
||||||
|
>
|
||||||
|
</ConsumedDetail>
|
||||||
</div>
|
</div>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,10 +119,11 @@ import notification from "ant-design-vue/es/notification";
|
|||||||
import PartitionInfo from "@/views/topic/PartitionInfo";
|
import PartitionInfo from "@/views/topic/PartitionInfo";
|
||||||
import CreateTopic from "@/views/topic/CreateTopic";
|
import CreateTopic from "@/views/topic/CreateTopic";
|
||||||
import AddPartition from "@/views/topic/AddPartition";
|
import AddPartition from "@/views/topic/AddPartition";
|
||||||
|
import ConsumedDetail from "@/views/topic/ConsumedDetail";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Topic",
|
name: "Topic",
|
||||||
components: { PartitionInfo, CreateTopic, AddPartition },
|
components: { PartitionInfo, CreateTopic, AddPartition, ConsumedDetail },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
queryParam: { type: "normal" },
|
queryParam: { type: "normal" },
|
||||||
@@ -128,6 +142,7 @@ export default {
|
|||||||
loading: false,
|
loading: false,
|
||||||
showCreateTopic: false,
|
showCreateTopic: false,
|
||||||
showAddPartition: false,
|
showAddPartition: false,
|
||||||
|
showConsumedDetailDialog: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -194,6 +209,13 @@ export default {
|
|||||||
this.getTopicList();
|
this.getTopicList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openConsumedDetailDialog(topic) {
|
||||||
|
this.showConsumedDetailDialog = true;
|
||||||
|
this.selectDetail.resourceName = topic;
|
||||||
|
},
|
||||||
|
closeConsumedDetailDialog() {
|
||||||
|
this.showConsumedDetailDialog = false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getTopicList();
|
this.getTopicList();
|
||||||
|
|||||||
Reference in New Issue
Block a user