Files
dbx/apps/desktop/src/components/mq/KafkaMessagesPanel.vue
T

235 lines
7.2 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { TopicInfo, TopicRef, TopicStats } from "@/types/mq";
import { mqGetTopicStats } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { extractKafkaPartitionRows } from "@/lib/mq/kafkaTopicStats";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
import MessageBrowser from "./MessageBrowser.vue";
import SendMessagePanel from "./SendMessagePanel.vue";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
canSendMessage?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
topicSelected: [topic: TopicInfo | undefined];
}>();
const { t } = useI18n();
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const topics = ref<TopicInfo[]>([]);
const topicName = ref(props.topic?.shortName ?? "");
const stats = ref<TopicStats>();
const statsLoading = ref(false);
const statsError = ref<string>();
let statsRequestVersion = 0;
const selectedTopic = computed(() => topics.value.find((item) => item.shortName === topicName.value));
const selectedTopicRef = computed<TopicRef | null>(() => {
const name = topicName.value.trim();
if (!name || !props.tenant || !props.namespace) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
topic: name,
persistent: selectedTopic.value?.persistent ?? true,
partitioned: selectedTopic.value?.partitioned,
};
});
const partitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
function handleTopicsLoaded(loaded: TopicInfo[]) {
topics.value = loaded;
if (topicName.value && loaded.some((item) => item.shortName === topicName.value)) {
return;
}
if (props.topic && loaded.some((item) => item.shortName === props.topic?.shortName)) {
topicName.value = props.topic.shortName;
} else if (loaded.length === 1) {
topicName.value = loaded[0].shortName;
} else {
topicName.value = "";
}
}
async function loadStats() {
const topic = selectedTopicRef.value;
const requestVersion = ++statsRequestVersion;
stats.value = undefined;
statsError.value = undefined;
if (!topic) return;
statsLoading.value = true;
try {
const result = await mqGetTopicStats(props.connectionId, topic);
if (requestVersion === statsRequestVersion) stats.value = result;
} catch (cause: unknown) {
if (requestVersion === statsRequestVersion) statsError.value = formatError(cause);
} finally {
if (requestVersion === statsRequestVersion) statsLoading.value = false;
}
}
watch([() => props.connectionId, () => props.tenant, () => props.namespace, topicName], () => void loadStats(), { immediate: true });
watch([() => props.connectionId, () => props.tenant, () => props.namespace], () => {
topics.value = [];
topicName.value = props.topic?.shortName ?? "";
});
watch(
() => props.topic?.shortName,
(name) => {
if (name) topicName.value = name;
},
);
watch(selectedTopic, (topic) => emit("topicSelected", topic));
</script>
<template>
<div class="kafka-messages-panel">
<div class="panel-toolbar">
<h3>{{ t("mqMessages.queryTitle") }}</h3>
<button type="button" class="btn-secondary" :disabled="!tenant || !namespace" @click="topicSelectRef?.loadTopics()">
{{ t("mqMessages.refreshTopicList") }}
</button>
</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqMessages.selectNamespaceOrTopicFirst") }}</div>
<div v-else class="kafka-messages-content">
<section class="kafka-topic-section">
<label>
<span>{{ t("mqMessages.queryTopicLabel") }}</span>
<RocketMqTopicSelect ref="topicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :show-type-filter="false" show-hint @loaded="handleTopicsLoaded" />
</label>
</section>
<section v-if="selectedTopicRef" class="partition-overview" data-testid="kafka-partition-overview">
<div class="section-heading">
<h4>{{ t("mqMonitoring.kafkaPartitionDetails") }}</h4>
<button type="button" class="btn-sm" :disabled="statsLoading" @click="loadStats">{{ t("common.refresh") }}</button>
</div>
<div v-if="statsError" class="panel-error">{{ statsError }}</div>
<div v-else-if="statsLoading" class="section-empty">{{ t("common.loading") }}</div>
<div v-else-if="!partitionRows.length" class="section-empty">{{ t("mqMonitoring.noKafkaPartitionMetrics") }}</div>
<div v-else class="partition-table-wrap">
<table class="partition-table">
<thead>
<tr>
<th>{{ t("mqMonitoring.tablePartition") }}</th>
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in partitionRows" :key="row.partition">
<td>{{ row.partition }}</td>
<td>{{ row.beginOffset.toLocaleString() }}</td>
<td>{{ row.endOffset.toLocaleString() }}</td>
<td>{{ row.messageCount.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<MessageBrowser :connection-id="connectionId" :topic="selectedTopicRef" mq-system-kind="kafka" />
<SendMessagePanel v-if="canSendMessage && selectedTopic" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="selectedTopic" :read-only="readOnly" mq-system-kind="kafka" is-flat-mq-cluster :supports-peek-messages="false" fixed-topic embedded />
</div>
</div>
</template>
<style scoped>
@import "./shared/mqPanel.css";
.kafka-messages-panel,
.kafka-messages-content {
display: flex;
flex-direction: column;
gap: 14px;
}
.kafka-messages-panel {
height: 100%;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
padding: 16px;
}
.panel-toolbar {
flex-shrink: 0;
}
.kafka-messages-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.kafka-topic-section,
.partition-overview {
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
}
.kafka-topic-section label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-heading h4 {
margin: 0;
font-size: 14px;
}
.partition-table-wrap {
overflow: auto;
}
.partition-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.partition-table th,
.partition-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.partition-table th {
color: var(--color-text-secondary);
font-weight: 600;
}
.section-empty {
padding: 14px;
color: var(--color-text-tertiary);
text-align: center;
font-size: 13px;
}
</style>