1296 lines
45 KiB
Vue
1296 lines
45 KiB
Vue
<script setup lang="ts">
|
||
import { ref, watch, computed } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { RecycleScroller } from "vue-virtual-scroller";
|
||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
|
||
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
|
||
import type { ClusterInfo } from "@/types/mq";
|
||
import RocketMqTopicDialogs, { type RocketMqTopicDialogKind } from "./rocketmq/RocketMqTopicDialogs.vue";
|
||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||
import ExchangesPanel from "./ExchangesPanel.vue";
|
||
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
|
||
import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
|
||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||
import { formatMqRateCompact, rabbitMqArgumentsSummary, rabbitMqFeatureBadges, rabbitMqQueueType, type RabbitMqFeatureBadge } from "@/lib/mq/rabbitmqQueueFeatures";
|
||
import { formatError } from "@/lib/backend/errorUtils";
|
||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||
|
||
const TOPIC_ROW_HEIGHT = 44;
|
||
|
||
type VirtualTopicRow = {
|
||
id: string;
|
||
topic: TopicInfo;
|
||
};
|
||
|
||
interface Props {
|
||
connectionId: string;
|
||
tenant?: string;
|
||
namespace?: string;
|
||
readOnly?: boolean;
|
||
supportsPartitionedTopics?: boolean;
|
||
isFlatMqCluster?: boolean;
|
||
mqSystemKind?: MqSystemKind;
|
||
supportsExchanges?: boolean;
|
||
}
|
||
|
||
const props = defineProps<Props>();
|
||
const emit = defineEmits<{
|
||
topicSelected: [topic: TopicInfo];
|
||
navigateTab: [payload: { tab: MqTab; topic?: TopicInfo; subscription?: string; preferDlqTopic?: boolean }];
|
||
}>();
|
||
|
||
const { t } = useI18n();
|
||
|
||
const topics = ref<TopicInfo[]>([]);
|
||
const loading = ref(false);
|
||
const error = ref<string>();
|
||
const dialogError = ref<string>();
|
||
const showCreateDialog = ref(false);
|
||
const showPartitionsDialog = ref(false);
|
||
const selectedTopic = ref<TopicInfo>();
|
||
const editingTopic = ref<TopicInfo>();
|
||
const topicSearch = ref("");
|
||
const deleteTarget = ref<TopicInfo>();
|
||
const showDeleteDialog = ref(false);
|
||
const deleting = ref(false);
|
||
|
||
const clusterInfo = ref<ClusterInfo>();
|
||
const activeRocketMqDialog = ref<RocketMqTopicDialogKind | null>(null);
|
||
const rocketMqDialogTopic = ref<TopicInfo>();
|
||
const showSendDialog = ref(false);
|
||
const sendDialogTopic = ref<TopicInfo>();
|
||
|
||
const ROCKETMQ_PERM_OPTIONS = [
|
||
{ value: 6, labelKey: "mqTopics.permReadWrite" },
|
||
{ value: 4, labelKey: "mqTopics.permRead" },
|
||
{ value: 2, labelKey: "mqTopics.permWrite" },
|
||
] as const;
|
||
|
||
const formData = ref({
|
||
topicName: "",
|
||
persistent: true,
|
||
partitioned: false,
|
||
partitions: 4,
|
||
messageType: "NORMAL" as RocketMqTopicMessageType,
|
||
brokerName: "",
|
||
readQueueNums: 8,
|
||
writeQueueNums: 8,
|
||
perm: 6,
|
||
});
|
||
|
||
const newPartitions = ref(4);
|
||
|
||
const includeNonPersistent = ref(false);
|
||
const includeSystemTopics = ref(false);
|
||
const messageTypeFilters = ref<Record<RocketMqTopicMessageType, boolean>>({
|
||
...DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS,
|
||
});
|
||
|
||
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
|
||
const isKafkaCluster = computed(() => props.mqSystemKind === "kafka");
|
||
const isRabbitMqCluster = computed(() => props.mqSystemKind === "rabbitmq");
|
||
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`.
|
||
const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhostsNamespace(props.namespace));
|
||
// RabbitMQ splits the topics tab into Queues (topics table) and Exchanges views.
|
||
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
|
||
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
|
||
const rabbitMqMessageSort = ref<"asc" | "desc" | null>(null);
|
||
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
|
||
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
|
||
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
|
||
const rocketMqBrokerOptions = computed(() => clusterInfo.value?.brokers ?? []);
|
||
const rocketMqMasterBrokers = computed(() => rocketMqBrokerOptions.value.filter((broker) => !broker.role || broker.role === "MASTER"));
|
||
|
||
const typeFilteredTopics = computed(() => {
|
||
let rows = topics.value;
|
||
if (isRocketMqCluster.value) {
|
||
rows = rows.filter((topic) => matchesRocketMqTypeFilters(topic, messageTypeFilters.value));
|
||
} else if (props.isFlatMqCluster && !includeSystemTopics.value) {
|
||
rows = rows.filter((topic) => !topic.internal);
|
||
}
|
||
return rows;
|
||
});
|
||
|
||
const filteredTopics = computed(() => {
|
||
const query = topicSearch.value.trim().toLowerCase();
|
||
if (!query) return typeFilteredTopics.value;
|
||
return typeFilteredTopics.value.filter((topic) => {
|
||
return topic.name.toLowerCase().includes(query) || topic.shortName.toLowerCase().includes(query);
|
||
});
|
||
});
|
||
|
||
const displayedTopics = computed(() => {
|
||
if (!isRabbitMqCluster.value || !rabbitMqMessageSort.value) return filteredTopics.value;
|
||
const direction = rabbitMqMessageSort.value === "asc" ? 1 : -1;
|
||
return [...filteredTopics.value].sort((left, right) => {
|
||
const countDifference = (rabbitMqReadyMessageCount(left) - rabbitMqReadyMessageCount(right)) * direction;
|
||
return countDifference || left.shortName.localeCompare(right.shortName);
|
||
});
|
||
});
|
||
|
||
/** Stable ids for RecycleScroller; avoids mounting all topic rows at once. */
|
||
const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
|
||
displayedTopics.value.map((topic) => ({
|
||
id: showNamespaceColumn.value ? `${topic.namespace ?? ""}:${topic.name}` : topic.name,
|
||
topic,
|
||
})),
|
||
);
|
||
|
||
const topicsGridTemplate = computed(() => {
|
||
const cols: string[] = ["minmax(180px, 1.6fr)"];
|
||
if (showNamespaceColumn.value) cols.push("minmax(100px, 0.8fr)");
|
||
if (isRabbitMqCluster.value) {
|
||
// RabbitMQ queues: type(+state) | features | messages | consumers | rates | actions
|
||
cols.push("110px", "170px", "90px", "70px", "190px");
|
||
} else {
|
||
cols.push("120px");
|
||
}
|
||
if (!isRocketMqCluster.value && !isRabbitMqCluster.value) cols.push("140px");
|
||
// RocketMQ keeps tiled row actions (status/route/consumers/…) — reserve a wider actions column.
|
||
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(150px, 1fr)");
|
||
return cols.join(" ");
|
||
});
|
||
|
||
/** Min content width from grid track mins + gaps + padding; enables shared horizontal scroll. */
|
||
const topicsTableMinWidthPx = computed(() => {
|
||
let min = 180; // name
|
||
if (showNamespaceColumn.value) min += 100;
|
||
let colCount = 0;
|
||
if (isRabbitMqCluster.value) {
|
||
// type + features + messages + consumers + rates + actions
|
||
min += 110 + 170 + 90 + 70 + 190 + 150;
|
||
colCount = 7; // name, type, features, messages, consumers, rates, actions
|
||
} else {
|
||
min += 120; // type
|
||
if (!isRocketMqCluster.value) min += 140; // partitions
|
||
min += isRocketMqCluster.value ? 560 : 200; // actions
|
||
// RocketMQ: name + type + actions. Others: + partitions.
|
||
colCount = isRocketMqCluster.value ? 3 : 4;
|
||
}
|
||
if (showNamespaceColumn.value) colCount += 1;
|
||
min += (colCount - 1) * 8; // column-gap
|
||
min += 24; // horizontal padding
|
||
return min;
|
||
});
|
||
|
||
const userTopicCount = computed(() => {
|
||
if (isRocketMqCluster.value) {
|
||
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length;
|
||
}
|
||
return topics.value.filter((topic) => !topic.internal).length;
|
||
});
|
||
|
||
function topicRowSelected(topic: TopicInfo): boolean {
|
||
return selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace;
|
||
}
|
||
|
||
function rabbitMqReadyMessageCount(topic: TopicInfo): number {
|
||
return topic.messagesReady ?? topic.messageCount ?? 0;
|
||
}
|
||
|
||
/** Queue type label for the type column; unknown types are never guessed. */
|
||
function rabbitMqQueueTypeLabel(topic: TopicInfo): string {
|
||
return rabbitMqQueueType(topic) ?? t("mqTopics.rabbitmqQueueTypeUnknown");
|
||
}
|
||
|
||
function rabbitMqQueueTypeBadgeClass(topic: TopicInfo): string {
|
||
const type = rabbitMqQueueType(topic);
|
||
if (type === "quorum") return "badge-info";
|
||
if (type === "stream") return "badge-warning";
|
||
return "badge-default";
|
||
}
|
||
|
||
function rabbitMqFeatureBadgesFor(topic: TopicInfo): RabbitMqFeatureBadge[] {
|
||
return rabbitMqFeatureBadges(topic);
|
||
}
|
||
|
||
/** Tooltip for the message-count column: Ready / Unacked / Total / Consumers. */
|
||
function rabbitMqMessageCountTitle(topic: TopicInfo): string {
|
||
const parts = [`${t("mqRabbitMqMonitoring.messagesReady")}: ${topic.messagesReady ?? "-"}`, `${t("mqRabbitMqMonitoring.messagesUnacked")}: ${topic.messagesUnacked ?? "-"}`, `${t("mqTopics.messageCount")}: ${topic.messageCount ?? "-"}`];
|
||
if (topic.consumerCount !== undefined) parts.push(`${t("mqTopics.consumers")}: ${topic.consumerCount}`);
|
||
if (topic.state) parts.push(`${t("mqTopics.rabbitmqState")}: ${topic.state}`);
|
||
const summary = rabbitMqArgumentsSummary(topic);
|
||
if (summary) parts.push(`${t("mqTopics.rabbitmqArguments")}: ${summary}`);
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
/** Tooltip for the rates column; rates are msg/s sampled by the management API. */
|
||
function rabbitMqRatesTitle(): string {
|
||
return `${t("mqTopics.rabbitmqPublishRate")} / ${t("mqTopics.rabbitmqDeliverRate")} / ${t("mqTopics.rabbitmqAckRate")} (msg/s)`;
|
||
}
|
||
|
||
function toggleRabbitMqMessageSort() {
|
||
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
|
||
}
|
||
|
||
function topicTypeLabel(topic: TopicInfo): string {
|
||
if (isRocketMqCluster.value) {
|
||
const type = resolveRocketMqMessageType(topic);
|
||
return t(`mqTopics.rocketmqType.${type.toLowerCase()}`);
|
||
}
|
||
if (topic.internal) return t("mqTopics.systemTopic");
|
||
if (topic.partitioned) return t("mqTopics.partitionedTopic");
|
||
return t("mqTopics.normalTopic");
|
||
}
|
||
|
||
function topicTypeBadgeClass(topic: TopicInfo): string {
|
||
if (isRocketMqCluster.value) {
|
||
const type = resolveRocketMqMessageType(topic);
|
||
if (type === "SYSTEM" || type === "RETRY" || type === "DLQ") return "badge-warning";
|
||
if (type === "DELAY" || type === "FIFO" || type === "TRANSACTION") return "badge-info";
|
||
return "badge-default";
|
||
}
|
||
if (topic.internal) return "badge-warning";
|
||
if (topic.partitioned) return "badge-info";
|
||
return "badge-default";
|
||
}
|
||
|
||
function isTopicProtected(topic: TopicInfo): boolean {
|
||
return isRocketMqCluster.value ? isProtectedRocketMqTopic(topic) : !!topic.internal;
|
||
}
|
||
const editingCurrentPartitions = computed(() => editingTopic.value?.partitions ?? 0);
|
||
const canSubmitPartitionUpdate = computed(() => {
|
||
const current = editingCurrentPartitions.value;
|
||
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
|
||
});
|
||
|
||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||
|
||
async function guardWritable(operation: string): Promise<boolean> {
|
||
if (props.readOnly) {
|
||
error.value = t("mqTopics.readOnly");
|
||
return false;
|
||
}
|
||
return confirmMqWrite(operation);
|
||
}
|
||
|
||
async function loadTopics() {
|
||
if (!props.tenant || !props.namespace) {
|
||
topics.value = [];
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
try {
|
||
const ns: NamespaceRef = {
|
||
tenant: props.tenant,
|
||
namespace: props.namespace,
|
||
};
|
||
const opts: ListTopicsOpts = {
|
||
includeNonPersistent: includeNonPersistent.value,
|
||
};
|
||
topics.value = await mqListTopics(props.connectionId, ns, opts);
|
||
} catch (e: unknown) {
|
||
error.value = formatError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadClusterInfo() {
|
||
if (!isRocketMqCluster.value) return;
|
||
try {
|
||
clusterInfo.value = await mqGetClusterInfo(props.connectionId);
|
||
} catch (e: unknown) {
|
||
console.warn("[DBX] Failed to load RocketMQ cluster info:", e);
|
||
}
|
||
}
|
||
|
||
function openCreateDialog() {
|
||
if (props.readOnly) {
|
||
error.value = t("mqTopics.readOnly");
|
||
return;
|
||
}
|
||
dialogError.value = undefined;
|
||
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
|
||
formData.value = {
|
||
topicName: "",
|
||
persistent: true,
|
||
partitioned: props.isFlatMqCluster ?? false,
|
||
partitions: 4,
|
||
messageType: "NORMAL",
|
||
brokerName: defaultBroker,
|
||
readQueueNums: 8,
|
||
writeQueueNums: 8,
|
||
perm: 6,
|
||
};
|
||
showCreateDialog.value = true;
|
||
}
|
||
|
||
function openRocketMqDialog(kind: RocketMqTopicDialogKind, topic: TopicInfo) {
|
||
rocketMqDialogTopic.value = topic;
|
||
activeRocketMqDialog.value = kind;
|
||
}
|
||
|
||
function closeRocketMqDialog() {
|
||
activeRocketMqDialog.value = null;
|
||
rocketMqDialogTopic.value = undefined;
|
||
}
|
||
|
||
function handleRocketMqNavigate(payload: { tab: "subscriptions" | "messages"; subscription?: string }) {
|
||
if (!rocketMqDialogTopic.value) return;
|
||
if (payload.tab === "messages") {
|
||
openSendDialog(rocketMqDialogTopic.value);
|
||
closeRocketMqDialog();
|
||
return;
|
||
}
|
||
emit("navigateTab", {
|
||
tab: payload.tab,
|
||
topic: rocketMqDialogTopic.value,
|
||
subscription: payload.subscription,
|
||
});
|
||
closeRocketMqDialog();
|
||
}
|
||
|
||
function openSendDialog(topic: TopicInfo) {
|
||
sendDialogTopic.value = topic;
|
||
showSendDialog.value = true;
|
||
}
|
||
|
||
function closeSendDialog() {
|
||
showSendDialog.value = false;
|
||
sendDialogTopic.value = undefined;
|
||
}
|
||
|
||
function navigateToMessages(topic: TopicInfo) {
|
||
if (isRocketMqCluster.value) {
|
||
openSendDialog(topic);
|
||
return;
|
||
}
|
||
emit("navigateTab", { tab: "messages", topic });
|
||
}
|
||
|
||
function navigateToMessageQuery(topic: TopicInfo, preferDlqTopic = false) {
|
||
emit("navigateTab", {
|
||
tab: "messages",
|
||
topic,
|
||
preferDlqTopic,
|
||
});
|
||
}
|
||
|
||
function isDlqTopic(topic: TopicInfo): boolean {
|
||
return resolveRocketMqMessageType(topic) === "DLQ";
|
||
}
|
||
|
||
function openPartitionsDialog(topic: TopicInfo) {
|
||
if (props.readOnly) {
|
||
error.value = t("mqTopics.readOnly");
|
||
return;
|
||
}
|
||
dialogError.value = undefined;
|
||
if (!topic.partitions || topic.partitions < 1) {
|
||
error.value = t("mqTopics.currentPartitionsUnknown");
|
||
return;
|
||
}
|
||
editingTopic.value = topic;
|
||
newPartitions.value = topic.partitions + 1;
|
||
showPartitionsDialog.value = true;
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (!(await guardWritable(t("mqTopics.create")))) return;
|
||
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
|
||
dialogError.value = t("mqTopics.topicNameRequired");
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
try {
|
||
const topicRef: TopicRef = {
|
||
tenant: props.tenant,
|
||
namespace: props.namespace,
|
||
topic: formData.value.topicName,
|
||
persistent: formData.value.persistent,
|
||
};
|
||
if (isRocketMqCluster.value) {
|
||
topicRef.messageType = formData.value.messageType;
|
||
topicRef.brokerName = formData.value.brokerName || undefined;
|
||
topicRef.readQueueNums = formData.value.readQueueNums;
|
||
topicRef.writeQueueNums = formData.value.writeQueueNums;
|
||
topicRef.perm = formData.value.perm;
|
||
}
|
||
const partitions = props.supportsPartitionedTopics !== false && formData.value.partitioned ? formData.value.partitions : undefined;
|
||
await mqCreateTopic(props.connectionId, topicRef, partitions);
|
||
showCreateDialog.value = false;
|
||
dialogError.value = undefined;
|
||
await loadTopics();
|
||
} catch (e: unknown) {
|
||
dialogError.value = formatError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleDelete(topic: TopicInfo) {
|
||
if (props.readOnly) {
|
||
error.value = t("mqTopics.readOnly");
|
||
return;
|
||
}
|
||
deleteTarget.value = topic;
|
||
showDeleteDialog.value = true;
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!(await guardWritable(t("mqTopics.delete")))) return;
|
||
const topic = deleteTarget.value;
|
||
if (!topic || !props.tenant || !props.namespace) return;
|
||
const namespace = resolveMqRowNamespace(topic, props.namespace);
|
||
if (!namespace) {
|
||
error.value = t("mqAdmin.selectNamespaceToWrite");
|
||
showDeleteDialog.value = false;
|
||
return;
|
||
}
|
||
deleting.value = true;
|
||
error.value = undefined;
|
||
try {
|
||
const topicRef: TopicRef = {
|
||
tenant: props.tenant,
|
||
namespace,
|
||
topic: topic.shortName,
|
||
persistent: topic.persistent,
|
||
};
|
||
await mqDeleteTopic(props.connectionId, topicRef, false);
|
||
if (selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace) {
|
||
selectedTopic.value = undefined;
|
||
}
|
||
showDeleteDialog.value = false;
|
||
await loadTopics();
|
||
} catch (e: unknown) {
|
||
error.value = formatError(e);
|
||
} finally {
|
||
deleting.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleUpdatePartitions() {
|
||
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
|
||
if (!editingTopic.value || !props.tenant || !props.namespace) return;
|
||
const currentPartitions = editingTopic.value.partitions;
|
||
if (!currentPartitions || currentPartitions < 1) {
|
||
dialogError.value = t("mqTopics.currentPartitionsUnknown");
|
||
return;
|
||
}
|
||
if (newPartitions.value <= currentPartitions) {
|
||
dialogError.value = t("mqTopics.partitionMustIncrease");
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
try {
|
||
const topicRef: TopicRef = {
|
||
tenant: props.tenant,
|
||
namespace: props.namespace,
|
||
topic: editingTopic.value.shortName,
|
||
persistent: editingTopic.value.persistent,
|
||
};
|
||
await mqUpdatePartitions(props.connectionId, topicRef, newPartitions.value);
|
||
showPartitionsDialog.value = false;
|
||
dialogError.value = undefined;
|
||
await loadTopics();
|
||
} catch (e: unknown) {
|
||
dialogError.value = formatError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function selectTopic(topic: TopicInfo) {
|
||
selectedTopic.value = topic;
|
||
emit("topicSelected", topic);
|
||
}
|
||
|
||
function normalizePartitionInput() {
|
||
const min = editingCurrentPartitions.value + 1;
|
||
if (!showPartitionsDialog.value || min <= 1) return;
|
||
if (!Number.isFinite(Number(newPartitions.value)) || Number(newPartitions.value) < min) {
|
||
newPartitions.value = min;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => [props.tenant, props.namespace],
|
||
() => {
|
||
selectedTopic.value = undefined;
|
||
loadTopics();
|
||
if (isRocketMqCluster.value) void loadClusterInfo();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
() => props.mqSystemKind,
|
||
() => {
|
||
if (isRocketMqCluster.value) void loadClusterInfo();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(includeNonPersistent, () => {
|
||
loadTopics();
|
||
});
|
||
|
||
watch(newPartitions, () => {
|
||
if (dialogError.value === t("mqTopics.partitionMustIncrease") && canSubmitPartitionUpdate.value) {
|
||
dialogError.value = undefined;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="topics-panel">
|
||
<div v-if="showRabbitMqSubTabs" class="rabbitmq-subtabs">
|
||
<button :class="{ active: rabbitMqSubTab === 'queues' }" @click="rabbitMqSubTab = 'queues'">{{ t("mqTopics.tabQueues") }}</button>
|
||
<button :class="{ active: rabbitMqSubTab === 'exchanges' }" @click="rabbitMqSubTab = 'exchanges'">{{ t("mqTopics.tabExchanges") }}</button>
|
||
</div>
|
||
|
||
<ExchangesPanel v-if="showRabbitMqSubTabs && rabbitMqSubTab === 'exchanges'" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :read-only="readOnly" />
|
||
|
||
<template v-else>
|
||
<div class="panel-toolbar">
|
||
<div class="toolbar-left">
|
||
<h3>{{ t("mqTopics.title") }}</h3>
|
||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
|
||
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
|
||
<label v-if="isKafkaCluster" class="checkbox-label">
|
||
<input v-model="includeSystemTopics" type="checkbox" />
|
||
{{ t("mqTopics.includeSystemTopics") }}
|
||
</label>
|
||
<label v-else-if="!isRocketMqCluster" class="checkbox-label">
|
||
<input v-model="includeNonPersistent" type="checkbox" />
|
||
{{ t("mqTopics.includeNonPersistent") }}
|
||
</label>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<button @click="loadTopics" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||
{{ loading ? t("mqTopics.refreshing") : t("mqTopics.refresh") }}
|
||
</button>
|
||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace || showNamespaceColumn" :title="showNamespaceColumn ? t('mqAdmin.selectNamespaceToCreate') : undefined" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqTopics.typeFilter')">
|
||
<label v-for="type in rocketMqTopicTypeOptions" :key="type" class="checkbox-label compact">
|
||
<input v-model="messageTypeFilters[type]" type="checkbox" />
|
||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||
</label>
|
||
</MqTypeFilterBar>
|
||
|
||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqTopics.selectTenantNamespace") }}</div>
|
||
|
||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||
|
||
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
|
||
|
||
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
|
||
|
||
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
|
||
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
|
||
</div>
|
||
|
||
<div v-else class="topics-table">
|
||
<!-- Shared horizontal scroller keeps header/body columns aligned when the grid min-width exceeds the panel. -->
|
||
<div class="topics-table-hscroll" :style="{ '--topics-table-min-width': `${topicsTableMinWidthPx}px` }">
|
||
<div class="topics-table-header" :style="{ gridTemplateColumns: topicsGridTemplate }">
|
||
<div class="topics-col">{{ t("mqTopics.name") }}</div>
|
||
<div v-if="showNamespaceColumn" class="topics-col">{{ t("mqAdmin.namespace") }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqQueueType") }}</div>
|
||
<div v-else class="topics-col">{{ t("mqTopics.type") }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqFeatures") }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col">
|
||
<button type="button" class="topics-sort-button" data-testid="rabbitmq-message-sort" @click="toggleRabbitMqMessageSort">
|
||
{{ t("mqTopics.messageCount") }}
|
||
<span v-if="rabbitMqMessageSort" aria-hidden="true">{{ rabbitMqMessageSort === "desc" ? "↓" : "↑" }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.consumers") }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col" :title="rabbitMqRatesTitle()">{{ t("mqTopics.rabbitmqRates") }}</div>
|
||
<div class="topics-col">{{ t("mqTopics.actions") }}</div>
|
||
</div>
|
||
<RecycleScroller class="topics-scroller" :items="virtualTopicRows" :item-size="TOPIC_ROW_HEIGHT" :buffer="200" key-field="id">
|
||
<template #default="{ item: row }">
|
||
<div class="topics-row" :class="{ selected: topicRowSelected(row.topic) }" :style="{ gridTemplateColumns: topicsGridTemplate, height: `${TOPIC_ROW_HEIGHT}px` }" @click="selectTopic(row.topic)">
|
||
<div class="topics-col topic-name">
|
||
<div class="topic-name-cell">
|
||
<span class="topic-name-text" :title="row.topic.shortName">{{ row.topic.shortName }}</span>
|
||
<span v-if="!row.topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="showNamespaceColumn" class="topics-col">{{ row.topic.namespace || "-" }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col">
|
||
<span class="badge" :class="rabbitMqQueueTypeBadgeClass(row.topic)" :title="t('mqTopics.rabbitmqQueueTypeHint')">
|
||
{{ rabbitMqQueueTypeLabel(row.topic) }}
|
||
</span>
|
||
<span v-if="row.topic.state" class="queue-state" :title="t('mqTopics.rabbitmqState')">{{ row.topic.state }}</span>
|
||
</div>
|
||
<div v-else class="topics-col">
|
||
<span class="badge" :class="topicTypeBadgeClass(row.topic)">
|
||
{{ topicTypeLabel(row.topic) }}
|
||
</span>
|
||
</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-features">
|
||
<span v-if="rabbitMqFeatureBadgesFor(row.topic).length" class="feature-badges">
|
||
<span v-for="badge in rabbitMqFeatureBadgesFor(row.topic)" :key="badge.key" class="feature-badge" :class="badge.kind === 'feature' ? 'feature-flag' : ''" :title="badge.title">
|
||
{{ badge.label }}
|
||
</span>
|
||
</span>
|
||
<span v-else class="text-muted">-</span>
|
||
</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-message-count" :title="rabbitMqMessageCountTitle(row.topic)">
|
||
{{ rabbitMqReadyMessageCount(row.topic) }}
|
||
</div>
|
||
<div v-else-if="!isRocketMqCluster" class="topics-col">
|
||
<span v-if="row.topic.partitioned">{{ row.topic.partitions ? t("mqTopics.partitionCount", { count: row.topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||
<span v-else class="text-muted">-</span>
|
||
</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-consumers">{{ row.topic.consumerCount ?? "-" }}</div>
|
||
<div v-if="isRabbitMqCluster" class="topics-col topics-rates" data-testid="rabbitmq-rates" :title="rabbitMqRatesTitle()">
|
||
<span>{{ formatMqRateCompact(row.topic.publishRate) }}</span>
|
||
<span class="rate-sep">/</span>
|
||
<span>{{ formatMqRateCompact(row.topic.deliverRate) }}</span>
|
||
<span class="rate-sep">/</span>
|
||
<span>{{ formatMqRateCompact(row.topic.ackRate) }}</span>
|
||
</div>
|
||
<div class="topics-col actions" @click.stop>
|
||
<template v-if="isRocketMqCluster">
|
||
<button type="button" class="btn-sm" @click="openRocketMqDialog('status', row.topic)">{{ t("mqTopics.actionStatus") }}</button>
|
||
<button type="button" class="btn-sm" @click="openRocketMqDialog('route', row.topic)">{{ t("mqTopics.actionRoute") }}</button>
|
||
<button type="button" class="btn-sm" @click="openRocketMqDialog('consumers', row.topic)">{{ t("mqTopics.actionConsumers") }}</button>
|
||
<button v-if="isDlqTopic(row.topic)" type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
|
||
<template v-else>
|
||
<button type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
|
||
<button type="button" class="btn-sm" :disabled="readOnly" @click="navigateToMessages(row.topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
|
||
</template>
|
||
<button type="button" class="btn-sm" @click="openRocketMqDialog('config', row.topic)">{{ t("mqTopics.actionConfig") }}</button>
|
||
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('reset', row.topic)">{{ t("mqTopics.actionReset") }}</button>
|
||
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('skip', row.topic)">{{ t("mqTopics.actionSkip") }}</button>
|
||
<button type="button" class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(row.topic)" @click="handleDelete(row.topic)">{{ t("mqTopics.delete") }}</button>
|
||
</template>
|
||
<template v-else>
|
||
<button v-if="row.topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(row.topic)" type="button" @click="openPartitionsDialog(row.topic)" :disabled="readOnly || !row.topic.partitions" class="btn-sm">
|
||
{{ t("mqTopics.adjustPartitions") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="handleDelete(row.topic)"
|
||
:disabled="readOnly || isTopicProtected(row.topic) || (showNamespaceColumn && !row.topic.namespace)"
|
||
:title="showNamespaceColumn && !row.topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined"
|
||
class="btn-sm btn-danger"
|
||
>
|
||
{{ t("mqTopics.delete") }}
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</RecycleScroller>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Dialog -->
|
||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||
<div class="dialog" @click.stop>
|
||
<div class="dialog-header">
|
||
<h3>{{ t("mqTopics.createTopic") }}</h3>
|
||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div v-if="!isRocketMqCluster && !isFlatMqCluster" class="form-group">
|
||
<label>{{ t("mqTopics.tenantNamespace") }}</label>
|
||
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
|
||
</div>
|
||
<div v-if="isRocketMqCluster" class="form-group">
|
||
<label>{{ t("mqTopics.clusterName") }}</label>
|
||
<input type="text" :value="rocketMqClusterName" disabled />
|
||
</div>
|
||
<div v-if="isRocketMqCluster" class="form-group">
|
||
<label>{{ t("mqTopics.brokerName") }}*</label>
|
||
<select v-model="formData.brokerName" :disabled="readOnly">
|
||
<option value="">{{ t("mqTopics.allBrokers") }}</option>
|
||
<option v-for="broker in rocketMqMasterBrokers.length ? rocketMqMasterBrokers : rocketMqBrokerOptions" :key="broker.brokerName || broker.id" :value="broker.brokerName || ''">
|
||
{{ broker.brokerName || `${broker.host}:${broker.port}` }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.topicName") }}*</label>
|
||
<input v-model="formData.topicName" type="text" :placeholder="t('mqTopics.topicNamePlaceholder')" :disabled="readOnly" />
|
||
</div>
|
||
<div v-if="isRocketMqCluster" class="form-group">
|
||
<label>{{ t("mqTopics.messageType") }}*</label>
|
||
<select v-model="formData.messageType" :disabled="readOnly">
|
||
<option v-for="type in rocketMqCreatableTopicTypes" :key="type" :value="type">
|
||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||
</option>
|
||
</select>
|
||
<div class="form-hint">{{ t("mqTopics.messageTypeHint") }}</div>
|
||
</div>
|
||
<div v-if="isRocketMqCluster" class="form-row-inline">
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.readQueues") }}*</label>
|
||
<input v-model.number="formData.readQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.writeQueues") }}*</label>
|
||
<input v-model.number="formData.writeQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.perm") }}*</label>
|
||
<select v-model.number="formData.perm" :disabled="readOnly">
|
||
<option v-for="opt in ROCKETMQ_PERM_OPTIONS" :key="opt.value" :value="opt.value">{{ t(opt.labelKey) }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div v-if="!isRocketMqCluster" class="form-group">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
|
||
{{ t("mqTopics.persistentRecommended") }}
|
||
</label>
|
||
<div class="form-hint">{{ t("mqTopics.persistentHint") }}</div>
|
||
</div>
|
||
<div v-if="!isRocketMqCluster && supportsPartitionedTopics !== false" class="form-group">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
|
||
{{ t("mqTopics.enablePartitions") }}
|
||
</label>
|
||
<div v-if="formData.partitioned" class="form-subgroup">
|
||
<label>{{ t("mqTopics.partitionQuantity") }}*</label>
|
||
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
|
||
<div class="form-hint">{{ t("mqTopics.partitionHint") }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqTopics.create") }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Update Partitions Dialog -->
|
||
<div v-if="showPartitionsDialog" class="dialog-overlay" @click="showPartitionsDialog = false">
|
||
<div class="dialog" @click.stop>
|
||
<div class="dialog-header">
|
||
<h3>{{ t("mqTopics.updatePartitionsTitle", { name: editingTopic?.shortName }) }}</h3>
|
||
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.currentPartitions") }}</label>
|
||
<input type="number" :value="editingTopic?.partitions" disabled />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t("mqTopics.newPartitions") }}*</label>
|
||
<input v-model.number="newPartitions" type="number" :min="editingCurrentPartitions + 1" max="256" :disabled="readOnly" @change="normalizePartitionInput" @blur="normalizePartitionInput" />
|
||
<div class="form-hint">{{ t("mqTopics.partitionMinHint", { min: editingCurrentPartitions + 1 }) }}</div>
|
||
</div>
|
||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button @click="showPartitionsDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||
<button @click="handleUpdatePartitions" :disabled="loading || !canSubmitPartitionUpdate" class="btn-primary">{{ t("mqTopics.update") }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Confirm Dialog -->
|
||
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqTopics.delete')" :message="t('mqTopics.confirmDelete', { name: deleteTarget?.shortName ?? '' })" :confirm-label="t('mqTopics.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
|
||
|
||
<RocketMqTopicDialogs
|
||
v-if="isRocketMqCluster"
|
||
:connection-id="connectionId"
|
||
:tenant="tenant"
|
||
:namespace="namespace"
|
||
:topic="rocketMqDialogTopic"
|
||
:dialog="activeRocketMqDialog"
|
||
:read-only="readOnly"
|
||
:broker-options="rocketMqBrokerOptions"
|
||
@close="closeRocketMqDialog"
|
||
@navigate="handleRocketMqNavigate"
|
||
@refreshed="loadTopics"
|
||
/>
|
||
|
||
<div v-if="showSendDialog && sendDialogTopic" class="dialog-overlay" @click="closeSendDialog">
|
||
<div class="dialog send-dialog" @click.stop>
|
||
<div class="dialog-header">
|
||
<h3>{{ t("mqMessages.title") }}</h3>
|
||
<button type="button" class="btn-close" @click="closeSendDialog">×</button>
|
||
</div>
|
||
<div class="dialog-body send-dialog-body">
|
||
<SendMessagePanel embedded :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="sendDialogTopic" :read-only="readOnly" mq-system-kind="rocketmq" :is-flat-mq-cluster="true" :supports-peek-messages="false" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@import "./shared/mqPanel.css";
|
||
|
||
.topics-panel {
|
||
--topics-surface: var(--card, var(--color-background, #ffffff));
|
||
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
|
||
--topics-border: var(--border, var(--color-border, #e5e7eb));
|
||
--topics-border-light: color-mix(in srgb, var(--topics-border) 68%, transparent);
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.rabbitmq-subtabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 8px 16px 0;
|
||
border-bottom: 1px solid var(--color-border);
|
||
background: var(--color-background-secondary);
|
||
}
|
||
|
||
.rabbitmq-subtabs button {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
border-bottom: 2px solid transparent;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.rabbitmq-subtabs button:hover {
|
||
color: var(--color-text);
|
||
background: var(--color-hover);
|
||
}
|
||
|
||
.rabbitmq-subtabs button.active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
background: var(--color-background);
|
||
}
|
||
|
||
.panel-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.topic-type-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 14px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--topics-border-light);
|
||
background: color-mix(in srgb, var(--topics-header-bg) 72%, transparent);
|
||
}
|
||
|
||
.topic-type-filters-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary, #6b7280);
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.checkbox-label.compact {
|
||
font-size: 12px;
|
||
gap: 6px;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toolbar-left h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.topic-search {
|
||
width: min(320px, 32vw);
|
||
min-width: 180px;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.topic-search:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||
}
|
||
|
||
.topic-count {
|
||
flex: 0 0 auto;
|
||
color: var(--color-text-tertiary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.checkbox-label input[type="checkbox"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.panel-placeholder,
|
||
.panel-error,
|
||
.panel-loading {
|
||
padding: 24px;
|
||
text-align: center;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.panel-error {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
.topics-table {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--topics-surface);
|
||
}
|
||
|
||
.topics-table-hscroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
|
||
.topics-table-header,
|
||
.topics-row {
|
||
display: grid;
|
||
align-items: center;
|
||
column-gap: 8px;
|
||
padding: 0 12px;
|
||
min-width: var(--topics-table-min-width, 100%);
|
||
}
|
||
|
||
.topics-table-header {
|
||
flex: 0 0 auto;
|
||
height: 38px;
|
||
/* Match RecycleScroller classic-scrollbar gutter so header/body columns stay aligned. */
|
||
overflow: hidden;
|
||
scrollbar-gutter: stable;
|
||
background: var(--topics-header-bg);
|
||
border-bottom: 1px solid var(--topics-border);
|
||
box-shadow:
|
||
0 1px 0 var(--topics-border),
|
||
0 2px 6px rgba(0, 0, 0, 0.04);
|
||
z-index: 2;
|
||
}
|
||
|
||
.topics-table-header .topics-col {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.topics-sort-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.topics-scroller {
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
min-width: var(--topics-table-min-width, 100%);
|
||
/* RecycleScroller owns overflow-y; reserve gutter for classic scrollbars (Windows). */
|
||
scrollbar-gutter: stable;
|
||
}
|
||
|
||
.topics-row {
|
||
width: 100%;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid var(--topics-border-light);
|
||
background: var(--topics-surface);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.topics-row:hover {
|
||
background: var(--color-hover);
|
||
}
|
||
|
||
.topics-row.selected {
|
||
background: var(--color-primary-alpha);
|
||
}
|
||
|
||
.topics-col {
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.topic-name-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.topic-name-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.topic-name {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.badge-default {
|
||
background: var(--color-background-secondary);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.badge-info {
|
||
background: var(--color-info-alpha);
|
||
color: var(--color-info);
|
||
}
|
||
|
||
.badge-warning {
|
||
background: var(--color-warning-alpha);
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.text-muted {
|
||
color: var(--color-text-tertiary);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* RabbitMQ queue feature badges (compact, tooltip-bearing). */
|
||
.queue-state {
|
||
margin-left: 4px;
|
||
font-size: 11px;
|
||
color: var(--color-text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.feature-badges {
|
||
display: inline-flex;
|
||
gap: 3px;
|
||
flex-wrap: nowrap;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.feature-badge {
|
||
display: inline-block;
|
||
padding: 1px 5px;
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
line-height: 1.5;
|
||
background: var(--color-background-secondary);
|
||
border: 1px solid var(--color-border);
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
cursor: help;
|
||
}
|
||
|
||
.feature-badge.feature-flag {
|
||
background: var(--color-warning-alpha);
|
||
border-color: transparent;
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.topics-rates {
|
||
font-family: var(--font-mono, monospace);
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
cursor: help;
|
||
}
|
||
|
||
.topics-rates .rate-sep {
|
||
margin: 0 3px;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
overflow-x: auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.form-row-inline {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Dialog styles */
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.dialog {
|
||
background: var(--color-background);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
width: 90%;
|
||
max-width: 500px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.dialog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.dialog-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.dialog-body {
|
||
padding: 20px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-group input[type="text"],
|
||
.form-group input[type="number"] {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-group input:disabled {
|
||
background: var(--color-background-secondary);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.form-subgroup {
|
||
margin-top: 12px;
|
||
padding-left: 24px;
|
||
}
|
||
|
||
.form-hint {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
.form-error {
|
||
margin-top: 12px;
|
||
padding: 8px 12px;
|
||
background: var(--color-error-bg);
|
||
color: var(--color-error);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.send-dialog {
|
||
width: min(760px, calc(100vw - 32px));
|
||
max-height: calc(100vh - 64px);
|
||
}
|
||
|
||
.send-dialog-body {
|
||
padding: 0;
|
||
max-height: calc(100vh - 140px);
|
||
overflow: auto;
|
||
}
|
||
</style>
|