feat(rabbitmq): add message queue management support

This commit is contained in:
Yann丶 authored and GitHub committed 2026-07-22 23:52:09 +08:00
1 parent 503d6748d4
commit 39e554369f
76 files changed
+14915 -707

No files matched your search

@@ -49,6 +49,7 @@ import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers";
import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
@@ -518,6 +519,8 @@ const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
const mqRocketmqNamesrvAddr = ref("127.0.0.1:9876");
const mqRocketmqClusterName = ref("");
const mqRabbitmqAddresses = ref("127.0.0.1:5672");
const mqRabbitmqVirtualHost = ref("/");
const mqKafkaBootstrapServers = ref("127.0.0.1:9092");
const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO);
const mqKafkaSaslMechanism = ref("PLAIN");
@@ -544,11 +547,13 @@ const MQ_DRIVER_LABELS: Record<MqSystemKind, string> = {
pulsar: "Apache Pulsar",
kafka: "Apache Kafka",
rocketmq: "Apache RocketMQ",
rabbitmq: "RabbitMQ",
};
function mqSystemKindFromProfile(profile: string): MqSystemKind {
if (profile === "kafka") return "kafka";
if (profile === "rocketmq") return "rocketmq";
if (profile === "rabbitmq") return "rabbitmq";
return "pulsar";
}
@@ -558,7 +563,7 @@ function syncMqSystemKindFromSelectedType() {
}
function resolveMqSystemKind(config?: Partial<MqAdminConfig>): MqSystemKind {
if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "pulsar") {
if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "rabbitmq" || config?.systemKind === "pulsar") {
return config.systemKind;
}
return mqSystemKindFromProfile(selectedType.value);
@@ -804,6 +809,7 @@ const driverProfiles: Record<
mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", host: "127.0.0.1" },
kafka: { type: "mq", port: 9092, user: "", label: "Apache Kafka", icon: "kafka", host: "127.0.0.1" },
rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" },
rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" },
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
@@ -835,6 +841,7 @@ function profileForConfig(config: ConnectionConfig) {
const kind = (config.external_config as MqAdminConfig | undefined)?.systemKind;
if (kind === "kafka") return "kafka";
if (kind === "rocketmq") return "rocketmq";
if (kind === "rabbitmq") return "rabbitmq";
return "mq";
}
if (config.db_type === "dameng") return "dm";
@@ -883,10 +890,13 @@ function resetMqFields(config?: Partial<MqAdminConfig>) {
const properties = mqExtraProperties(extra);
const jaasConfig = mqExtraPropertyString(extra, "sasl.jaas.config");
mqSystemKind.value = systemKind;
mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" || systemKind === "rocketmq" ? "" : "http://127.0.0.1:8080");
const storedAdminUrl = config?.adminUrl?.trim() || (config ? mqExtraString(config as Record<string, unknown>, "admin_url").trim() : "");
mqAdminUrl.value = storedAdminUrl || (systemKind === "kafka" || systemKind === "rocketmq" || systemKind === "rabbitmq" ? "" : "http://127.0.0.1:8080");
mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092";
mqRocketmqNamesrvAddr.value = mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr") || "127.0.0.1:9876";
mqRocketmqClusterName.value = mqExtraString(extra, "clusterName") || mqExtraString(extra, "cluster_name");
mqRabbitmqAddresses.value = mqExtraString(extra, "addresses") || "127.0.0.1:5672";
mqRabbitmqVirtualHost.value = mqExtraString(extra, "virtualHost") || "/";
mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO;
mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN";
mqKafkaKerberosPrincipal.value = parseJaasStringProperty(jaasConfig, "principal");
@@ -934,6 +944,14 @@ function defaultMqFieldsForProfile(profile: string): Partial<MqAdminConfig> | un
extra: { namesrvAddr: "127.0.0.1:9876" },
};
}
if (profile === "rabbitmq") {
return {
systemKind: "rabbitmq",
adminUrl: "",
auth: { kind: "none" },
extra: { addresses: "127.0.0.1:5672", virtualHost: "/" },
};
}
return undefined;
}
@@ -960,6 +978,12 @@ watch(mqSystemKind, (kind) => {
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (kind === "rabbitmq") {
if (!mqRabbitmqAddresses.value.trim()) mqRabbitmqAddresses.value = "127.0.0.1:5672";
if (!mqRabbitmqVirtualHost.value.trim()) mqRabbitmqVirtualHost.value = "/";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080";
});
@@ -1126,6 +1150,21 @@ function buildMqAdminConfig(): MqAdminConfig {
};
}
if (systemKind === "rabbitmq") {
const addresses = normalizeRabbitmqAddresses(mqRabbitmqAddresses.value);
const extra: Record<string, unknown> = {
addresses,
virtualHost: mqRabbitmqVirtualHost.value.trim() || "/",
};
return {
systemKind: "rabbitmq",
adminUrl: mqAdminUrl.value.trim(),
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
return {
systemKind: mqSystemKind.value,
adminUrl: requireMqField(mqAdminUrl.value, t("connection.mqAdminUrlRequired")),
@@ -1493,6 +1532,20 @@ function applyMqKafkaBootstrapServers(config: LegacyConnectionConfig, bootstrapS
config.ssl = securityProtocol === "SSL" || securityProtocol === "SASL_SSL";
}
function applyMqRabbitmqAddresses(config: LegacyConnectionConfig, addresses: string) {
const first = normalizeRabbitmqAddresses(addresses).split(",")[0];
if (!first) throw new Error(t("connection.mqRabbitmqAddressesRequired"));
let parsed: URL;
try {
parsed = new URL(`amqp://${first}`);
} catch {
throw new Error(t("connection.mqRabbitmqAddressesInvalid"));
}
config.host = parsed.hostname;
config.port = Number(parsed.port) || 5672;
config.ssl = false;
}
function applyNacosServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
let parsed: URL;
try {
@@ -1987,6 +2040,7 @@ const iconTypeMap: Record<string, string> = {
mq: "mq",
kafka: "kafka",
rocketmq: "rocketmq",
rabbitmq: "rabbitmq",
nacos: "nacos",
dm: "dm",
h2: "h2",
@@ -2082,6 +2136,7 @@ const dbOptions: DbOption[] = [
{ value: "mq", label: "Apache Pulsar" },
{ value: "kafka", label: "Apache Kafka" },
{ value: "rocketmq", label: "Apache RocketMQ" },
{ value: "rabbitmq", label: "RabbitMQ" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
@@ -2495,6 +2550,7 @@ const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") {
if (mqSystemKind.value === "kafka") return !!mqKafkaBootstrapServers.value.trim();
if (mqSystemKind.value === "rocketmq") return !!mqRocketmqNamesrvAddr.value.trim();
if (mqSystemKind.value === "rabbitmq") return !!mqRabbitmqAddresses.value.trim();
return !!mqAdminUrl.value.trim();
}
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
@@ -2829,6 +2885,9 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
} else if (mqConfig.systemKind === "rocketmq") {
const extra = mqExtraRecord(mqConfig);
applyMqRocketmqNamesrv(config, mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr"));
} else if (mqConfig.systemKind === "rabbitmq") {
const extra = mqExtraRecord(mqConfig);
applyMqRabbitmqAddresses(config, mqExtraString(extra, "addresses"));
} else {
applyMqAdminUrl(config, mqConfig.adminUrl);
}
@@ -4737,6 +4796,25 @@ function openExternalUrl(url: string) {
<Input v-model="mqRocketmqClusterName" class="col-span-3" :placeholder="t('connection.rocketmqClusterNamePlaceholder')" />
</div>
</template>
<template v-else-if="mqSystemKind === 'rabbitmq'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqRabbitmqAddresses") }}</Label>
<Input v-model="mqRabbitmqAddresses" class="col-span-3" :placeholder="t('connection.mqRabbitmqAddressesPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqVirtualHost") }}</Label>
<Input v-model="mqRabbitmqVirtualHost" class="col-span-3" :placeholder="t('connection.mqVirtualHostPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqRabbitmqAdminUrl") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="mqAdminUrl" :placeholder="t('connection.mqRabbitmqAdminUrlPlaceholder')" />
<p class="text-xs text-muted-foreground">
{{ t("connection.mqRabbitmqAdminUrlHint") }}
</p>
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAdminUrl") }}</Label>
@@ -4763,11 +4841,11 @@ function openExternalUrl(url: string) {
<template v-else-if="mqAuthKind === 'basic'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqAccessKey") : t("connection.user") }}</Label>
<Input v-model="mqBasicUsername" class="col-span-3" />
<Input v-model="mqBasicUsername" class="col-span-3" :placeholder="mqSystemKind === 'rabbitmq' ? t('connection.mqRabbitmqUsernamePlaceholder') : ''" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqSecretKey") : t("connection.password") }}</Label>
<PasswordInput v-model="mqBasicPassword" class="col-span-3" />
<PasswordInput v-model="mqBasicPassword" class="col-span-3" :placeholder="mqSystemKind === 'rabbitmq' ? t('connection.mqRabbitmqPasswordPlaceholder') : ''" />
</div>
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqSaslMechanism") }}</Label>
@@ -89,6 +89,7 @@ const assetIcons: Record<string, string> = {
pulsar: "pulsar",
kafka: "kafka",
rocketmq: "rocketmq",
rabbitmq: "rabbitmq",
nacos: "nacos.png",
iris: "iris.png",
influxdb: "influxdb",
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
@@ -457,6 +457,7 @@ function tabDatabaseIconType(tab: QueryTab) {
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
if (connection.driver_profile === "rabbitmq" || systemKind === "rabbitmq") return "rabbitmq";
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
}
return connection.driver_profile || connection.db_type;
@@ -0,0 +1,852 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchanges, mqListTopics, mqUnbind } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
readOnly?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const exchanges = ref<MqExchangeInfo[]>([]);
const loading = ref(false);
const error = ref<string>();
const exchangeSearch = ref("");
const selectedExchange = ref<MqExchangeInfo>();
const bindings = ref<MqBindingInfo[]>([]);
const bindingsLoading = ref(false);
const bindingsError = ref<string>();
const dialogError = ref<string>();
const showCreateDialog = ref(false);
const createForm = ref({
name: "",
type: "direct" as MqExchangeType | string,
durable: true,
autoDelete: false,
});
const exchangeTypeOptions = RABBITMQ_EXCHANGE_TYPES;
const showDeleteDialog = ref(false);
const deleteTarget = ref<MqExchangeInfo>();
const deleting = ref(false);
const showBindDialog = ref(false);
const bindForm = ref({
destinationType: "queue" as "queue" | "exchange",
destination: "",
routingKey: "",
argumentsText: "",
});
const binding = ref(false);
const showUnbindDialog = ref(false);
const unbindTarget = ref<MqBindingInfo>();
const unbinding = ref(false);
const availableQueues = ref<TopicInfo[]>([]);
const filteredExchanges = computed(() => {
const query = exchangeSearch.value.trim().toLowerCase();
if (!query) return exchanges.value;
return exchanges.value.filter((exchange) => rabbitMqExchangeDisplayName(exchange).toLowerCase().includes(query));
});
function nsRef(): NamespaceRef | null {
if (!props.tenant || !props.namespace) return null;
return { tenant: props.tenant, namespace: props.namespace };
}
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`, and
// row-level operations must target that vhost rather than the "*" selection.
const showNamespaceColumn = computed(() => isAllVhostsNamespace(props.namespace));
function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null {
if (!props.tenant || !props.namespace) return null;
const namespace = resolveMqRowNamespace(row, props.namespace);
if (!namespace) return null;
return { tenant: props.tenant, namespace };
}
function guardWritable() {
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return false;
}
return true;
}
async function loadExchanges() {
const ns = nsRef();
if (!ns) {
exchanges.value = [];
return;
}
loading.value = true;
error.value = undefined;
try {
exchanges.value = await mqListExchanges(props.connectionId, ns);
if (selectedExchange.value && !exchanges.value.some((exchange) => exchange.name === selectedExchange.value?.name && exchange.namespace === selectedExchange.value?.namespace)) {
selectedExchange.value = undefined;
bindings.value = [];
}
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
async function loadBindings(exchange: MqExchangeInfo) {
const ns = nsRefFor(exchange);
if (!ns) return;
bindingsLoading.value = true;
bindingsError.value = undefined;
try {
bindings.value = await mqListBindings(props.connectionId, ns, { exchange: exchange.name });
} catch (e: unknown) {
bindingsError.value = formatError(e);
} finally {
bindingsLoading.value = false;
}
}
async function loadQueues() {
const ns = nsRefFor(selectedExchange.value);
if (!ns) return;
try {
availableQueues.value = await mqListTopics(props.connectionId, ns, { includeNonPersistent: false });
} catch (e: unknown) {
console.warn("[DBX] Failed to load queues for binding dialog:", e);
}
}
function selectExchange(exchange: MqExchangeInfo) {
if (selectedExchange.value?.name === exchange.name && selectedExchange.value?.namespace === exchange.namespace) {
selectedExchange.value = undefined;
bindings.value = [];
return;
}
selectedExchange.value = exchange;
void loadBindings(exchange);
}
function openCreateDialog() {
if (!guardWritable()) return;
dialogError.value = undefined;
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
showCreateDialog.value = true;
}
async function handleCreate() {
if (!guardWritable()) return;
const ns = nsRef();
if (!createForm.value.name.trim() || !ns) {
dialogError.value = t("mqExchanges.nameRequired");
return;
}
loading.value = true;
error.value = undefined;
try {
await mqCreateExchange(props.connectionId, ns, {
name: createForm.value.name.trim(),
type: createForm.value.type,
durable: createForm.value.durable,
autoDelete: createForm.value.autoDelete,
});
showCreateDialog.value = false;
dialogError.value = undefined;
await loadExchanges();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
function openDeleteDialog(exchange: MqExchangeInfo) {
if (!guardWritable()) return;
if (isBuiltinRabbitMqExchange(exchange)) return;
deleteTarget.value = exchange;
showDeleteDialog.value = true;
}
async function confirmDelete() {
const target = deleteTarget.value;
if (!target) return;
const ns = nsRefFor(target);
if (!ns) {
error.value = t("mqAdmin.selectNamespaceToWrite");
return;
}
deleting.value = true;
error.value = undefined;
try {
await mqDeleteExchange(props.connectionId, ns, target.name);
showDeleteDialog.value = false;
if (selectedExchange.value?.name === target.name && selectedExchange.value?.namespace === target.namespace) {
selectedExchange.value = undefined;
bindings.value = [];
}
await loadExchanges();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
deleting.value = false;
}
}
function openBindDialog() {
if (!guardWritable()) return;
if (!selectedExchange.value) return;
dialogError.value = undefined;
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
void loadQueues();
showBindDialog.value = true;
}
function parseBindingArguments(): Record<string, unknown> | undefined {
const text = bindForm.value.argumentsText.trim();
if (!text) return undefined;
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(t("mqExchanges.argumentsMustBeObject"));
}
return parsed as Record<string, unknown>;
}
async function handleBind() {
if (!guardWritable()) return;
const exchange = selectedExchange.value;
if (!exchange) return;
const ns = nsRefFor(exchange);
if (!ns) {
dialogError.value = t("mqAdmin.selectNamespaceToWrite");
return;
}
if (!bindForm.value.destination.trim()) {
dialogError.value = t("mqExchanges.destinationRequired");
return;
}
binding.value = true;
dialogError.value = undefined;
try {
const args = parseBindingArguments();
await mqBind(props.connectionId, ns, {
source: exchange.name,
destination: bindForm.value.destination.trim(),
destinationType: bindForm.value.destinationType,
routingKey: bindForm.value.routingKey.trim() || undefined,
arguments: args,
});
showBindDialog.value = false;
await loadBindings(exchange);
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
binding.value = false;
}
}
function openUnbindDialog(bindingRow: MqBindingInfo) {
if (!guardWritable()) return;
unbindTarget.value = bindingRow;
showUnbindDialog.value = true;
}
async function confirmUnbind() {
const target = unbindTarget.value;
if (!target) return;
const ns = nsRefFor(target ?? selectedExchange.value);
if (!ns) {
bindingsError.value = t("mqAdmin.selectNamespaceToWrite");
return;
}
unbinding.value = true;
bindingsError.value = undefined;
try {
await mqUnbind(props.connectionId, ns, target);
showUnbindDialog.value = false;
if (selectedExchange.value) {
await loadBindings(selectedExchange.value);
}
} catch (e: unknown) {
bindingsError.value = formatError(e);
} finally {
unbinding.value = false;
}
}
function formatBindingArguments(bindingRow: MqBindingInfo): string {
if (!bindingRow.arguments || !Object.keys(bindingRow.arguments).length) return "-";
return JSON.stringify(bindingRow.arguments);
}
watch(
() => [props.tenant, props.namespace],
() => {
selectedExchange.value = undefined;
bindings.value = [];
loadExchanges();
},
{ immediate: true },
);
</script>
<template>
<div class="exchanges-panel">
<div class="panel-toolbar">
<div class="toolbar-left">
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length" />
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchanges.length }}</span>
</div>
<div class="toolbar-actions">
<button @click="loadExchanges" :disabled="loading || !tenant || !namespace" class="btn-secondary">
{{ loading ? t("mqExchanges.refreshing") : t("mqExchanges.refresh") }}
</button>
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace || showNamespaceColumn" :title="showNamespaceColumn ? t('mqAdmin.selectNamespaceToCreate') : undefined" class="btn-primary">+ {{ t("mqExchanges.createExchange") }}</button>
</div>
</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqExchanges.selectNamespace") }}</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !exchanges.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ t("mqExchanges.noExchanges") }}</div>
<div v-else-if="!filteredExchanges.length" class="panel-placeholder">{{ t("mqExchanges.noMatches") }}</div>
<div v-else class="exchanges-table">
<table>
<thead>
<tr>
<th>{{ t("mqExchanges.name") }}</th>
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
<th>{{ t("mqExchanges.type") }}</th>
<th>{{ t("mqExchanges.durable") }}</th>
<th>{{ t("mqExchanges.autoDelete") }}</th>
<th>{{ t("mqExchanges.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="exchange in filteredExchanges" :key="`${exchange.namespace ?? ''}:${exchange.name || '(default)'}`" :class="{ selected: selectedExchange?.name === exchange.name && selectedExchange?.namespace === exchange.namespace }" @click="selectExchange(exchange)">
<td class="exchange-name">
<div class="exchange-name-cell">
<span>{{ rabbitMqExchangeDisplayName(exchange) }}</span>
<span v-if="isBuiltinRabbitMqExchange(exchange)" class="badge badge-warning">{{ t("mqExchanges.builtin") }}</span>
</div>
</td>
<td v-if="showNamespaceColumn">{{ exchange.namespace || "-" }}</td>
<td>
<span class="badge badge-info">{{ exchange.type }}</span>
</td>
<td>{{ exchange.durable ? t("mqExchanges.yes") : t("mqExchanges.no") }}</td>
<td>{{ exchange.autoDelete ? t("mqExchanges.yes") : t("mqExchanges.no") }}</td>
<td class="actions" @click.stop>
<button class="btn-sm" @click="selectExchange(exchange)">{{ t("mqExchanges.viewBindings") }}</button>
<button class="btn-sm btn-danger" :disabled="readOnly || isBuiltinRabbitMqExchange(exchange) || (showNamespaceColumn && !exchange.namespace)" :title="showNamespaceColumn && !exchange.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" @click="openDeleteDialog(exchange)">
{{ t("mqExchanges.delete") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Bindings of the selected exchange -->
<div v-if="selectedExchange" class="bindings-section">
<div class="bindings-header">
<h4>{{ t("mqExchanges.bindingsTitle", { name: rabbitMqExchangeDisplayName(selectedExchange) }) }}</h4>
<div class="toolbar-actions">
<button class="btn-sm" :disabled="bindingsLoading" @click="loadBindings(selectedExchange)">
{{ bindingsLoading ? t("mqExchanges.refreshing") : t("mqExchanges.refresh") }}
</button>
<button class="btn-sm" :disabled="readOnly" @click="openBindDialog">+ {{ t("mqExchanges.bindDestination") }}</button>
</div>
</div>
<div v-if="bindingsError" class="panel-error">{{ bindingsError }}</div>
<div v-else-if="bindingsLoading && !bindings.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
<div v-else-if="!bindings.length" class="panel-placeholder">{{ t("mqExchanges.noBindings") }}</div>
<div v-else class="bindings-table">
<table>
<thead>
<tr>
<th>{{ t("mqExchanges.bindingSource") }}</th>
<th>{{ t("mqExchanges.bindingDestination") }}</th>
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
<th>{{ t("mqExchanges.bindingType") }}</th>
<th>{{ t("mqExchanges.routingKey") }}</th>
<th>{{ t("mqExchanges.arguments") }}</th>
<th>{{ t("mqExchanges.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(bindingRow, index) in bindings" :key="`${bindingRow.namespace ?? ''}:${bindingRow.source}->${bindingRow.destination}:${bindingRow.routingKey ?? ''}:${index}`">
<td>{{ rabbitMqExchangeDisplayName({ name: bindingRow.source }) }}</td>
<td class="exchange-name">{{ bindingRow.destination }}</td>
<td v-if="showNamespaceColumn">{{ bindingRow.namespace || "-" }}</td>
<td>
<span class="badge" :class="bindingRow.destinationType === 'exchange' ? 'badge-info' : 'badge-default'">
{{ bindingRow.destinationType === "exchange" ? t("mqExchanges.destinationTypeExchange") : t("mqExchanges.destinationTypeQueue") }}
</span>
</td>
<td>{{ bindingRow.routingKey || "-" }}</td>
<td class="binding-arguments">{{ formatBindingArguments(bindingRow) }}</td>
<td class="actions">
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openUnbindDialog(bindingRow)">{{ t("mqExchanges.unbind") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Exchange Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqExchanges.createExchange") }}</h3>
<button @click="showCreateDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqExchanges.virtualHost") }}</label>
<input type="text" :value="namespace" disabled />
</div>
<div class="form-group">
<label>{{ t("mqExchanges.name") }}*</label>
<input v-model="createForm.name" type="text" :placeholder="t('mqExchanges.namePlaceholder')" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqExchanges.type") }}*</label>
<select v-model="createForm.type" :disabled="readOnly">
<option v-for="type in exchangeTypeOptions" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="createForm.durable" :disabled="readOnly" />
{{ t("mqExchanges.durable") }}
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="createForm.autoDelete" :disabled="readOnly" />
{{ t("mqExchanges.autoDelete") }}
</label>
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqExchanges.cancel") }}</button>
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqExchanges.create") }}</button>
</div>
</div>
</div>
<!-- Bind Dialog -->
<div v-if="showBindDialog" class="dialog-overlay" @click="showBindDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqExchanges.bindDialogTitle", { name: selectedExchange ? rabbitMqExchangeDisplayName(selectedExchange) : "" }) }}</h3>
<button @click="showBindDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqExchanges.bindingType") }}*</label>
<select v-model="bindForm.destinationType" :disabled="readOnly">
<option value="queue">{{ t("mqExchanges.destinationTypeQueue") }}</option>
<option value="exchange">{{ t("mqExchanges.destinationTypeExchange") }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqExchanges.bindingDestination") }}*</label>
<input v-model="bindForm.destination" type="text" list="mq-exchange-bind-queues" :placeholder="bindForm.destinationType === 'queue' ? t('mqExchanges.queueNamePlaceholder') : t('mqExchanges.exchangeNamePlaceholder')" :disabled="readOnly" />
<datalist id="mq-exchange-bind-queues">
<option v-for="queue in availableQueues" :key="queue.name" :value="queue.shortName" />
</datalist>
</div>
<div class="form-group">
<label>{{ t("mqExchanges.routingKey") }}</label>
<input v-model="bindForm.routingKey" type="text" :placeholder="t('mqExchanges.routingKeyPlaceholder')" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqExchanges.arguments") }}</label>
<textarea v-model="bindForm.argumentsText" rows="3" class="arguments-textarea" :placeholder="t('mqExchanges.argumentsPlaceholder')" :disabled="readOnly" />
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showBindDialog = false" class="btn-secondary">{{ t("mqExchanges.cancel") }}</button>
<button @click="handleBind" :disabled="binding || readOnly" class="btn-primary">{{ t("mqExchanges.bind") }}</button>
</div>
</div>
</div>
<!-- Delete Exchange Confirm -->
<DangerConfirmDialog
v-model:open="showDeleteDialog"
:title="t('mqExchanges.delete')"
:message="t('mqExchanges.confirmDelete', { name: deleteTarget ? rabbitMqExchangeDisplayName(deleteTarget) : '' })"
:confirm-label="t('mqExchanges.delete')"
:loading="deleting"
:close-on-confirm="false"
@confirm="confirmDelete"
/>
<!-- Unbind Confirm -->
<DangerConfirmDialog
v-model:open="showUnbindDialog"
:title="t('mqExchanges.unbind')"
:message="t('mqExchanges.confirmUnbind', { destination: unbindTarget?.destination ?? '', source: unbindTarget?.source ?? '' })"
:confirm-label="t('mqExchanges.unbind')"
:loading="unbinding"
:close-on-confirm="false"
@confirm="confirmUnbind"
/>
</div>
</template>
<style scoped>
.exchanges-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 16px;
overflow: auto;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.exchange-search {
width: min(320px, 32vw);
min-width: 180px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.exchange-search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.exchange-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.exchanges-table,
.bindings-table {
overflow: auto;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
}
.exchanges-table tbody tr {
cursor: pointer;
transition: background 0.2s;
}
.exchanges-table tbody tr:hover {
background: var(--color-hover);
}
.exchanges-table tbody tr.selected {
background: var(--color-primary-alpha);
}
.exchange-name {
font-weight: 500;
}
.exchange-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
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);
}
.binding-arguments {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.bindings-section {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px;
background: var(--color-background-secondary);
}
.bindings-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.bindings-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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: 8px;
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;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.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 select,
.arguments-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.arguments-textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
resize: vertical;
}
.form-group input:disabled {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
.form-error {
color: var(--color-error);
font-size: 13px;
}
</style>
@@ -174,7 +174,7 @@ function getTopicRef(): TopicRef | null {
if (!props.topic || !props.tenant || !props.namespace) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
namespace: props.topic.namespace || props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
+299 -19
View File
@@ -2,11 +2,25 @@
import { formatError } from "@/lib/backend/errorUtils";
import { ref, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, TopicInfo } from "@/types/mq";
import { mqTestConnection } from "@/lib/backend/api";
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
import { defaultMqCapabilitiesForSystemKind, isFlatMqSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection, type MqTab } from "@/lib/mq/mqConsoleDefaults";
import {
defaultMqCapabilitiesForSystemKind,
isAllVhostsNamespace,
isFlatMqSystemKind,
normalizeMqTabForSystemKind,
RABBITMQ_ALL_VHOSTS,
RABBITMQ_MQ_TENANT,
resolveAvailableMqTabs,
resolveInitialMqTab,
resolveMqSystemKindFromConnection,
resolveRabbitMqDefaultVhost,
type MqTab,
} from "@/lib/mq/mqConsoleDefaults";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { AcceptableValue } from "reka-ui";
import TenantsPanel from "./TenantsPanel.vue";
import NamespacesPanel from "./NamespacesPanel.vue";
import TopicsPanel from "./TopicsPanel.vue";
@@ -21,6 +35,10 @@ import RocketMqMessagesPanel from "./RocketMqMessagesPanel.vue";
import SendMessagePanel from "./SendMessagePanel.vue";
import MessageQueryPanel from "./MessageQueryPanel.vue";
import BrokerPanel from "./BrokerPanel.vue";
import RabbitMqClientsPanel from "./rabbitmq/RabbitMqClientsPanel.vue";
import RabbitMqPermissionsPanel from "./rabbitmq/RabbitMqPermissionsPanel.vue";
import RabbitMqPoliciesPanel from "./rabbitmq/RabbitMqPoliciesPanel.vue";
import RabbitMqMonitoringPanel from "./rabbitmq/RabbitMqMonitoringPanel.vue";
interface Props {
connectionId: string;
@@ -35,11 +53,22 @@ const connectionStore = useConnectionStore();
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
const FLAT_MQ_CONTEXT = "_flat_mq";
function normalizeFlatMqTenant(tenant?: string): string | undefined {
function normalizeFlatMqTenant(tenant: string | undefined, systemKind: MqSystemKind | undefined): string | undefined {
// RabbitMQ has no tenant concept: the console pins a synthetic tenant and
// exposes virtual hosts as namespaces instead.
if (systemKind === "rabbitmq") return tenant ? RABBITMQ_MQ_TENANT : undefined;
if (tenant === "_kafka" || tenant === FLAT_MQ_CONTEXT) return FLAT_MQ_CONTEXT;
return tenant;
}
// The connection's default virtual host acts as the initial RabbitMQ namespace.
const rabbitMqDefaultVhost = computed(() => resolveRabbitMqDefaultVhost(connectionStore.getConfig(props.connectionId)));
function initialMqNamespace(systemKind: MqSystemKind | undefined): string | undefined {
if (systemKind === "rabbitmq") return rabbitMqDefaultVhost.value;
return isFlatMqSystemKind(systemKind) ? FLAT_MQ_CONTEXT : undefined;
}
// State
const activeTab = ref<MqTab>(
resolveInitialMqTab({
@@ -48,8 +77,8 @@ const activeTab = ref<MqTab>(
systemKind: configuredSystemKind.value,
}),
);
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant));
const selectedNamespace = ref<string | undefined>(isFlatMqSystemKind(configuredSystemKind.value) ? FLAT_MQ_CONTEXT : undefined);
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
const selectedNamespace = ref<string | undefined>(initialMqNamespace(configuredSystemKind.value));
const selectedTopic = ref<TopicInfo>();
const selectedSubscriptionName = ref<string>();
const capabilities = ref<MqClusterInfo["capabilities"]>();
@@ -58,9 +87,18 @@ const loading = ref(false);
const error = ref<string>();
const preferDlqTopic = ref(props.initialTab === "dlq");
// RabbitMQ vhost switcher (tab-bar namespace dropdown).
const CREATE_NAMESPACE_VALUE = "__create_namespace__";
const rabbitMqVhosts = ref<string[]>([]);
const showCreateNamespaceDialog = ref(false);
const createNamespaceName = ref("");
const createNamespaceError = ref<string>();
const creatingNamespace = ref(false);
// Computed
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
const isRabbitMqCluster = computed(() => mqSystemKind.value === "rabbitmq");
const isRocketMqCluster = computed(() => mqSystemKind.value === "rocketmq");
const rocketmqClusterLabel = computed(() => {
if (!isRocketMqCluster.value) return undefined;
@@ -72,10 +110,17 @@ const rocketmqClusterLabel = computed(() => {
const clusterName = extra?.clusterName ?? extra?.cluster_name;
return typeof clusterName === "string" && clusterName.trim() ? clusterName.trim() : undefined;
});
const effectiveTenant = computed(() => (isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value) || FLAT_MQ_CONTEXT : selectedTenant.value));
const effectiveNamespace = computed(() => (isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value));
const effectiveTenant = computed(() => {
if (isRabbitMqCluster.value) return RABBITMQ_MQ_TENANT;
return isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value, mqSystemKind.value) || FLAT_MQ_CONTEXT : selectedTenant.value;
});
const effectiveNamespace = computed(() => {
if (isRabbitMqCluster.value) return selectedNamespace.value || rabbitMqDefaultVhost.value;
return isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value;
});
const breadcrumbTenant = computed(() => (isFlatMqCluster.value ? undefined : selectedTenant.value));
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value ? undefined : selectedNamespace.value));
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value && !isRabbitMqCluster.value ? undefined : selectedNamespace.value));
const breadcrumbNamespaceLabel = computed(() => (isAllVhostsNamespace(breadcrumbNamespace.value) ? t("mqAdmin.allNamespaces") : breadcrumbNamespace.value));
const effectiveCapabilities = computed(() => capabilities.value ?? defaultMqCapabilitiesForSystemKind(configuredSystemKind.value));
const canManageTenants = computed(() => effectiveCapabilities.value.supportsTenants);
const canManageNamespaces = computed(() => effectiveCapabilities.value.supportsNamespaces);
@@ -94,7 +139,12 @@ const canManagePolicies = computed(() => {
return canManageRateLimits.value || canManageBacklogQuota.value || canManageRetention.value;
});
const canManagePermissions = computed(() => effectiveCapabilities.value.supportsPermissions);
const canManageUserPermissions = computed(() => effectiveCapabilities.value.supportsUserPermissions ?? false);
const canManageRabbitMqPolicies = computed(() => effectiveCapabilities.value.supportsPolicies ?? false);
const canClusterMonitor = computed(() => effectiveCapabilities.value.supportsClusterMonitoring ?? false);
const canSendMessage = computed(() => effectiveCapabilities.value.supportsSendMessage ?? false);
const canManageExchanges = computed(() => effectiveCapabilities.value.supportsExchanges ?? false);
const canManageClientConnections = computed(() => effectiveCapabilities.value.supportsClientConnections ?? false);
const canMessageQuery = computed(() => effectiveCapabilities.value.supportsMessageQuery ?? false);
const canMessageTrace = computed(() => effectiveCapabilities.value.supportsMessageTrace ?? false);
const canUseRawApi = computed(() => effectiveCapabilities.value.supportsRawAdminApi);
@@ -146,9 +196,58 @@ async function loadClusterInfo() {
}
}
async function loadRabbitMqVhosts() {
try {
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
rabbitMqVhosts.value = namespaces.map((ns) => ns.namespace);
} catch (e: unknown) {
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
}
}
function switchNamespace(namespace: string) {
selectedNamespace.value = namespace;
selectedTopic.value = undefined;
selectedSubscriptionName.value = undefined;
}
function handleNamespaceSelect(value: AcceptableValue) {
if (typeof value !== "string") return;
if (value === CREATE_NAMESPACE_VALUE) {
if (props.readOnly) return;
createNamespaceName.value = "";
createNamespaceError.value = undefined;
showCreateNamespaceDialog.value = true;
return;
}
switchNamespace(value);
}
async function handleCreateNamespace() {
if (props.readOnly) return;
const name = createNamespaceName.value.trim();
if (!name) {
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired");
return;
}
creatingNamespace.value = true;
createNamespaceError.value = undefined;
try {
const ns: NamespaceRef = { tenant: RABBITMQ_MQ_TENANT, namespace: name };
await mqCreateNamespace(props.connectionId, ns, {});
showCreateNamespaceDialog.value = false;
await loadRabbitMqVhosts();
switchNamespace(name);
} catch (e: unknown) {
createNamespaceError.value = formatError(e);
} finally {
creatingNamespace.value = false;
}
}
function selectTenant(tenant: string) {
selectedTenant.value = tenant;
selectedNamespace.value = isFlatMqCluster.value ? FLAT_MQ_CONTEXT : undefined;
selectedNamespace.value = initialMqNamespace(mqSystemKind.value);
selectedTopic.value = undefined;
selectedSubscriptionName.value = undefined;
if (canManageNamespaces.value) {
@@ -245,10 +344,17 @@ function reconcileActiveTab() {
}
watch(availableTabs, reconcileActiveTab);
watch(
isRabbitMqCluster,
(isRabbitMq) => {
if (isRabbitMq) void loadRabbitMqVhosts();
},
{ immediate: true },
);
watch(
() => props.initialTenant,
(tenant) => {
const normalized = normalizeFlatMqTenant(tenant);
const normalized = normalizeFlatMqTenant(tenant, mqSystemKind.value);
if (normalized && normalized !== selectedTenant.value) {
selectTenant(normalized);
}
@@ -287,7 +393,7 @@ onMounted(async () => {
<span v-if="breadcrumbTenant" class="breadcrumb-separator">›</span>
<button v-if="breadcrumbTenant" class="breadcrumb-button" @click="goToTenantLevel" :title="t('mqAdmin.viewTenant')">{{ breadcrumbTenant }}</button>
<span v-if="breadcrumbNamespace" class="breadcrumb-separator">›</span>
<button v-if="breadcrumbNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ breadcrumbNamespace }}</button>
<button v-if="breadcrumbNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ breadcrumbNamespaceLabel }}</button>
<span v-if="selectedTopic" class="breadcrumb-separator">›</span>
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
</div>
@@ -299,15 +405,38 @@ onMounted(async () => {
<!-- Tab Bar -->
<div class="mq-tabs">
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
{{ t(tabLabelKey(tab)) }}
</button>
<div class="mq-tabs-list">
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
{{ t(tabLabelKey(tab)) }}
</button>
</div>
<div v-if="isRabbitMqCluster" class="mq-namespace-switcher">
<Select :model-value="selectedNamespace" @update:model-value="handleNamespaceSelect">
<SelectTrigger class="h-7 w-[180px] rounded-[6px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="RABBITMQ_ALL_VHOSTS">{{ t("mqAdmin.allNamespaces") }}</SelectItem>
<SelectItem v-for="vhost in rabbitMqVhosts" :key="vhost" :value="vhost">{{ vhost }}</SelectItem>
<SelectItem :value="CREATE_NAMESPACE_VALUE" :disabled="readOnly">+ {{ t("mqAdmin.newNamespace") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- Main Content Area -->
<div class="mq-content">
<TenantsPanel v-if="activeTab === 'tenants'" :connection-id="connectionId" :supports-tenants="canManageTenants" :read-only="readOnly" :cluster-options="clusterOptions" @tenant-selected="handleTenantSelected" />
<NamespacesPanel v-else-if="activeTab === 'namespaces'" :connection-id="connectionId" :tenant="selectedTenant" :supports-namespaces="canManageNamespaces" :read-only="readOnly" @namespace-selected="handleNamespaceSelected" @namespace-roles-selected="handleNamespaceRolesSelected" />
<NamespacesPanel
v-else-if="activeTab === 'namespaces'"
:connection-id="connectionId"
:tenant="effectiveTenant"
:supports-namespaces="canManageNamespaces"
:supports-permissions="canManagePermissions"
:read-only="readOnly"
@namespace-selected="handleNamespaceSelected"
@namespace-roles-selected="handleNamespaceRolesSelected"
/>
<TopicsPanel
v-else-if="activeTab === 'topics'"
:connection-id="connectionId"
@@ -317,6 +446,7 @@ onMounted(async () => {
:supports-partitioned-topics="canManagePartitionedTopics"
:is-flat-mq-cluster="isFlatMqCluster"
:mq-system-kind="mqSystemKind"
:supports-exchanges="canManageExchanges"
@topic-selected="handleTopicSelected"
@navigate-tab="handleNavigateTab"
/>
@@ -337,7 +467,9 @@ onMounted(async () => {
:supports-expire-messages="canExpireMessages"
@subscription-selected="handleSubscriptionSelected"
/>
<RabbitMqMonitoringPanel v-else-if="activeTab === 'monitoring' && isRabbitMqCluster && canClusterMonitor" :connection-id="connectionId" />
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" />
<RabbitMqClientsPanel v-else-if="activeTab === 'clients' && isRabbitMqCluster && canManageClientConnections" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
<ProducerConsumerPanel
v-else-if="activeTab === 'clients'"
:connection-id="connectionId"
@@ -386,6 +518,7 @@ onMounted(async () => {
:supports-peek-messages="canPeekMessages"
/>
<BrokerPanel v-else-if="activeTab === 'broker'" :connection-id="connectionId" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
<RabbitMqPoliciesPanel v-else-if="activeTab === 'policies' && isRabbitMqCluster && canManageRabbitMqPolicies" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
<PoliciesPanel
v-else-if="activeTab === 'policies' && canManagePolicies"
:connection-id="connectionId"
@@ -398,9 +531,31 @@ onMounted(async () => {
:supports-backlog-quota="canManageBacklogQuota"
:supports-retention="canManageRetention"
/>
<RabbitMqPermissionsPanel v-else-if="activeTab === 'permissions' && isRabbitMqCluster && canManageUserPermissions" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
<RawApiPanel v-else-if="activeTab === 'raw' && canUseRawApi" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :topic="selectedTopic" :read-only="readOnly" />
</div>
<!-- Create Namespace (vhost) Dialog -->
<div v-if="showCreateNamespaceDialog" class="dialog-overlay" @click="showCreateNamespaceDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqAdmin.newNamespace") }}</h3>
<button @click="showCreateNamespaceDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqNamespaces.namespaceName") }}</label>
<input v-model="createNamespaceName" type="text" :placeholder="t('mqNamespaces.namespaceNamePlaceholder')" :disabled="readOnly" />
</div>
<div v-if="createNamespaceError" class="form-error">{{ createNamespaceError }}</div>
</div>
<div class="dialog-footer">
<button @click="showCreateNamespaceDialog = false" class="btn-secondary">{{ t("mqNamespaces.cancel") }}</button>
<button @click="handleCreateNamespace" :disabled="creatingNamespace || readOnly" class="btn-primary">{{ t("mqNamespaces.create") }}</button>
</div>
</div>
</div>
</div>
</template>
@@ -482,12 +637,19 @@ onMounted(async () => {
.mq-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-background-secondary);
}
.mq-tabs-list {
display: flex;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.mq-tabs button {
.mq-tabs-list button {
padding: 10px 20px;
border: none;
background: transparent;
@@ -499,17 +661,24 @@ onMounted(async () => {
transition: all 0.2s;
}
.mq-tabs button:hover {
.mq-tabs-list button:hover {
color: var(--color-text);
background: var(--color-hover);
}
.mq-tabs button.active {
.mq-tabs-list button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
background: var(--color-background);
}
.mq-namespace-switcher {
display: flex;
align-items: center;
padding: 4px 12px;
flex: 0 0 auto;
}
.mq-content {
flex: 1;
overflow: hidden;
@@ -530,4 +699,115 @@ onMounted(async () => {
.mq-content :deep(tbody tr:last-child td) {
border-bottom: 1px solid var(--color-border);
}
/* Create namespace dialog */
.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: 8px;
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;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 13px;
}
.form-group input[type="text"] {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.form-error {
color: var(--color-error);
font-size: 13px;
}
.btn-primary,
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -4,15 +4,19 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
tenant?: string;
supportsNamespaces: boolean;
supportsPermissions?: boolean;
readOnly?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
supportsPermissions: true,
});
const emit = defineEmits<{
namespaceSelected: [namespace: string];
namespaceRolesSelected: [namespace: string];
@@ -25,6 +29,9 @@ const loading = ref(false);
const error = ref<string>();
const showCreateDialog = ref(false);
const selectedNamespace = ref<string>();
const deleteTarget = ref<NamespaceInfo>();
const showDeleteDialog = ref(false);
const deleting = ref(false);
const formData = ref({
namespace: "",
@@ -86,11 +93,16 @@ async function handleCreate() {
}
}
async function handleDelete(ns: NamespaceInfo) {
function handleDelete(ns: NamespaceInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqNamespaces.confirmDelete", { name: ns.namespace }))) return;
if (!props.tenant) return;
loading.value = true;
deleteTarget.value = ns;
showDeleteDialog.value = true;
}
async function confirmDelete() {
const ns = deleteTarget.value;
if (!ns || !props.tenant) return;
deleting.value = true;
error.value = undefined;
try {
const nsRef: NamespaceRef = {
@@ -101,11 +113,12 @@ async function handleDelete(ns: NamespaceInfo) {
if (selectedNamespace.value === ns.namespace) {
selectedNamespace.value = undefined;
}
showDeleteDialog.value = false;
await loadNamespaces();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
deleting.value = false;
}
}
@@ -150,7 +163,7 @@ watch(
<tr>
<th>{{ t("mqNamespaces.name") }}</th>
<th>{{ t("mqNamespaces.tenant") }}</th>
<th>{{ t("mqNamespaces.adminRoles") }}</th>
<th v-if="supportsPermissions">{{ t("mqNamespaces.adminRoles") }}</th>
<th>{{ t("mqNamespaces.actions") }}</th>
</tr>
</thead>
@@ -158,14 +171,14 @@ watch(
<tr v-for="ns in namespaces" :key="ns.namespace" :class="{ selected: selectedNamespace === ns.namespace }" @click="selectNamespace(ns)">
<td class="namespace-name">{{ ns.namespace }}</td>
<td>{{ ns.tenant }}</td>
<td>
<td v-if="supportsPermissions">
<span v-if="ns.adminRoles.length" class="tag-list">
<span v-for="role in ns.adminRoles" :key="role" class="tag">{{ role }}</span>
</span>
<span v-else class="text-muted">{{ t("mqNamespaces.none") }}</span>
</td>
<td class="actions">
<button @click.stop="editNamespaceRoles(ns)" class="btn-sm">{{ t("mqNamespaces.editRoles") }}</button>
<button v-if="supportsPermissions" @click.stop="editNamespaceRoles(ns)" class="btn-sm">{{ t("mqNamespaces.editRoles") }}</button>
<button @click.stop="handleDelete(ns)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqNamespaces.delete") }}</button>
</td>
</tr>
@@ -197,6 +210,9 @@ watch(
</div>
</div>
</div>
<!-- Delete Confirm Dialog -->
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqNamespaces.delete')" :message="t('mqNamespaces.confirmDelete', { name: deleteTarget?.namespace ?? '' })" :confirm-label="t('mqNamespaces.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
</div>
</template>
@@ -112,7 +112,7 @@ const topicRef = computed<TopicRef | null>(() => {
if (!topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
namespace: topic.namespace || props.namespace,
topic: topic.shortName,
persistent: topic.persistent,
partitioned: topic.partitioned,
+2 -2
View File
@@ -19,9 +19,9 @@
</template>
<script setup lang="ts">
import MqAdminConsole from '@/components/mq/MqAdminConsole.vue'
import MqAdminConsole from "@/components/mq/MqAdminConsole.vue";
const connectionId = 'your-mq-connection-id'
const connectionId = "your-mq-connection-id";
</script>
```
@@ -5,6 +5,7 @@ import type { MqSystemKind, PeekedMessage, TopicInfo, TopicRef, SendMessageReque
import { mqSendMessage, mqListTopics, mqPeekMessages } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props {
@@ -26,6 +27,8 @@ const { t } = useI18n();
const topicName = ref("");
const messageKey = ref("");
const messageTag = ref("");
const exchangeName = ref("");
const routingKey = ref("");
const messageValue = ref("");
const headersText = ref("");
const loading = ref(false);
@@ -47,6 +50,7 @@ let successTimer: ReturnType<typeof setTimeout> | undefined;
const readOnlyMessage = computed(() => t("mqMessages.readOnlyCannotSend"));
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const isRabbitMqCluster = computed(() => props.mqSystemKind === "rabbitmq");
const topicOptions = computed(() => {
return availableTopics.value.map((t) => ({
@@ -62,7 +66,9 @@ const selectedTopicRef = computed<TopicRef | null>(() => {
const selected = availableTopics.value.find((item) => item.shortName === topic);
return {
tenant: props.tenant,
namespace: props.namespace,
// Cross-vhost listings tag each row with its own vhost; row-level
// operations (e.g. peek) must target that vhost, not the "*" selection.
namespace: selected?.namespace || props.topic?.namespace || props.namespace,
topic,
persistent: selected?.persistent ?? true,
partitioned: selected?.partitioned,
@@ -172,6 +178,23 @@ async function sendMessage() {
payloadText: messageValue.value,
headers,
};
// RabbitMQ: publish through a specific exchange only when one is given;
// an empty exchange keeps the default-exchange behavior. The vhost is
// resolved for every publish: a topic picked from the datalist keeps the
// vhost of that row (cross-vhost listings), then the selected topic prop,
// then the current selection; in all-vhosts mode without a row topic the
// publish falls back to the connection default vhost (no explicit
// namespace).
const exchange = exchangeName.value.trim();
if (isRabbitMqCluster.value) {
if (exchange) {
req.exchange = exchange;
req.routingKey = routingKey.value.trim() || undefined;
}
const datalistTopic = availableTopics.value.find((item) => item.shortName === topic);
const sendNamespace = resolveRabbitMqSendNamespace(datalistTopic, props.namespace, props.topic);
if (sendNamespace) req.namespace = sendNamespace;
}
success.value = await mqSendMessage(props.connectionId, req);
if (canBrowseMessages.value) {
peekPartition.value = String(success.value.partition);
@@ -249,6 +272,8 @@ function formatJson() {
function clearForm() {
topicName.value = "";
messageKey.value = "";
exchangeName.value = "";
routingKey.value = "";
messageValue.value = "";
headersText.value = "";
error.value = undefined;
@@ -327,6 +352,18 @@ watch(
<input v-model="messageTag" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
</div>
<template v-if="isRabbitMqCluster">
<div class="form-group">
<label>{{ t("mqMessages.exchange") }}</label>
<input v-model="exchangeName" type="text" :placeholder="t('mqMessages.exchangePlaceholder')" :disabled="readOnly" />
<div class="form-hint">{{ t("mqMessages.exchangeHint") }}</div>
</div>
<div v-if="exchangeName.trim()" class="form-group">
<label>{{ t("mqMessages.routingKey") }}</label>
<input v-model="routingKey" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
</div>
</template>
<!-- 消息内容 -->
<div class="form-group">
<div class="label-row">
@@ -6,6 +6,7 @@ import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, P
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
interface Props {
@@ -43,6 +44,12 @@ const selectedSub = ref<SubscriptionInfo>();
const peekedMessages = ref<PeekedMessage[]>([]);
const peekLoading = ref(false);
const peekCount = ref(5);
const deleteTarget = ref<SubscriptionInfo>();
const showDeleteDialog = ref(false);
const deleting = ref(false);
const clearBacklogTarget = ref<SubscriptionInfo>();
const showClearBacklogDialog = ref(false);
const clearingBacklog = ref(false);
const formData = ref({
subName: "",
@@ -122,7 +129,7 @@ function getListTopicRef(): TopicRef | null {
if (!props.topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
namespace: props.topic.namespace || props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
@@ -133,7 +140,7 @@ function getPulsarTopicRef(): TopicRef | null {
if (!props.tenant || !props.namespace || !props.topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
namespace: props.topic.namespace || props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
@@ -246,20 +253,27 @@ async function handleCreate() {
}
}
async function handleDelete(sub: SubscriptionInfo) {
function handleDelete(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqSubscriptions.confirmDelete", { name: sub.name }))) return;
deleteTarget.value = sub;
showDeleteDialog.value = true;
}
async function confirmDelete() {
const sub = deleteTarget.value;
if (!sub) return;
const topicRef = getListTopicRef();
if (!topicRef) return;
loading.value = true;
deleting.value = true;
error.value = undefined;
try {
await mqDeleteSubscription(props.connectionId, topicRef, sub.name, false);
showDeleteDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
deleting.value = false;
}
}
@@ -304,20 +318,27 @@ async function handleSkipMessages() {
}
}
async function handleClearBacklog(sub: SubscriptionInfo) {
function handleClearBacklog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqSubscriptions.confirmClearBacklog", { name: sub.name }))) return;
clearBacklogTarget.value = sub;
showClearBacklogDialog.value = true;
}
async function confirmClearBacklog() {
const sub = clearBacklogTarget.value;
if (!sub) return;
const topicRef = getPulsarTopicRef();
if (!topicRef) return;
loading.value = true;
clearingBacklog.value = true;
error.value = undefined;
try {
await mqClearBacklog(props.connectionId, topicRef, sub.name);
showClearBacklogDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
clearingBacklog.value = false;
}
}
@@ -628,6 +649,19 @@ watch(
</div>
</div>
</div>
<!-- Delete Confirm Dialog -->
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqSubscriptions.delete')" :message="t('mqSubscriptions.confirmDelete', { name: deleteTarget?.name ?? '' })" :confirm-label="t('mqSubscriptions.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
<!-- Clear Backlog Confirm Dialog -->
<DangerConfirmDialog
v-model:open="showClearBacklogDialog"
:title="t('mqSubscriptions.clearBacklog')"
:message="t('mqSubscriptions.confirmClearBacklog', { name: clearBacklogTarget?.name ?? '' })"
:confirm-label="t('mqSubscriptions.clearBacklog')"
:loading="clearingBacklog"
:close-on-confirm="false"
@confirm="confirmClearBacklog"
/>
</div>
</template>
+284 -212
View File
@@ -6,10 +6,13 @@ import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetCl
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 { 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 DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
@@ -19,6 +22,7 @@ interface Props {
supportsPartitionedTopics?: boolean;
isFlatMqCluster?: boolean;
mqSystemKind?: MqSystemKind;
supportsExchanges?: boolean;
}
const props = defineProps<Props>();
@@ -38,6 +42,9 @@ 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);
@@ -73,6 +80,12 @@ const messageTypeFilters = ref<Record<RocketMqTopicMessageType, boolean>>({
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 rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
@@ -294,28 +307,40 @@ async function handleCreate() {
}
}
async function handleDelete(topic: TopicInfo) {
function handleDelete(topic: TopicInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqTopics.confirmDelete", { name: topic.shortName }))) return;
if (!props.tenant || !props.namespace) return;
loading.value = true;
deleteTarget.value = topic;
showDeleteDialog.value = true;
}
async function confirmDelete() {
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: props.namespace,
namespace,
topic: topic.shortName,
persistent: topic.persistent,
};
await mqDeleteTopic(props.connectionId, topicRef, false);
if (selectedTopic.value?.name === topic.name) {
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 {
loading.value = false;
deleting.value = false;
}
}
@@ -395,233 +420,249 @@ watch(newPartitions, () => {
<template>
<div class="topics-panel">
<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" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
</div>
<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>
<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>
<ExchangesPanel v-if="showRabbitMqSubTabs && rabbitMqSubTab === 'exchanges'" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :read-only="readOnly" />
<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">
<table>
<thead>
<tr>
<th>{{ t("mqTopics.name") }}</th>
<th>{{ t("mqTopics.type") }}</th>
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
<th>{{ t("mqTopics.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="topic in filteredTopics" :key="topic.name" :class="{ selected: selectedTopic?.name === topic.name }" @click="selectTopic(topic)">
<td class="topic-name">
<div class="topic-name-cell">
<span>{{ topic.shortName }}</span>
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
</div>
</td>
<td>
<span class="badge" :class="topicTypeBadgeClass(topic)">
{{ topicTypeLabel(topic) }}
</span>
</td>
<td v-if="!isRocketMqCluster">
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-else class="text-muted">-</span>
</td>
<td class="actions" @click.stop>
<template v-if="isRocketMqCluster">
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
<template v-else>
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
</template>
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
</template>
<template v-else>
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
{{ t("mqTopics.adjustPartitions") }}
</button>
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic)" class="btn-sm btn-danger">{{ t("mqTopics.delete") }}</button>
</template>
</td>
</tr>
</tbody>
</table>
</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>
<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="dialog-body">
<div v-if="!isRocketMqCluster && !isFlatMqCluster" class="form-group">
<label>{{ t("mqTopics.tenantNamespace") }}</label>
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
<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">
<table>
<thead>
<tr>
<th>{{ t("mqTopics.name") }}</th>
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
<th>{{ t("mqTopics.type") }}</th>
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
<th>{{ t("mqTopics.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="topic in filteredTopics" :key="showNamespaceColumn ? `${topic.namespace ?? ''}:${topic.name}` : topic.name" :class="{ selected: selectedTopic?.name === topic.name && selectedTopic?.namespace === topic.namespace }" @click="selectTopic(topic)">
<td class="topic-name">
<div class="topic-name-cell">
<span>{{ topic.shortName }}</span>
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
</div>
</td>
<td v-if="showNamespaceColumn">{{ topic.namespace || "-" }}</td>
<td>
<span class="badge" :class="topicTypeBadgeClass(topic)">
{{ topicTypeLabel(topic) }}
</span>
</td>
<td v-if="!isRocketMqCluster">
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-else class="text-muted">-</span>
</td>
<td class="actions" @click.stop>
<template v-if="isRocketMqCluster">
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
<template v-else>
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
</template>
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
</template>
<template v-else>
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
{{ t("mqTopics.adjustPartitions") }}
</button>
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic) || (showNamespaceColumn && !topic.namespace)" :title="showNamespaceColumn && !topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" class="btn-sm btn-danger">
{{ t("mqTopics.delete") }}
</button>
</template>
</td>
</tr>
</tbody>
</table>
</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 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 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 class="form-group">
<label>{{ t("mqTopics.writeQueues") }}*</label>
<input v-model.number="formData.writeQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
<div v-if="isRocketMqCluster" class="form-group">
<label>{{ t("mqTopics.clusterName") }}</label>
<input type="text" :value="rocketMqClusterName" disabled />
</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>
<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>
<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 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 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 />
<!-- 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="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 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 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>
<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"
/>
<!-- 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" />
<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" />
<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>
</div>
</template>
</div>
</template>
@@ -636,6 +677,37 @@ watch(newPartitions, () => {
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;
@@ -0,0 +1,480 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/types/mq";
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
/** RabbitMQ virtual host; undefined lists connections across all vhosts. */
namespace?: string;
readOnly?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const connections = ref<MqClientConnectionInfo[]>([]);
const loading = ref(false);
const error = ref<string>();
const connectionSearch = ref("");
const expandedNames = ref<Set<string>>(new Set());
const channelsByConnection = ref<Record<string, MqChannelInfo[]>>({});
const channelsLoading = ref<Record<string, boolean>>({});
const channelsError = ref<Record<string, string | undefined>>({});
const showCloseDialog = ref(false);
const closeTarget = ref<MqClientConnectionInfo>();
const closing = ref(false);
const filteredConnections = computed(() => {
const query = connectionSearch.value.trim().toLowerCase();
if (!query) return connections.value;
return connections.value.filter((connection) => {
return connection.name.toLowerCase().includes(query) || connection.user.toLowerCase().includes(query) || connection.peerHost.toLowerCase().includes(query);
});
});
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`, and
// row-level operations must target that vhost rather than the "*" selection.
const showNamespaceColumn = computed(() => isAllVhostsNamespace(props.namespace));
// RabbitMQ namespace is the vhost; the tenant is always the synthetic one.
function nsRef(namespace?: string): NamespaceRef {
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
}
function guardWritable() {
if (props.readOnly) {
error.value = t("mqClientConnections.readOnly");
return false;
}
return true;
}
async function loadConnections() {
loading.value = true;
error.value = undefined;
try {
connections.value = await mqListClientConnections(props.connectionId, nsRef(props.namespace));
// Drop cached channels for connections that went away.
const liveNames = new Set(connections.value.map((connection) => connection.name));
expandedNames.value = new Set([...expandedNames.value].filter((name) => liveNames.has(name)));
for (const name of Object.keys(channelsByConnection.value)) {
if (!liveNames.has(name)) {
delete channelsByConnection.value[name];
delete channelsLoading.value[name];
delete channelsError.value[name];
}
}
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
async function loadChannels(connection: MqClientConnectionInfo) {
const connectionName = connection.name;
const namespace = resolveMqRowNamespace(connection, props.namespace);
if (!namespace) {
channelsError.value = { ...channelsError.value, [connectionName]: t("mqAdmin.selectNamespaceToWrite") };
return;
}
channelsLoading.value = { ...channelsLoading.value, [connectionName]: true };
channelsError.value = { ...channelsError.value, [connectionName]: undefined };
try {
const channels = await mqListClientChannels(props.connectionId, nsRef(namespace), connectionName);
channelsByConnection.value = { ...channelsByConnection.value, [connectionName]: channels };
} catch (e: unknown) {
channelsError.value = { ...channelsError.value, [connectionName]: formatError(e) };
} finally {
channelsLoading.value = { ...channelsLoading.value, [connectionName]: false };
}
}
function toggleExpanded(connection: MqClientConnectionInfo) {
const next = new Set(expandedNames.value);
if (next.has(connection.name)) {
next.delete(connection.name);
expandedNames.value = next;
return;
}
next.add(connection.name);
expandedNames.value = next;
if (!channelsByConnection.value[connection.name]) {
void loadChannels(connection);
}
}
function openCloseDialog(connection: MqClientConnectionInfo) {
if (!guardWritable()) return;
closeTarget.value = connection;
showCloseDialog.value = true;
}
async function confirmClose() {
const target = closeTarget.value;
if (!target) return;
const namespace = resolveMqRowNamespace(target, props.namespace);
if (!namespace) {
error.value = t("mqAdmin.selectNamespaceToWrite");
return;
}
closing.value = true;
error.value = undefined;
try {
await mqCloseClientConnection(props.connectionId, nsRef(namespace), target.name);
showCloseDialog.value = false;
await loadConnections();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
closing.value = false;
}
}
function formatRate(value: number | undefined): string {
if (value === undefined) return "-";
if (!value) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(2)} ${units[index]}`;
}
function formatConnectedAt(value: number | undefined): string {
if (value === undefined) return "-";
return new Date(value).toLocaleString();
}
function formatOptionalNumber(value: number | undefined): string {
return value === undefined ? "-" : String(value);
}
watch(
() => props.namespace,
() => {
expandedNames.value = new Set();
channelsByConnection.value = {};
channelsLoading.value = {};
channelsError.value = {};
loadConnections();
},
{ immediate: true },
);
</script>
<template>
<div class="clients-panel">
<div class="panel-toolbar">
<div class="toolbar-left">
<input v-model="connectionSearch" type="search" class="connection-search" :placeholder="t('mqClientConnections.searchPlaceholder')" :disabled="loading && !connections.length" />
<span v-if="connections.length" class="connection-count">{{ filteredConnections.length }} / {{ connections.length }}</span>
</div>
<div class="toolbar-actions">
<button @click="loadConnections" :disabled="loading" class="btn-secondary">
{{ loading ? t("mqClientConnections.refreshing") : t("mqClientConnections.refresh") }}
</button>
</div>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !connections.length" class="panel-loading">{{ t("mqClientConnections.loading") }}</div>
<div v-else-if="!connections.length" class="panel-placeholder">{{ t("mqClientConnections.noConnections") }}</div>
<div v-else-if="!filteredConnections.length" class="panel-placeholder">{{ t("mqClientConnections.noMatches") }}</div>
<div v-else class="connections-table">
<table>
<thead>
<tr>
<th class="expand-col"></th>
<th>{{ t("mqClientConnections.name") }}</th>
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
<th>{{ t("mqClientConnections.user") }}</th>
<th>{{ t("mqClientConnections.peerAddress") }}</th>
<th>{{ t("mqClientConnections.state") }}</th>
<th>{{ t("mqClientConnections.channels") }}</th>
<th>{{ t("mqClientConnections.recvRate") }}</th>
<th>{{ t("mqClientConnections.sendRate") }}</th>
<th>{{ t("mqClientConnections.connectedAt") }}</th>
<th>{{ t("mqClientConnections.actions") }}</th>
</tr>
</thead>
<tbody v-for="connection in filteredConnections" :key="connection.name">
<tr :class="{ expanded: expandedNames.has(connection.name) }" @click="toggleExpanded(connection)">
<td class="expand-col">
<span class="expand-icon">{{ expandedNames.has(connection.name) ? "▾" : "▸" }}</span>
</td>
<td class="connection-name" :title="connection.name">{{ connection.name }}</td>
<td v-if="showNamespaceColumn">{{ connection.namespace || "-" }}</td>
<td>{{ connection.user || "-" }}</td>
<td>{{ connection.peerHost ? `${connection.peerHost}:${connection.peerPort}` : "-" }}</td>
<td>
<span class="badge" :class="connection.state === 'running' ? 'badge-info' : 'badge-warning'">{{ connection.state || "-" }}</span>
</td>
<td>{{ connection.channels }}</td>
<td>{{ formatRate(connection.recvRate) }}</td>
<td>{{ formatRate(connection.sendRate) }}</td>
<td>{{ formatConnectedAt(connection.connectedAt) }}</td>
<td class="actions" @click.stop>
<button class="btn-sm btn-danger" :disabled="readOnly || (showNamespaceColumn && !connection.namespace)" :title="showNamespaceColumn && !connection.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" @click="openCloseDialog(connection)">
{{ t("mqClientConnections.closeConnection") }}
</button>
</td>
</tr>
<tr v-if="expandedNames.has(connection.name)" class="channels-row">
<td :colspan="showNamespaceColumn ? 11 : 10">
<div v-if="channelsError[connection.name]" class="panel-error">{{ channelsError[connection.name] }}</div>
<div v-else-if="channelsLoading[connection.name] && !channelsByConnection[connection.name]?.length" class="panel-loading">{{ t("mqClientConnections.loading") }}</div>
<div v-else-if="!channelsByConnection[connection.name]?.length" class="panel-placeholder">{{ t("mqClientConnections.noChannels") }}</div>
<table v-else class="channels-table">
<thead>
<tr>
<th>{{ t("mqClientConnections.channelName") }}</th>
<th>{{ t("mqClientConnections.state") }}</th>
<th>{{ t("mqClientConnections.prefetch") }}</th>
<th>{{ t("mqClientConnections.unacked") }}</th>
<th>{{ t("mqClientConnections.consumers") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="channel in channelsByConnection[connection.name]" :key="channel.name">
<td class="connection-name" :title="channel.name">{{ channel.name }}</td>
<td>{{ channel.state || "-" }}</td>
<td>{{ formatOptionalNumber(channel.prefetch) }}</td>
<td>{{ formatOptionalNumber(channel.messagesUnacked) }}</td>
<td>{{ formatOptionalNumber(channel.consumerCount) }}</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Close Connection Confirm -->
<DangerConfirmDialog
v-model:open="showCloseDialog"
:title="t('mqClientConnections.closeConnection')"
:message="t('mqClientConnections.confirmClose', { name: closeTarget?.name ?? '' })"
:confirm-label="t('mqClientConnections.closeConnection')"
:loading="closing"
:close-on-confirm="false"
@confirm="confirmClose"
/>
</div>
</template>
<style scoped>
.clients-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 16px;
overflow: auto;
height: 100%;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.connection-search {
width: min(320px, 32vw);
min-width: 180px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.connection-search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.connection-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.connections-table {
overflow: auto;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
}
.connections-table tbody tr {
cursor: pointer;
transition: background 0.2s;
}
.connections-table tbody tr:hover {
background: var(--color-hover);
}
.connections-table tbody tr.expanded {
background: var(--color-primary-alpha);
}
.expand-col {
width: 24px;
padding-right: 0;
}
.expand-icon {
color: var(--color-text-tertiary);
font-size: 12px;
}
.connection-name {
font-weight: 500;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channels-row {
cursor: default;
}
.channels-row:hover {
background: transparent;
}
.channels-row td {
background: var(--color-background-secondary);
padding: 8px 12px 12px 36px;
}
.channels-table {
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
background: var(--color-background);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.badge-info {
background: var(--color-info-alpha);
color: var(--color-info);
}
.badge-warning {
background: var(--color-warning-alpha);
color: var(--color-warning);
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,437 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqNodeInfo, MqOverviewInfo } from "@/types/mq";
import { mqGetOverview, mqListNodes } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
interface Props {
connectionId: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
const overview = ref<MqOverviewInfo>();
const nodes = ref<MqNodeInfo[]>([]);
const loading = ref(false);
const error = ref<string>();
const autoRefresh = ref(true);
const refreshInterval = ref(5); // seconds
let refreshTimer: number | undefined;
function isDocumentHidden(): boolean {
return typeof document !== "undefined" && document.hidden;
}
async function loadStats(options: { skipWhenHidden?: boolean } = {}) {
if (options.skipWhenHidden && isDocumentHidden()) return;
loading.value = true;
error.value = undefined;
try {
const [overviewData, nodeData] = await Promise.all([mqGetOverview(props.connectionId), mqListNodes(props.connectionId)]);
overview.value = overviewData;
nodes.value = nodeData;
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function refreshNow() {
void loadStats();
}
function startAutoRefresh() {
stopAutoRefresh();
if (autoRefresh.value && !isDocumentHidden()) {
refreshTimer = window.setInterval(() => {
void loadStats({ skipWhenHidden: true });
}, refreshInterval.value * 1000);
}
}
function stopAutoRefresh() {
if (refreshTimer !== undefined) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
}
function handleVisibilityChange() {
if (isDocumentHidden()) {
stopAutoRefresh();
return;
}
startAutoRefresh();
void loadStats();
}
function formatNumber(value: number | undefined): string {
return value === undefined ? "-" : value.toLocaleString();
}
function formatRate(value: number | undefined): string {
return value === undefined ? "-" : `${value.toFixed(2)} msg/s`;
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined) return "-";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
function formatUsage(used: number | undefined, total: number | undefined): string {
if (used === undefined) return "-";
return total === undefined ? formatNumber(used) : `${formatNumber(used)} / ${formatNumber(total)}`;
}
function formatMemory(node: MqNodeInfo): string {
if (node.memUsed === undefined) return "-";
if (node.memLimit === undefined || node.memLimit <= 0) return formatBytes(node.memUsed);
const percent = Math.round((node.memUsed / node.memLimit) * 100);
return `${formatBytes(node.memUsed)} / ${formatBytes(node.memLimit)} (${percent}%)`;
}
function formatUptime(uptimeMs: number | undefined): string {
if (uptimeMs === undefined) return "-";
const totalSeconds = Math.floor(uptimeMs / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
watch(autoRefresh, () => {
startAutoRefresh();
});
watch(refreshInterval, () => {
if (autoRefresh.value) {
startAutoRefresh();
}
});
watch(
() => props.connectionId,
() => {
overview.value = undefined;
nodes.value = [];
void loadStats();
startAutoRefresh();
},
{ immediate: true },
);
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibilityChange);
startAutoRefresh();
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopAutoRefresh();
});
</script>
<template>
<div class="rabbitmq-monitoring-panel">
<div class="panel-toolbar">
<h3 class="section-title">{{ t("mqRabbitMqMonitoring.title") }}</h3>
<div class="toolbar-actions">
<label class="checkbox-label">
<input type="checkbox" v-model="autoRefresh" />
<span>{{ t("mqMonitoring.autoRefresh") }}</span>
</label>
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
<option :value="5">{{ t("mqMonitoring.refreshInterval5s") }}</option>
<option :value="10">{{ t("mqMonitoring.refreshInterval10s") }}</option>
<option :value="30">{{ t("mqMonitoring.refreshInterval30s") }}</option>
<option :value="60">{{ t("mqMonitoring.refreshInterval60s") }}</option>
</select>
<button @click="refreshNow" :disabled="loading" class="btn-secondary">
{{ loading ? t("mqMonitoring.refreshing") : t("mqMonitoring.refreshNow") }}
</button>
</div>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !overview" class="panel-loading">{{ t("mqRabbitMqMonitoring.loading") }}</div>
<template v-else>
<!-- Overview cards -->
<div class="panel-section">
<h4 class="section-subtitle">{{ t("mqRabbitMqMonitoring.overviewTitle") }}</h4>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.messagesReady") }}</div>
<div class="stat-value">{{ formatNumber(overview?.messagesReady) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.messagesUnacked") }}</div>
<div class="stat-value">{{ formatNumber(overview?.messagesUnacked) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.publishRate") }}</div>
<div class="stat-value">{{ formatRate(overview?.publishRate) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.deliverRate") }}</div>
<div class="stat-value">{{ formatRate(overview?.deliverRate) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.ackRate") }}</div>
<div class="stat-value">{{ formatRate(overview?.ackRate) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalQueues") }}</div>
<div class="stat-value">{{ formatNumber(overview?.totalQueues) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalExchanges") }}</div>
<div class="stat-value">{{ formatNumber(overview?.totalExchanges) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalConnections") }}</div>
<div class="stat-value">{{ formatNumber(overview?.totalConnections) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalChannels") }}</div>
<div class="stat-value">{{ formatNumber(overview?.totalChannels) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalConsumers") }}</div>
<div class="stat-value">{{ formatNumber(overview?.totalConsumers) }}</div>
</div>
</div>
</div>
<!-- Node table -->
<div class="panel-section">
<h4 class="section-subtitle">{{ t("mqRabbitMqMonitoring.nodesTitle") }}</h4>
<div v-if="!nodes.length" class="panel-placeholder">{{ t("mqRabbitMqMonitoring.noNodes") }}</div>
<div v-else class="data-table">
<table>
<thead>
<tr>
<th>{{ t("mqRabbitMqMonitoring.nodeName") }}</th>
<th>{{ t("mqRabbitMqMonitoring.status") }}</th>
<th>{{ t("mqRabbitMqMonitoring.memory") }}</th>
<th>{{ t("mqRabbitMqMonitoring.diskFree") }}</th>
<th>{{ t("mqRabbitMqMonitoring.fileDescriptors") }}</th>
<th>{{ t("mqRabbitMqMonitoring.sockets") }}</th>
<th>{{ t("mqRabbitMqMonitoring.uptime") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes" :key="node.name">
<td class="node-name" :title="node.name">{{ node.name }}</td>
<td>
<span :class="['status-badge', node.running ? 'running' : 'stopped']">
{{ node.running ? t("mqRabbitMqMonitoring.running") : t("mqRabbitMqMonitoring.stopped") }}
</span>
</td>
<td>{{ formatMemory(node) }}</td>
<td>{{ formatBytes(node.diskFree) }}</td>
<td>{{ formatUsage(node.fdUsed, node.fdTotal) }}</td>
<td>{{ formatUsage(node.socketsUsed, node.socketsTotal) }}</td>
<td>{{ formatUptime(node.uptimeMs) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.rabbitmq-monitoring-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px;
overflow: auto;
height: 100%;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.section-subtitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.panel-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-secondary);
cursor: pointer;
}
.refresh-interval {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.stat-card {
padding: 12px 14px;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: var(--color-text);
}
.data-table {
overflow: auto;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
}
.data-table tbody tr {
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-hover);
}
.node-name {
font-family: var(--font-mono, monospace);
font-size: 12px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.status-badge.running {
background: var(--color-success-alpha);
color: var(--color-success);
}
.status-badge.stopped {
background: var(--color-error-alpha);
color: var(--color-error);
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,780 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
/** Selected RabbitMQ virtual host; "*" lists permissions across all vhosts. */
namespace?: string;
readOnly?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const users = ref<MqUserInfo[]>([]);
const usersLoading = ref(false);
const error = ref<string>();
const userSearch = ref("");
/** Clicking a user row filters the permission matrix to that user. */
const selectedUser = ref<string>();
const permissions = ref<MqVhostPermission[]>([]);
const permissionsLoading = ref(false);
/** Virtual hosts for the grant dialog dropdown. */
const vhosts = ref<string[]>([]);
const dialogError = ref<string>();
const showCreateDialog = ref(false);
const createForm = ref({ name: "", password: "", tagsText: "" });
const creating = ref(false);
const showDeleteDialog = ref(false);
const deleteTarget = ref<MqUserInfo>();
const deleting = ref(false);
const showGrantDialog = ref(false);
const grantForm = ref({ user: "", virtualHost: "", configure: ".*", write: ".*", read: ".*" });
const granting = ref(false);
const showRevokeDialog = ref(false);
const revokeTarget = ref<MqVhostPermission>();
const revoking = ref(false);
const filteredUsers = computed(() => {
const query = userSearch.value.trim().toLowerCase();
if (!query) return users.value;
return users.value.filter((user) => user.name.toLowerCase().includes(query));
});
const filteredPermissions = computed(() => {
if (!selectedUser.value) return permissions.value;
return permissions.value.filter((permission) => permission.user === selectedUser.value);
});
function guardWritable(): boolean {
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return false;
}
return true;
}
async function loadUsers() {
usersLoading.value = true;
error.value = undefined;
try {
users.value = await mqListUsers(props.connectionId);
if (selectedUser.value && !users.value.some((user) => user.name === selectedUser.value)) {
selectedUser.value = undefined;
}
} catch (e: unknown) {
error.value = formatError(e);
} finally {
usersLoading.value = false;
}
}
async function loadPermissions() {
permissionsLoading.value = true;
error.value = undefined;
try {
// "*" is a listing sentinel: translate it into the all-vhosts listing.
if (isAllVhostsNamespace(props.namespace)) {
permissions.value = await mqListUserPermissions(props.connectionId, { allVhosts: true });
} else if (props.namespace) {
permissions.value = await mqListUserPermissions(props.connectionId, { virtualHost: props.namespace });
} else {
permissions.value = await mqListUserPermissions(props.connectionId);
}
} catch (e: unknown) {
error.value = formatError(e);
} finally {
permissionsLoading.value = false;
}
}
async function loadVhosts() {
try {
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
vhosts.value = namespaces.map((ns) => ns.namespace);
} catch (e: unknown) {
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
}
}
function toggleUserFilter(user: MqUserInfo) {
selectedUser.value = selectedUser.value === user.name ? undefined : user.name;
}
function openCreateDialog() {
if (!guardWritable()) return;
createForm.value = { name: "", password: "", tagsText: "" };
dialogError.value = undefined;
showCreateDialog.value = true;
}
function parseTags(text: string): string[] | undefined {
const tags = text
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
return tags.length ? tags : undefined;
}
async function handleCreateUser() {
const name = createForm.value.name.trim();
const password = createForm.value.password;
if (!name) {
dialogError.value = t("mqUserPermissions.nameRequired");
return;
}
if (!password) {
dialogError.value = t("mqUserPermissions.passwordRequired");
return;
}
creating.value = true;
dialogError.value = undefined;
try {
await mqCreateUser(props.connectionId, name, password, parseTags(createForm.value.tagsText));
showCreateDialog.value = false;
await loadUsers();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
creating.value = false;
}
}
function openDeleteDialog(user: MqUserInfo) {
if (!guardWritable()) return;
deleteTarget.value = user;
showDeleteDialog.value = true;
}
async function confirmDelete() {
const target = deleteTarget.value;
if (!target) return;
deleting.value = true;
error.value = undefined;
try {
await mqDeleteUser(props.connectionId, target.name);
showDeleteDialog.value = false;
await Promise.all([loadUsers(), loadPermissions()]);
} catch (e: unknown) {
error.value = formatError(e);
} finally {
deleting.value = false;
}
}
function openGrantDialog() {
if (!guardWritable()) return;
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
grantForm.value = {
user: selectedUser.value ?? users.value[0]?.name ?? "",
virtualHost: currentVhost || vhosts.value[0] || "",
configure: ".*",
write: ".*",
read: ".*",
};
dialogError.value = undefined;
showGrantDialog.value = true;
}
function patternOrDefault(value: string): string | undefined {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
async function handleGrant() {
const user = grantForm.value.user.trim();
const virtualHost = grantForm.value.virtualHost.trim();
if (!user) {
dialogError.value = t("mqUserPermissions.userRequired");
return;
}
if (!virtualHost || isAllVhostsNamespace(virtualHost)) {
dialogError.value = t("mqUserPermissions.vhostRequired");
return;
}
granting.value = true;
dialogError.value = undefined;
try {
await mqGrantUserPermission(props.connectionId, user, virtualHost, {
configure: patternOrDefault(grantForm.value.configure),
write: patternOrDefault(grantForm.value.write),
read: patternOrDefault(grantForm.value.read),
});
showGrantDialog.value = false;
await loadPermissions();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
granting.value = false;
}
}
function openRevokeDialog(permission: MqVhostPermission) {
if (!guardWritable()) return;
revokeTarget.value = permission;
showRevokeDialog.value = true;
}
async function confirmRevoke() {
const target = revokeTarget.value;
if (!target) return;
revoking.value = true;
error.value = undefined;
try {
await mqRevokeUserPermission(props.connectionId, target.user, target.vhost);
showRevokeDialog.value = false;
await loadPermissions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
revoking.value = false;
}
}
watch(
() => props.namespace,
() => {
loadPermissions();
},
);
watch(
() => props.connectionId,
() => {
selectedUser.value = undefined;
loadUsers();
loadPermissions();
loadVhosts();
},
{ immediate: true },
);
</script>
<template>
<div class="user-permissions-panel">
<!-- Users -->
<div class="panel-section">
<div class="panel-toolbar">
<div class="toolbar-left">
<h3 class="section-title">{{ t("mqUserPermissions.usersTitle") }}</h3>
<input v-model="userSearch" type="search" class="user-search" :placeholder="t('mqUserPermissions.searchUsers')" :disabled="usersLoading && !users.length" />
<span v-if="users.length" class="row-count">{{ filteredUsers.length }} / {{ users.length }}</span>
</div>
<div class="toolbar-actions">
<button @click="openCreateDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqUserPermissions.createUser") }}</button>
<button @click="loadUsers" :disabled="usersLoading" class="btn-secondary">
{{ usersLoading ? t("mqUserPermissions.refreshing") : t("mqUserPermissions.refresh") }}
</button>
</div>
</div>
<div v-if="usersLoading && !users.length" class="panel-loading">{{ t("mqUserPermissions.loading") }}</div>
<div v-else-if="!users.length" class="panel-placeholder">{{ t("mqUserPermissions.noUsers") }}</div>
<div v-else-if="!filteredUsers.length" class="panel-placeholder">{{ t("mqUserPermissions.noUserMatches") }}</div>
<div v-else class="data-table">
<table>
<thead>
<tr>
<th>{{ t("mqUserPermissions.name") }}</th>
<th>{{ t("mqUserPermissions.tags") }}</th>
<th>{{ t("mqUserPermissions.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.name" :class="{ selected: selectedUser === user.name }" @click="toggleUserFilter(user)">
<td class="user-name" :title="user.name">{{ user.name }}</td>
<td>
<span v-if="user.tags.length" class="tag-list">
<span v-for="tag in user.tags" :key="tag" class="badge badge-info">{{ tag }}</span>
</span>
<span v-else>-</span>
</td>
<td class="actions" @click.stop>
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openDeleteDialog(user)">{{ t("mqUserPermissions.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Permission matrix -->
<div class="panel-section">
<div class="panel-toolbar">
<div class="toolbar-left">
<h3 class="section-title">{{ t("mqUserPermissions.permissionsTitle") }}</h3>
<span v-if="selectedUser" class="filter-chip">
{{ selectedUser }}
<button class="chip-clear" @click="selectedUser = undefined">×</button>
</span>
</div>
<div class="toolbar-actions">
<button @click="openGrantDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqUserPermissions.grantPermission") }}</button>
<button @click="loadPermissions" :disabled="permissionsLoading" class="btn-secondary">
{{ permissionsLoading ? t("mqUserPermissions.refreshing") : t("mqUserPermissions.refresh") }}
</button>
</div>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="permissionsLoading && !permissions.length" class="panel-loading">{{ t("mqUserPermissions.loading") }}</div>
<div v-else-if="!filteredPermissions.length" class="panel-placeholder">{{ t("mqUserPermissions.noPermissions") }}</div>
<div v-else class="data-table">
<table>
<thead>
<tr>
<th>{{ t("mqUserPermissions.user") }}</th>
<th>{{ t("mqUserPermissions.vhost") }}</th>
<th>{{ t("mqUserPermissions.configure") }}</th>
<th>{{ t("mqUserPermissions.write") }}</th>
<th>{{ t("mqUserPermissions.read") }}</th>
<th>{{ t("mqUserPermissions.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="permission in filteredPermissions" :key="`${permission.user}@${permission.vhost}`">
<td class="user-name" :title="permission.user">{{ permission.user }}</td>
<td>{{ permission.vhost }}</td>
<td class="pattern-cell" :title="permission.configure">{{ permission.configure }}</td>
<td class="pattern-cell" :title="permission.write">{{ permission.write }}</td>
<td class="pattern-cell" :title="permission.read">{{ permission.read }}</td>
<td class="actions">
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openRevokeDialog(permission)">{{ t("mqUserPermissions.revoke") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create User Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqUserPermissions.createUser") }}</h3>
<button @click="showCreateDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqUserPermissions.name") }}</label>
<input v-model="createForm.name" type="text" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.password") }}</label>
<input v-model="createForm.password" type="password" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.tags") }}</label>
<input v-model="createForm.tagsText" type="text" :placeholder="t('mqUserPermissions.tagsPlaceholder')" :disabled="readOnly" />
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqUserPermissions.cancel") }}</button>
<button @click="handleCreateUser" :disabled="creating || readOnly" class="btn-primary">{{ t("mqUserPermissions.create") }}</button>
</div>
</div>
</div>
<!-- Grant Permission Dialog -->
<div v-if="showGrantDialog" class="dialog-overlay" @click="showGrantDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqUserPermissions.grantPermission") }}</h3>
<button @click="showGrantDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqUserPermissions.user") }}</label>
<select v-model="grantForm.user" :disabled="readOnly">
<option value="" disabled>{{ t("mqUserPermissions.selectUser") }}</option>
<option v-for="user in users" :key="user.name" :value="user.name">{{ user.name }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.vhost") }}</label>
<select v-model="grantForm.virtualHost" :disabled="readOnly">
<option value="" disabled>{{ t("mqUserPermissions.selectVhost") }}</option>
<option v-for="vhost in vhosts" :key="vhost" :value="vhost">{{ vhost }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.configure") }}</label>
<input v-model="grantForm.configure" type="text" placeholder=".*" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.write") }}</label>
<input v-model="grantForm.write" type="text" placeholder=".*" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqUserPermissions.read") }}</label>
<input v-model="grantForm.read" type="text" placeholder=".*" :disabled="readOnly" />
</div>
<div class="form-hint">{{ t("mqUserPermissions.patternHint") }}</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showGrantDialog = false" class="btn-secondary">{{ t("mqUserPermissions.cancel") }}</button>
<button @click="handleGrant" :disabled="granting || readOnly" class="btn-primary">{{ t("mqUserPermissions.grant") }}</button>
</div>
</div>
</div>
<!-- Delete User Confirm -->
<DangerConfirmDialog
v-model:open="showDeleteDialog"
:title="t('mqUserPermissions.delete')"
:message="t('mqUserPermissions.confirmDeleteUser', { name: deleteTarget?.name ?? '' })"
:confirm-label="t('mqUserPermissions.delete')"
:loading="deleting"
:close-on-confirm="false"
@confirm="confirmDelete"
/>
<!-- Revoke Permission Confirm -->
<DangerConfirmDialog
v-model:open="showRevokeDialog"
:title="t('mqUserPermissions.revoke')"
:message="t('mqUserPermissions.confirmRevoke', { user: revokeTarget?.user ?? '', vhost: revokeTarget?.vhost ?? '' })"
:confirm-label="t('mqUserPermissions.revoke')"
:loading="revoking"
:close-on-confirm="false"
@confirm="confirmRevoke"
/>
</div>
</template>
<style scoped>
.user-permissions-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px;
overflow: auto;
height: 100%;
}
.panel-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-search {
width: min(280px, 28vw);
min-width: 160px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.user-search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.row-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
background: var(--color-primary-alpha);
color: var(--color-primary);
font-size: 12px;
font-weight: 500;
}
.chip-clear {
border: none;
background: none;
color: inherit;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.data-table {
overflow: auto;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
}
.data-table tbody tr {
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-hover);
}
.data-table tbody tr.selected {
background: var(--color-primary-alpha);
}
.user-name {
font-weight: 500;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pattern-cell {
font-family: var(--font-mono, monospace);
font-size: 12px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-list {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.badge-info {
background: var(--color-info-alpha);
color: var(--color-info);
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dialogs */
.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: 8px;
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;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.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="password"],
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.form-hint {
color: var(--color-text-tertiary);
font-size: 12px;
margin-top: -8px;
}
.form-error {
color: var(--color-error);
font-size: 13px;
}
</style>
@@ -0,0 +1,626 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
connectionId: string;
/** Selected RabbitMQ virtual host; "*" lists policies across all vhosts. */
namespace?: string;
readOnly?: boolean;
}
interface DefinitionEntry {
key: string;
value: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
/** Well-known policy definition keys offered in the editor dropdown. */
const COMMON_DEFINITION_KEYS = ["message-ttl", "expires", "max-length", "max-length-bytes", "dead-letter-exchange", "dead-letter-routing-key", "max-age"];
/** Definition keys whose values are numeric. */
const NUMERIC_DEFINITION_KEYS = new Set(["message-ttl", "expires", "max-length", "max-length-bytes"]);
const APPLY_TO_OPTIONS = ["queues", "exchanges", "all"];
const policies = ref<MqPolicyInfo[]>([]);
const loading = ref(false);
const error = ref<string>();
/** Virtual hosts for the create/edit dialog dropdown. */
const vhosts = ref<string[]>([]);
const dialogError = ref<string>();
const showEditDialog = ref(false);
/** When set the dialog edits this policy; name and vhost stay fixed. */
const editingPolicy = ref<MqPolicyInfo>();
const editForm = ref({ name: "", virtualHost: "", pattern: "", applyTo: "queues", priority: 0 });
const definitionEntries = ref<DefinitionEntry[]>([]);
const saving = ref(false);
const showDeleteDialog = ref(false);
const deleteTarget = ref<MqPolicyInfo>();
const deleting = ref(false);
function guardWritable(): boolean {
if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return false;
}
return true;
}
async function loadPolicies() {
loading.value = true;
error.value = undefined;
try {
// "*" is a listing sentinel: translate it into the all-vhosts listing.
if (isAllVhostsNamespace(props.namespace)) {
policies.value = await mqListPolicies(props.connectionId, { allVhosts: true });
} else if (props.namespace) {
policies.value = await mqListPolicies(props.connectionId, { virtualHost: props.namespace });
} else {
policies.value = await mqListPolicies(props.connectionId);
}
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
async function loadVhosts() {
try {
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
vhosts.value = namespaces.map((ns) => ns.namespace);
} catch (e: unknown) {
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
}
}
function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
return Object.entries(policy.definition).map(([key, value]) => ({ key, value: String(value) }));
}
function openCreateDialog() {
if (!guardWritable()) return;
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
editingPolicy.value = undefined;
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
definitionEntries.value = [];
dialogError.value = undefined;
showEditDialog.value = true;
}
function openEditDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return;
editingPolicy.value = policy;
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
definitionEntries.value = definitionEntriesFrom(policy);
dialogError.value = undefined;
showEditDialog.value = true;
}
function addDefinitionEntry() {
definitionEntries.value = [...definitionEntries.value, { key: "", value: "" }];
}
function removeDefinitionEntry(index: number) {
definitionEntries.value = definitionEntries.value.filter((_, i) => i !== index);
}
function buildDefinition(): Record<string, unknown> {
const definition: Record<string, unknown> = {};
for (const entry of definitionEntries.value) {
const key = entry.key.trim();
if (!key) continue;
const raw = entry.value.trim();
if (NUMERIC_DEFINITION_KEYS.has(key) && raw !== "" && Number.isFinite(Number(raw))) {
definition[key] = Number(raw);
} else {
definition[key] = raw;
}
}
return definition;
}
async function handleSave() {
const name = editForm.value.name.trim();
const virtualHost = editForm.value.virtualHost.trim();
const pattern = editForm.value.pattern.trim();
if (!name) {
dialogError.value = t("mqRabbitMqPolicies.nameRequired");
return;
}
// "*" is a listing sentinel and must never reach a write operation.
if (!virtualHost || isAllVhostsNamespace(virtualHost)) {
dialogError.value = t("mqRabbitMqPolicies.vhostRequired");
return;
}
if (!pattern) {
dialogError.value = t("mqRabbitMqPolicies.patternRequired");
return;
}
const request: MqPolicyUpsertRequest = {
name,
pattern,
applyTo: editForm.value.applyTo || undefined,
priority: Number.isFinite(editForm.value.priority) ? editForm.value.priority : undefined,
definition: buildDefinition(),
};
saving.value = true;
dialogError.value = undefined;
try {
await mqSetPolicy(props.connectionId, virtualHost, request);
showEditDialog.value = false;
await loadPolicies();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
saving.value = false;
}
}
function openDeleteDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return;
deleteTarget.value = policy;
showDeleteDialog.value = true;
}
async function confirmDelete() {
const target = deleteTarget.value;
if (!target) return;
deleting.value = true;
error.value = undefined;
try {
await mqDeletePolicy(props.connectionId, target.vhost, target.name);
showDeleteDialog.value = false;
await loadPolicies();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
deleting.value = false;
}
}
function formatDefinitionValue(value: unknown): string {
return typeof value === "string" ? value : JSON.stringify(value);
}
watch(
() => props.namespace,
() => {
loadPolicies();
},
);
watch(
() => props.connectionId,
() => {
loadPolicies();
loadVhosts();
},
{ immediate: true },
);
</script>
<template>
<div class="policies-panel">
<div class="panel-toolbar">
<div class="toolbar-left">
<h3 class="section-title">{{ t("mqRabbitMqPolicies.title") }}</h3>
<span v-if="policies.length" class="row-count">{{ policies.length }}</span>
</div>
<div class="toolbar-actions">
<button @click="openCreateDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqRabbitMqPolicies.createPolicy") }}</button>
<button @click="loadPolicies" :disabled="loading" class="btn-secondary">
{{ loading ? t("mqRabbitMqPolicies.refreshing") : t("mqRabbitMqPolicies.refresh") }}
</button>
</div>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !policies.length" class="panel-loading">{{ t("mqRabbitMqPolicies.loading") }}</div>
<div v-else-if="!policies.length" class="panel-placeholder">{{ t("mqRabbitMqPolicies.noPolicies") }}</div>
<div v-else class="data-table">
<table>
<thead>
<tr>
<th>{{ t("mqRabbitMqPolicies.name") }}</th>
<th>{{ t("mqRabbitMqPolicies.vhost") }}</th>
<th>{{ t("mqRabbitMqPolicies.pattern") }}</th>
<th>{{ t("mqRabbitMqPolicies.applyTo") }}</th>
<th>{{ t("mqRabbitMqPolicies.priority") }}</th>
<th>{{ t("mqRabbitMqPolicies.definition") }}</th>
<th>{{ t("mqRabbitMqPolicies.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="policy in policies" :key="`${policy.vhost}/${policy.name}`">
<td class="policy-name" :title="policy.name">{{ policy.name }}</td>
<td>{{ policy.vhost }}</td>
<td class="pattern-cell" :title="policy.pattern">{{ policy.pattern }}</td>
<td>{{ policy.applyTo }}</td>
<td>{{ policy.priority }}</td>
<td class="definition-cell">
<div v-for="(value, key) in policy.definition" :key="key" class="definition-item">
<span class="definition-key">{{ key }}</span>
<span class="definition-value">{{ formatDefinitionValue(value) }}</span>
</div>
</td>
<td class="actions">
<button class="btn-sm" :disabled="readOnly" @click="openEditDialog(policy)">{{ t("mqRabbitMqPolicies.edit") }}</button>
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openDeleteDialog(policy)">{{ t("mqRabbitMqPolicies.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create / Edit Policy Dialog -->
<div v-if="showEditDialog" class="dialog-overlay" @click="showEditDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ editingPolicy ? t("mqRabbitMqPolicies.editPolicy") : t("mqRabbitMqPolicies.createPolicy") }}</h3>
<button @click="showEditDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.name") }}</label>
<input v-model="editForm.name" type="text" :disabled="readOnly || !!editingPolicy" />
</div>
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.vhost") }}</label>
<select v-model="editForm.virtualHost" :disabled="readOnly || !!editingPolicy">
<option value="" disabled>{{ t("mqRabbitMqPolicies.selectVhost") }}</option>
<option v-for="vhost in vhosts" :key="vhost" :value="vhost">{{ vhost }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.pattern") }}</label>
<input v-model="editForm.pattern" type="text" placeholder="^dbx-" :disabled="readOnly" />
</div>
<div class="form-hint">{{ t("mqRabbitMqPolicies.patternHint") }}</div>
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.applyTo") }}</label>
<select v-model="editForm.applyTo" :disabled="readOnly">
<option v-for="option in APPLY_TO_OPTIONS" :key="option" :value="option">{{ option }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.priority") }}</label>
<input v-model.number="editForm.priority" type="number" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqRabbitMqPolicies.definition") }}</label>
<div v-for="(entry, index) in definitionEntries" :key="index" class="definition-row">
<input v-model="entry.key" type="text" list="rabbitmq-policy-definition-keys" :placeholder="t('mqRabbitMqPolicies.definitionKey')" :disabled="readOnly" />
<input v-model="entry.value" type="text" :placeholder="t('mqRabbitMqPolicies.definitionValue')" :disabled="readOnly" />
<button class="btn-sm btn-danger" :disabled="readOnly" @click="removeDefinitionEntry(index)">×</button>
</div>
<datalist id="rabbitmq-policy-definition-keys">
<option v-for="key in COMMON_DEFINITION_KEYS" :key="key" :value="key" />
</datalist>
<button class="btn-sm" :disabled="readOnly" @click="addDefinitionEntry">{{ t("mqRabbitMqPolicies.addDefinition") }}</button>
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showEditDialog = false" class="btn-secondary">{{ t("mqRabbitMqPolicies.cancel") }}</button>
<button @click="handleSave" :disabled="saving || readOnly" class="btn-primary">{{ t("mqRabbitMqPolicies.save") }}</button>
</div>
</div>
</div>
<!-- Delete Policy Confirm -->
<DangerConfirmDialog
v-model:open="showDeleteDialog"
:title="t('mqRabbitMqPolicies.delete')"
:message="t('mqRabbitMqPolicies.confirmDelete', { name: deleteTarget?.name ?? '', vhost: deleteTarget?.vhost ?? '' })"
:confirm-label="t('mqRabbitMqPolicies.delete')"
:loading="deleting"
:close-on-confirm="false"
@confirm="confirmDelete"
/>
</div>
</template>
<style scoped>
.policies-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 16px;
overflow: auto;
height: 100%;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.row-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.data-table {
overflow: auto;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
}
.data-table tbody tr {
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-hover);
}
.policy-name {
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pattern-cell {
font-family: var(--font-mono, monospace);
font-size: 12px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.definition-cell {
max-width: 320px;
}
.definition-item {
display: flex;
gap: 8px;
font-family: var(--font-mono, monospace);
font-size: 12px;
line-height: 1.6;
}
.definition-key {
color: var(--color-text-secondary);
}
.definition-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dialog */
.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: 8px;
width: 90%;
max-width: 560px;
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;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 70vh;
overflow: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.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"],
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.definition-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.definition-row input {
flex: 1;
}
.definition-row .btn-sm {
flex: 0 0 auto;
}
.form-hint {
color: var(--color-text-tertiary);
font-size: 12px;
margin-top: -8px;
margin-bottom: 16px;
}
.form-error {
color: var(--color-error);
font-size: 13px;
}
</style>