feat(rabbitmq): add message queue management support

This commit is contained in:
Yann丶 2026-07-22 23:52:09 +08:00 committed by GitHub
parent 503d6748d4
commit 39e554369f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 14915 additions and 707 deletions

View File

@ -43,6 +43,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| iotdb | Apache IoTDB | IoTDB JDBC |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | RabbitMQ AMQP Java client |
## Multi-JRE Support

View File

@ -43,6 +43,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| iotdb | Apache IoTDB | IoTDB JDBC |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | RabbitMQ AMQP Java client |
## 多 JRE 支持

View File

@ -4,7 +4,7 @@ plugins {
def java8Projects = ['common', 'test-support'] as Set
def infrastructureProjects = ['common', 'test-support'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq', 'rabbitmq'] as Set
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) }

View File

@ -0,0 +1,12 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
implementation 'com.rabbitmq:amqp-client:5.21.0'
runtimeOnly 'org.slf4j:slf4j-simple:1.7.36'
}
tasks.named('shadowJar') {
mergeServiceFiles()
manifest {
attributes('Agent-Label': 'RabbitMQ', 'Main-Class': 'com.dbx.agent.rabbitmq.RabbitMqAgent')
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ SOURCE_GLOBS = ("*/src/main/**/*.java", "drivers/*/src/main/**/*.java")
KOTLIN_FILE_SUFFIXES = (".kt", ".kts")
KOTLIN_SCAN_EXCLUDED_PARTS = {".git", ".gradle", "build"}
DEFAULT_AGENT_JRE_KEY = "21"
NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka", "rocketmq"}
NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka", "rocketmq", "rabbitmq"}
NATIVE_ONLY_AGENT_MODULES = {
"oracle": "drivers/oracle-go",
"xugu": "drivers/xugu",

View File

@ -6,7 +6,7 @@ def driverModules = [
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
'bigquery', 'kylin', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'hive', 'spark',
'db2', 'informix', 'neo4j', 'cassandra', 'mongodb', 'highgo', 'tdengine', 'yashandb', 'oscar',
'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'sqlserver-legacy'
'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'rabbitmq', 'sqlserver-legacy'
]
include(*(infrastructureModules + driverModules))

View File

@ -40,5 +40,6 @@
"zookeeper": "0.1.11",
"kafka": "0.1.4",
"rocketmq": "0.1.0",
"rabbitmq": "0.1.0",
"sqlserver-legacy": "0.1.4"
}

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<!-- RabbitMQ rabbit head icon (symbol only, no text) -->
<circle cx="24" cy="24" r="18" stroke="#F8FAFC" stroke-width="2" fill="#231F20"/>
<!-- Ears -->
<ellipse cx="19" cy="16" rx="2.8" ry="7.5" fill="#F97316" transform="rotate(-12 19 16)"/>
<ellipse cx="29" cy="16" rx="2.8" ry="7.5" fill="#F97316" transform="rotate(12 29 16)"/>
<!-- Head -->
<circle cx="24" cy="29" r="8" fill="#F97316"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@ -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>

View File

@ -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",

View File

@ -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;

View File

@ -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>

View File

@ -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,

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>

View File

@ -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>

View File

@ -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,

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>
```

View File

@ -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">

View File

@ -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>

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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -356,6 +356,18 @@ export default {
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka bootstrap servers are required",
mqBootstrapServersInvalid: "Kafka bootstrap servers are invalid",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "Addresses",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "RabbitMQ addresses are required",
mqRabbitmqAddressesInvalid: "RabbitMQ addresses are invalid",
mqVirtualHost: "Virtual Host",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "Leave empty to derive from AMQP addresses; reverse-proxy path prefixes like https://proxy/rmq are supported",
mqRabbitmqUsernamePlaceholder: "Defaults to guest",
mqRabbitmqPasswordPlaceholder: "Defaults to guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4483,6 +4495,8 @@ export default {
partitionMustIncrease: "New partition count must be greater than the current partition count.",
confirmDelete: 'Delete topic "{name}"? This action cannot be undone.',
queues: "Queues",
tabQueues: "Queues",
tabExchanges: "Exchanges",
queueCount: "{count} queues",
clusterName: "Cluster",
brokerName: "Broker",
@ -4532,6 +4546,172 @@ export default {
endOffset: "Max offset",
messageCount: "Messages",
},
mqExchanges: {
searchPlaceholder: "Search exchanges",
refresh: "Refresh",
refreshing: "Refreshing...",
createExchange: "Create Exchange",
selectNamespace: "Select a virtual host first",
loading: "Loading...",
noExchanges: "No exchanges in this virtual host",
noMatches: "No matching exchanges",
name: "Name",
type: "Type",
durable: "Durable",
autoDelete: "Auto-delete",
actions: "Actions",
builtin: "Built-in",
viewBindings: "Bindings",
delete: "Delete",
yes: "Yes",
no: "No",
bindingsTitle: "Bindings from {name}",
bindDestination: "Bind",
noBindings: "No bindings from this exchange",
bindingSource: "Source",
bindingDestination: "Destination",
bindingType: "Type",
routingKey: "Routing key",
arguments: "Arguments",
unbind: "Unbind",
destinationTypeQueue: "Queue",
destinationTypeExchange: "Exchange",
virtualHost: "Virtual host",
namePlaceholder: "e.g. dbx-events",
cancel: "Cancel",
create: "Create",
bind: "Bind",
bindDialogTitle: "Bind to {name}",
queueNamePlaceholder: "e.g. dbx-queue",
exchangeNamePlaceholder: "e.g. dbx-events",
routingKeyPlaceholder: "e.g. orders.*",
argumentsPlaceholder: 'Optional JSON object, e.g. {"x-match": "all"}',
nameRequired: "Exchange name is required",
destinationRequired: "Destination is required",
argumentsMustBeObject: "Arguments must be a JSON object",
readOnly: "This connection is read-only and cannot perform write operations.",
confirmDelete: 'Delete exchange "{name}"? This action cannot be undone.',
confirmUnbind: 'Remove the binding from "{source}" to "{destination}"?',
},
mqClientConnections: {
searchPlaceholder: "Search connections",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading...",
noConnections: "No client connections",
noMatches: "No matching connections",
name: "Name",
user: "User",
peerAddress: "Peer Address",
state: "State",
channels: "Channels",
recvRate: "Recv Rate",
sendRate: "Send Rate",
connectedAt: "Connected At",
actions: "Actions",
closeConnection: "Close Connection",
confirmClose: 'Close the connection "{name}"? All of its channels will be closed and this cannot be undone.',
readOnly: "This connection is read-only; write operations are disabled.",
noChannels: "No channels on this connection",
channelName: "Channel",
prefetch: "Prefetch",
unacked: "Unacked",
consumers: "Consumers",
},
mqUserPermissions: {
usersTitle: "Users",
permissionsTitle: "Permissions",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading...",
createUser: "Create User",
grantPermission: "Grant Permission",
searchUsers: "Search users",
noUsers: "No users",
noUserMatches: "No matching users",
name: "Name",
tags: "Tags",
actions: "Actions",
delete: "Delete",
confirmDeleteUser: 'Delete user "{name}"? This action cannot be undone.',
password: "Password",
tagsPlaceholder: "Comma-separated, e.g. administrator, monitoring",
nameRequired: "User name is required",
passwordRequired: "Password is required",
cancel: "Cancel",
create: "Create",
user: "User",
vhost: "Virtual host",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "No permissions",
revoke: "Revoke",
confirmRevoke: 'Revoke the permission of user "{user}" on virtual host "{vhost}"?',
grant: "Grant",
selectUser: "Select a user",
selectVhost: "Select a virtual host",
patternHint: "Regular expression matched against resource names; empty defaults to .* (all).",
userRequired: "Select a user",
vhostRequired: "Select a virtual host",
readOnly: "This connection is read-only; write operations are disabled.",
},
mqRabbitMqPolicies: {
title: "Policies",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading...",
createPolicy: "Create Policy",
editPolicy: "Edit Policy",
noPolicies: "No policies",
name: "Name",
vhost: "Virtual host",
pattern: "Pattern",
applyTo: "Apply to",
priority: "Priority",
definition: "Definition",
actions: "Actions",
edit: "Edit",
delete: "Delete",
confirmDelete: 'Delete policy "{name}" on virtual host "{vhost}"?',
cancel: "Cancel",
save: "Save",
selectVhost: "Select a virtual host",
definitionKey: "Key",
definitionValue: "Value",
addDefinition: "Add definition entry",
patternHint: "Regular expression matched against queue/exchange names.",
nameRequired: "Policy name is required",
vhostRequired: "Select a virtual host",
patternRequired: "Pattern is required",
readOnly: "This connection is read-only; write operations are disabled.",
},
mqRabbitMqMonitoring: {
title: "Cluster Monitoring",
loading: "Loading...",
overviewTitle: "Overview",
messagesReady: "Ready messages",
messagesUnacked: "Unacked messages",
publishRate: "Publish rate",
deliverRate: "Deliver rate",
ackRate: "Ack rate",
totalQueues: "Queues",
totalExchanges: "Exchanges",
totalConnections: "Connections",
totalChannels: "Channels",
totalConsumers: "Consumers",
nodesTitle: "Nodes",
nodeName: "Node",
status: "Status",
running: "Running",
stopped: "Stopped",
memory: "Memory",
diskFree: "Disk free",
fileDescriptors: "File descriptors",
sockets: "Sockets",
uptime: "Uptime",
noNodes: "No nodes",
},
mqRocketmq: {
consumerGroupTitle: "Consumer groups",
searchConsumerGroup: "Search consumer group",
@ -4561,6 +4741,11 @@ export default {
viewNamespace: "View namespace",
viewTopic: "View topic",
readOnly: "Read-only",
allNamespaces: "All namespaces",
newNamespace: "New namespace",
namespace: "Namespace",
selectNamespaceToCreate: "Select a specific namespace to create",
selectNamespaceToWrite: "Select a specific namespace to perform this action",
tabTenants: "Tenants",
tabNamespaces: "Namespaces",
tabTopics: "Topics",
@ -4596,6 +4781,10 @@ export default {
topicSearchHint: "Type to filter the list. Selecting a topic runs the query. Click again to search with the current topic shown in gray.",
messageKey: "Message key",
messageTag: "Message tag",
exchange: "Exchange",
exchangePlaceholder: "Empty = default exchange",
exchangeHint: "When set, the message is published to this exchange instead of directly to the queue.",
routingKey: "Routing key",
optional: "Optional",
messageContent: "Message content",
formatJson: "Format JSON",

View File

@ -487,6 +487,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Los bootstrap servers de Kafka son obligatorios",
mqBootstrapServersInvalid: "Los bootstrap servers de Kafka no son válidos",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "Direcciones",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "Las direcciones de RabbitMQ son obligatorias",
mqRabbitmqAddressesInvalid: "Las direcciones de RabbitMQ no son válidas",
mqVirtualHost: "Host virtual",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "Déjalo vacío para derivarlo de las direcciones AMQP; admite prefijos de ruta de proxy inverso como https://proxy/rmq",
mqRabbitmqUsernamePlaceholder: "Por defecto guest",
mqRabbitmqPasswordPlaceholder: "Por defecto guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4354,6 +4366,8 @@ export default withEnglishFallback({
system: "Sistema",
},
queues: "Colas",
tabQueues: "Colas",
tabExchanges: "Exchanges",
queueCount: "{count} colas",
clusterName: "Clúster",
brokerName: "Broker",
@ -4408,6 +4422,11 @@ export default withEnglishFallback({
viewNamespace: "Ver namespace",
viewTopic: "Ver tema",
readOnly: "Solo lectura",
allNamespaces: "Todos los namespaces",
newNamespace: "Nuevo namespace",
namespace: "Namespace",
selectNamespaceToCreate: "Selecciona un namespace específico para crear",
selectNamespaceToWrite: "Selecciona un namespace específico para realizar esta acción",
tabTenants: "Tenants",
tabNamespaces: "Namespaces",
tabTopics: "Temas",
@ -4470,6 +4489,10 @@ export default withEnglishFallback({
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Seleccione un tema",
messageTag: "Etiqueta de mensaje (Tag)",
exchange: "Exchange",
exchangePlaceholder: "Vacío = exchange por defecto",
exchangeHint: "Si se indica, el mensaje se publica en este exchange en lugar de ir directamente a la cola.",
routingKey: "Clave de enrutamiento",
queryTitle: "Consulta de mensajes",
queryTabTopic: "Por Topic",
queryTabKey: "Por Key",
@ -5151,6 +5174,172 @@ export default withEnglishFallback({
saturday: "Sábado",
},
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
refresh: "Actualizar",
refreshing: "Actualizando...",
createExchange: "Crear exchange",
selectNamespace: "Seleccione primero un host virtual",
loading: "Cargando...",
noExchanges: "No hay exchanges en este host virtual",
noMatches: "No hay exchanges coincidentes",
name: "Nombre",
type: "Tipo",
durable: "Durable",
autoDelete: "Autoeliminar",
actions: "Acciones",
builtin: "Integrado",
viewBindings: "Bindings",
delete: "Eliminar",
yes: "Sí",
no: "No",
bindingsTitle: "Bindings de {name}",
bindDestination: "Vincular",
noBindings: "No hay bindings desde este exchange",
bindingSource: "Origen",
bindingDestination: "Destino",
bindingType: "Tipo",
routingKey: "Clave de enrutamiento",
arguments: "Argumentos",
unbind: "Desvincular",
destinationTypeQueue: "Cola",
destinationTypeExchange: "Exchange",
virtualHost: "Host virtual",
namePlaceholder: "p. ej. dbx-events",
cancel: "Cancelar",
create: "Crear",
bind: "Vincular",
bindDialogTitle: "Vincular a {name}",
queueNamePlaceholder: "p. ej. dbx-queue",
exchangeNamePlaceholder: "p. ej. dbx-events",
routingKeyPlaceholder: "p. ej. orders.*",
argumentsPlaceholder: 'Objeto JSON opcional, p. ej. {"x-match": "all"}',
nameRequired: "El nombre del exchange es obligatorio",
destinationRequired: "El destino es obligatorio",
argumentsMustBeObject: "Los argumentos deben ser un objeto JSON",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
confirmDelete: '¿Eliminar el exchange "{name}"? Esta acción no se puede deshacer.',
confirmUnbind: '¿Eliminar el binding de "{source}" a "{destination}"?',
},
mqClientConnections: {
searchPlaceholder: "Buscar conexiones",
refresh: "Actualizar",
refreshing: "Actualizando...",
loading: "Cargando...",
noConnections: "No hay conexiones de clientes",
noMatches: "No hay conexiones coincidentes",
name: "Nombre",
user: "Usuario",
peerAddress: "Dirección de origen",
state: "Estado",
channels: "Canales",
recvRate: "Tasa de recepción",
sendRate: "Tasa de envío",
connectedAt: "Conectado el",
actions: "Acciones",
closeConnection: "Cerrar conexión",
confirmClose: '¿Cerrar la conexión "{name}"? Todos sus canales se cerrarán y esta acción no se puede deshacer.',
readOnly: "Esta conexión es de solo lectura; no se pueden ejecutar operaciones de escritura.",
noChannels: "Esta conexión no tiene canales",
channelName: "Canal",
prefetch: "Prefetch",
unacked: "Sin confirmar",
consumers: "Consumidores",
},
mqUserPermissions: {
usersTitle: "Usuarios",
permissionsTitle: "Permisos",
refresh: "Actualizar",
refreshing: "Actualizando...",
loading: "Cargando...",
createUser: "Crear usuario",
grantPermission: "Conceder permiso",
searchUsers: "Buscar usuarios",
noUsers: "No hay usuarios",
noUserMatches: "No hay usuarios coincidentes",
name: "Nombre",
tags: "Etiquetas",
actions: "Acciones",
delete: "Eliminar",
confirmDeleteUser: '¿Eliminar el usuario "{name}"? Esta acción no se puede deshacer.',
password: "Contraseña",
tagsPlaceholder: "Separadas por comas, p. ej. administrator, monitoring",
nameRequired: "El nombre de usuario es obligatorio",
passwordRequired: "La contraseña es obligatoria",
cancel: "Cancelar",
create: "Crear",
user: "Usuario",
vhost: "Host virtual",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "No hay permisos",
revoke: "Revocar",
confirmRevoke: '¿Revocar el permiso del usuario "{user}" en el host virtual "{vhost}"?',
grant: "Conceder",
selectUser: "Selecciona un usuario",
selectVhost: "Selecciona un host virtual",
patternHint: "Expresión regular aplicada a los nombres de recursos; vacío equivale a .* (todo).",
userRequired: "Selecciona un usuario",
vhostRequired: "Selecciona un host virtual",
readOnly: "Esta conexión es de solo lectura; las operaciones de escritura están deshabilitadas.",
},
mqRabbitMqPolicies: {
title: "Políticas",
refresh: "Actualizar",
refreshing: "Actualizando...",
loading: "Cargando...",
createPolicy: "Crear política",
editPolicy: "Editar política",
noPolicies: "No hay políticas",
name: "Nombre",
vhost: "Host virtual",
pattern: "Patrón",
applyTo: "Aplicar a",
priority: "Prioridad",
definition: "Definición",
actions: "Acciones",
edit: "Editar",
delete: "Eliminar",
confirmDelete: '¿Eliminar la política "{name}" en el host virtual "{vhost}"?',
cancel: "Cancelar",
save: "Guardar",
selectVhost: "Selecciona un host virtual",
definitionKey: "Clave",
definitionValue: "Valor",
addDefinition: "Añadir entrada de definición",
patternHint: "Expresión regular que coincide con nombres de colas/intercambios.",
nameRequired: "El nombre de la política es obligatorio",
vhostRequired: "Selecciona un host virtual",
patternRequired: "El patrón es obligatorio",
readOnly: "Esta conexión es de solo lectura; las operaciones de escritura están deshabilitadas.",
},
mqRabbitMqMonitoring: {
title: "Monitorización del clúster",
loading: "Cargando...",
overviewTitle: "Resumen",
messagesReady: "Mensajes listos",
messagesUnacked: "Mensajes sin confirmar",
publishRate: "Tasa de publicación",
deliverRate: "Tasa de entrega",
ackRate: "Tasa de confirmación",
totalQueues: "Colas",
totalExchanges: "Intercambios",
totalConnections: "Conexiones",
totalChannels: "Canales",
totalConsumers: "Consumidores",
nodesTitle: "Nodos",
nodeName: "Nodo",
status: "Estado",
running: "En ejecución",
stopped: "Detenido",
memory: "Memoria",
diskFree: "Disco libre",
fileDescriptors: "Descriptores de archivo",
sockets: "Sockets",
uptime: "Tiempo activo",
noNodes: "No hay nodos",
},
mqRocketmq: {
consumerGroupTitle: "Grupo de consumidores",
searchConsumerGroup: "Buscar grupo de consumidores",

View File

@ -485,6 +485,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "I bootstrap server Kafka sono obbligatori",
mqBootstrapServersInvalid: "I bootstrap server Kafka non sono validi",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "Indirizzi",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "Gli indirizzi RabbitMQ sono obbligatori",
mqRabbitmqAddressesInvalid: "Gli indirizzi RabbitMQ non sono validi",
mqVirtualHost: "Host virtuale",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "Lascia vuoto per derivarlo dagli indirizzi AMQP; supporta prefissi di percorso di reverse proxy come https://proxy/rmq",
mqRabbitmqUsernamePlaceholder: "Predefinito guest",
mqRabbitmqPasswordPlaceholder: "Predefinito guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4352,6 +4364,8 @@ export default withEnglishFallback({
system: "Sistema",
},
queues: "Code",
tabQueues: "Code",
tabExchanges: "Exchange",
queueCount: "{count} code",
clusterName: "Cluster",
brokerName: "Broker",
@ -4406,6 +4420,11 @@ export default withEnglishFallback({
viewNamespace: "Visualizza namespace",
viewTopic: "Visualizza topic",
readOnly: "Sola lettura",
allNamespaces: "Tutti i namespace",
newNamespace: "Nuovo namespace",
namespace: "Namespace",
selectNamespaceToCreate: "Seleziona un namespace specifico per creare",
selectNamespaceToWrite: "Seleziona un namespace specifico per eseguire questa azione",
tabTenants: "Tenant",
tabNamespaces: "Namespace",
tabTopics: "Topic",
@ -4468,6 +4487,10 @@ export default withEnglishFallback({
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Seleziona un Topic",
messageTag: "Tag del messaggio",
exchange: "Exchange",
exchangePlaceholder: "Vuoto = exchange predefinito",
exchangeHint: "Se impostato, il messaggio viene pubblicato su questo exchange invece che direttamente sulla coda.",
routingKey: "Chiave di routing",
queryTitle: "Query messaggi",
queryTabTopic: "Per Topic",
queryTabKey: "Per Key",
@ -5149,6 +5172,172 @@ export default withEnglishFallback({
saturday: "Sabato",
},
},
mqExchanges: {
searchPlaceholder: "Cerca exchange",
refresh: "Aggiorna",
refreshing: "Aggiornamento...",
createExchange: "Crea exchange",
selectNamespace: "Seleziona prima un virtual host",
loading: "Caricamento...",
noExchanges: "Nessun exchange in questo virtual host",
noMatches: "Nessun exchange corrispondente",
name: "Nome",
type: "Tipo",
durable: "Durevole",
autoDelete: "Eliminazione automatica",
actions: "Azioni",
builtin: "Integrato",
viewBindings: "Binding",
delete: "Elimina",
yes: "Sì",
no: "No",
bindingsTitle: "Binding da {name}",
bindDestination: "Collega",
noBindings: "Nessun binding da questo exchange",
bindingSource: "Origine",
bindingDestination: "Destinazione",
bindingType: "Tipo",
routingKey: "Chiave di routing",
arguments: "Argomenti",
unbind: "Scollega",
destinationTypeQueue: "Coda",
destinationTypeExchange: "Exchange",
virtualHost: "Virtual host",
namePlaceholder: "es. dbx-events",
cancel: "Annulla",
create: "Crea",
bind: "Collega",
bindDialogTitle: "Collega a {name}",
queueNamePlaceholder: "es. dbx-queue",
exchangeNamePlaceholder: "es. dbx-events",
routingKeyPlaceholder: "es. orders.*",
argumentsPlaceholder: 'Oggetto JSON opzionale, es. {"x-match": "all"}',
nameRequired: "Il nome dell'exchange è obbligatorio",
destinationRequired: "La destinazione è obbligatoria",
argumentsMustBeObject: "Gli argomenti devono essere un oggetto JSON",
readOnly: "Questa connessione è di sola lettura e non può eseguire operazioni di scrittura.",
confirmDelete: "Eliminare l'exchange \"{name}\"? L'operazione non può essere annullata.",
confirmUnbind: 'Rimuovere il binding da "{source}" a "{destination}"?',
},
mqClientConnections: {
searchPlaceholder: "Cerca connessioni",
refresh: "Aggiorna",
refreshing: "Aggiornamento...",
loading: "Caricamento...",
noConnections: "Nessuna connessione client",
noMatches: "Nessuna connessione corrispondente",
name: "Nome",
user: "Utente",
peerAddress: "Indirizzo peer",
state: "Stato",
channels: "Canali",
recvRate: "Velocità di ricezione",
sendRate: "Velocità di invio",
connectedAt: "Connesso il",
actions: "Azioni",
closeConnection: "Chiudi connessione",
confirmClose: 'Chiudere la connessione "{name}"? Tutti i suoi canali verranno chiusi e l\'operazione non può essere annullata.',
readOnly: "Questa connessione è di sola lettura; le operazioni di scrittura sono disabilitate.",
noChannels: "Nessun canale su questa connessione",
channelName: "Canale",
prefetch: "Prefetch",
unacked: "Non confermati",
consumers: "Consumer",
},
mqUserPermissions: {
usersTitle: "Utenti",
permissionsTitle: "Permessi",
refresh: "Aggiorna",
refreshing: "Aggiornamento...",
loading: "Caricamento...",
createUser: "Crea utente",
grantPermission: "Concedi permesso",
searchUsers: "Cerca utenti",
noUsers: "Nessun utente",
noUserMatches: "Nessun utente corrispondente",
name: "Nome",
tags: "Tag",
actions: "Azioni",
delete: "Elimina",
confirmDeleteUser: "Eliminare l'utente \"{name}\"? L'azione è irreversibile.",
password: "Password",
tagsPlaceholder: "Separati da virgola, es. administrator, monitoring",
nameRequired: "Il nome utente è obbligatorio",
passwordRequired: "La password è obbligatoria",
cancel: "Annulla",
create: "Crea",
user: "Utente",
vhost: "Host virtuale",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "Nessun permesso",
revoke: "Revoca",
confirmRevoke: 'Revocare il permesso dell\'utente "{user}" sull\'host virtuale "{vhost}"?',
grant: "Concedi",
selectUser: "Seleziona un utente",
selectVhost: "Seleziona un host virtuale",
patternHint: "Espressione regolare applicata ai nomi delle risorse; vuoto equivale a .* (tutto).",
userRequired: "Seleziona un utente",
vhostRequired: "Seleziona un host virtuale",
readOnly: "Questa connessione è di sola lettura; le operazioni di scrittura sono disabilitate.",
},
mqRabbitMqPolicies: {
title: "Policy",
refresh: "Aggiorna",
refreshing: "Aggiornamento...",
loading: "Caricamento...",
createPolicy: "Crea policy",
editPolicy: "Modifica policy",
noPolicies: "Nessuna policy",
name: "Nome",
vhost: "Host virtuale",
pattern: "Pattern",
applyTo: "Applica a",
priority: "Priorità",
definition: "Definizione",
actions: "Azioni",
edit: "Modifica",
delete: "Elimina",
confirmDelete: 'Eliminare la policy "{name}" sull\'host virtuale "{vhost}"?',
cancel: "Annulla",
save: "Salva",
selectVhost: "Seleziona un host virtuale",
definitionKey: "Chiave",
definitionValue: "Valore",
addDefinition: "Aggiungi voce di definizione",
patternHint: "Espressione regolare che corrisponde ai nomi di code/exchange.",
nameRequired: "Il nome della policy è obbligatorio",
vhostRequired: "Seleziona un host virtuale",
patternRequired: "Il pattern è obbligatorio",
readOnly: "Questa connessione è di sola lettura; le operazioni di scrittura sono disabilitate.",
},
mqRabbitMqMonitoring: {
title: "Monitoraggio cluster",
loading: "Caricamento...",
overviewTitle: "Panoramica",
messagesReady: "Messaggi pronti",
messagesUnacked: "Messaggi non confermati",
publishRate: "Frequenza di pubblicazione",
deliverRate: "Frequenza di consegna",
ackRate: "Frequenza di conferma",
totalQueues: "Code",
totalExchanges: "Exchange",
totalConnections: "Connessioni",
totalChannels: "Canali",
totalConsumers: "Consumer",
nodesTitle: "Nodi",
nodeName: "Nodo",
status: "Stato",
running: "In esecuzione",
stopped: "Arrestato",
memory: "Memoria",
diskFree: "Disco libero",
fileDescriptors: "Descrittori di file",
sockets: "Socket",
uptime: "Uptime",
noNodes: "Nessun nodo",
},
mqRocketmq: {
consumerGroupTitle: "Gruppo di consumatori",
searchConsumerGroup: "Cerca gruppo di consumatori",

View File

@ -485,6 +485,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers は必須です",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers が無効です",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "アドレス",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "RabbitMQ アドレスは必須です",
mqRabbitmqAddressesInvalid: "RabbitMQ アドレスが無効です",
mqVirtualHost: "仮想ホスト",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "空欄の場合は AMQP アドレスから派生します。https://proxy/rmq のようなリバースプロキシのパスプレフィックスに対応しています",
mqRabbitmqUsernamePlaceholder: "デフォルトは guest",
mqRabbitmqPasswordPlaceholder: "デフォルトは guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4352,6 +4364,8 @@ export default withEnglishFallback({
system: "システム",
},
queues: "キュー",
tabQueues: "キュー",
tabExchanges: "エクスチェンジ",
queueCount: "{count} キュー",
clusterName: "クラスタ",
brokerName: "Broker",
@ -4406,6 +4420,11 @@ export default withEnglishFallback({
viewNamespace: "名前空間を表示",
viewTopic: "トピックを表示",
readOnly: "読み取り専用",
allNamespaces: "すべての名前空間",
newNamespace: "新規名前空間",
namespace: "名前空間",
selectNamespaceToCreate: "作成するには特定の名前空間を選択してください",
selectNamespaceToWrite: "この操作を実行するには特定の名前空間を選択してください",
tabTenants: "テナント",
tabNamespaces: "名前空間",
tabTopics: "トピック",
@ -4468,6 +4487,10 @@ export default withEnglishFallback({
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "トピックを選択してください",
messageTag: "メッセージタグ (Tag)",
exchange: "エクスチェンジ",
exchangePlaceholder: "空欄 = デフォルトエクスチェンジ",
exchangeHint: "指定すると、メッセージはキューに直接ではなくこのエクスチェンジにパブリッシュされます。",
routingKey: "ルーティングキー",
queryTitle: "メッセージ検索",
queryTabTopic: "Topic 指定",
queryTabKey: "Key 指定",
@ -5149,6 +5172,172 @@ export default withEnglishFallback({
saturday: "土曜日",
},
},
mqExchanges: {
searchPlaceholder: "エクスチェンジを検索",
refresh: "更新",
refreshing: "更新中...",
createExchange: "エクスチェンジを作成",
selectNamespace: "先に仮想ホストを選択してください",
loading: "読み込み中...",
noExchanges: "この仮想ホストにエクスチェンジがありません",
noMatches: "一致するエクスチェンジがありません",
name: "名前",
type: "タイプ",
durable: "永続",
autoDelete: "自動削除",
actions: "操作",
builtin: "組み込み",
viewBindings: "バインディング",
delete: "削除",
yes: "はい",
no: "いいえ",
bindingsTitle: "{name} のバインディング",
bindDestination: "バインド",
noBindings: "このエクスチェンジにバインディングはありません",
bindingSource: "ソース",
bindingDestination: "宛先",
bindingType: "タイプ",
routingKey: "ルーティングキー",
arguments: "引数",
unbind: "バインド解除",
destinationTypeQueue: "キュー",
destinationTypeExchange: "エクスチェンジ",
virtualHost: "仮想ホスト",
namePlaceholder: "例: dbx-events",
cancel: "キャンセル",
create: "作成",
bind: "バインド",
bindDialogTitle: "{name} にバインド",
queueNamePlaceholder: "例: dbx-queue",
exchangeNamePlaceholder: "例: dbx-events",
routingKeyPlaceholder: "例: orders.*",
argumentsPlaceholder: '任意の JSON オブジェクト。例: {"x-match": "all"}',
nameRequired: "エクスチェンジ名は必須です",
destinationRequired: "宛先は必須です",
argumentsMustBeObject: "引数は JSON オブジェクトである必要があります",
readOnly: "この接続は読み取り専用のため、書き込み操作はできません。",
confirmDelete: "エクスチェンジ「{name}」を削除しますか?この操作は元に戻せません。",
confirmUnbind: "「{source}」から「{destination}」へのバインディングを削除しますか?",
},
mqClientConnections: {
searchPlaceholder: "接続を検索",
refresh: "更新",
refreshing: "更新中...",
loading: "読み込み中...",
noConnections: "クライアント接続がありません",
noMatches: "一致する接続がありません",
name: "名前",
user: "ユーザー",
peerAddress: "ピアアドレス",
state: "状態",
channels: "チャネル数",
recvRate: "受信レート",
sendRate: "送信レート",
connectedAt: "接続日時",
actions: "操作",
closeConnection: "接続を閉じる",
confirmClose: "接続「{name}」を閉じますか?すべてのチャネルが閉じられ、この操作は取り消せません。",
readOnly: "この接続は読み取り専用のため、書き込み操作は実行できません。",
noChannels: "この接続にチャネルはありません",
channelName: "チャネル",
prefetch: "Prefetch",
unacked: "未確認",
consumers: "コンシューマー数",
},
mqUserPermissions: {
usersTitle: "ユーザー",
permissionsTitle: "権限",
refresh: "更新",
refreshing: "更新中...",
loading: "読み込み中...",
createUser: "ユーザーを作成",
grantPermission: "権限を付与",
searchUsers: "ユーザーを検索",
noUsers: "ユーザーがいません",
noUserMatches: "一致するユーザーがいません",
name: "名前",
tags: "タグ",
actions: "操作",
delete: "削除",
confirmDeleteUser: 'ユーザー "{name}" を削除しますか?この操作は元に戻せません。',
password: "パスワード",
tagsPlaceholder: "カンマ区切り(例: administrator, monitoring",
nameRequired: "ユーザー名は必須です",
passwordRequired: "パスワードは必須です",
cancel: "キャンセル",
create: "作成",
user: "ユーザー",
vhost: "仮想ホスト",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "権限がありません",
revoke: "取り消し",
confirmRevoke: 'ユーザー "{user}" の仮想ホスト "{vhost}" に対する権限を取り消しますか?',
grant: "付与",
selectUser: "ユーザーを選択",
selectVhost: "仮想ホストを選択",
patternHint: "リソース名にマッチする正規表現。空の場合は .*(すべて)が既定です。",
userRequired: "ユーザーを選択してください",
vhostRequired: "仮想ホストを選択してください",
readOnly: "この接続は読み取り専用のため、書き込み操作は無効です。",
},
mqRabbitMqPolicies: {
title: "ポリシー",
refresh: "更新",
refreshing: "更新中...",
loading: "読み込み中...",
createPolicy: "ポリシーを作成",
editPolicy: "ポリシーを編集",
noPolicies: "ポリシーがありません",
name: "名前",
vhost: "仮想ホスト",
pattern: "パターン",
applyTo: "適用先",
priority: "優先度",
definition: "定義",
actions: "操作",
edit: "編集",
delete: "削除",
confirmDelete: '仮想ホスト "{vhost}" のポリシー "{name}" を削除しますか?',
cancel: "キャンセル",
save: "保存",
selectVhost: "仮想ホストを選択",
definitionKey: "キー",
definitionValue: "値",
addDefinition: "定義エントリを追加",
patternHint: "キュー/エクスチェンジ名にマッチする正規表現。",
nameRequired: "ポリシー名は必須です",
vhostRequired: "仮想ホストを選択してください",
patternRequired: "パターンは必須です",
readOnly: "この接続は読み取り専用のため、書き込み操作は無効です。",
},
mqRabbitMqMonitoring: {
title: "クラスター監視",
loading: "読み込み中...",
overviewTitle: "概要",
messagesReady: "配信待ちメッセージ",
messagesUnacked: "未確認メッセージ",
publishRate: "パブリッシュレート",
deliverRate: "配信レート",
ackRate: "確認レート",
totalQueues: "キュー数",
totalExchanges: "エクスチェンジ数",
totalConnections: "接続数",
totalChannels: "チャネル数",
totalConsumers: "コンシューマー数",
nodesTitle: "ノード",
nodeName: "ノード",
status: "状態",
running: "実行中",
stopped: "停止",
memory: "メモリ",
diskFree: "空きディスク",
fileDescriptors: "ファイルディスクリプタ",
sockets: "ソケット",
uptime: "稼働時間",
noNodes: "ノードがありません",
},
mqRocketmq: {
consumerGroupTitle: "コンシューマグループ",
searchConsumerGroup: "コンシューマグループを検索",

View File

@ -486,6 +486,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Bootstrap Servers do Kafka são obrigatórios",
mqBootstrapServersInvalid: "Bootstrap Servers do Kafka são inválidos",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "Endereços",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "Os endereços do RabbitMQ são obrigatórios",
mqRabbitmqAddressesInvalid: "Os endereços do RabbitMQ são inválidos",
mqVirtualHost: "Host virtual",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "Deixe vazio para derivar dos endereços AMQP; suporta prefixos de caminho de proxy reverso como https://proxy/rmq",
mqRabbitmqUsernamePlaceholder: "Padrão guest",
mqRabbitmqPasswordPlaceholder: "Padrão guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4354,6 +4366,8 @@ export default withEnglishFallback({
system: "Sistema",
},
queues: "Filas",
tabQueues: "Filas",
tabExchanges: "Exchanges",
queueCount: "{count} filas",
clusterName: "Cluster",
brokerName: "Broker",
@ -4408,6 +4422,11 @@ export default withEnglishFallback({
viewNamespace: "Ver namespace",
viewTopic: "Ver tópico",
readOnly: "Somente leitura",
allNamespaces: "Todos os namespaces",
newNamespace: "Novo namespace",
namespace: "Namespace",
selectNamespaceToCreate: "Selecione um namespace específico para criar",
selectNamespaceToWrite: "Selecione um namespace específico para realizar esta ação",
tabTenants: "Locatários",
tabNamespaces: "Namespaces",
tabTopics: "Tópicos",
@ -4470,6 +4489,10 @@ export default withEnglishFallback({
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Selecione um tópico",
messageTag: "Tag da mensagem",
exchange: "Exchange",
exchangePlaceholder: "Vazio = exchange padrão",
exchangeHint: "Quando definido, a mensagem é publicada neste exchange em vez de ir diretamente para a fila.",
routingKey: "Chave de roteamento",
queryTitle: "Consulta de mensagens",
queryTabTopic: "Por tópico",
queryTabKey: "Por Key",
@ -5151,6 +5174,172 @@ export default withEnglishFallback({
saturday: "Sábado",
},
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
refresh: "Atualizar",
refreshing: "Atualizando...",
createExchange: "Criar exchange",
selectNamespace: "Selecione um virtual host primeiro",
loading: "Carregando...",
noExchanges: "Nenhum exchange neste virtual host",
noMatches: "Nenhum exchange correspondente",
name: "Nome",
type: "Tipo",
durable: "Durável",
autoDelete: "Exclusão automática",
actions: "Ações",
builtin: "Interno",
viewBindings: "Bindings",
delete: "Excluir",
yes: "Sim",
no: "Não",
bindingsTitle: "Bindings de {name}",
bindDestination: "Vincular",
noBindings: "Nenhum binding a partir deste exchange",
bindingSource: "Origem",
bindingDestination: "Destino",
bindingType: "Tipo",
routingKey: "Chave de roteamento",
arguments: "Argumentos",
unbind: "Desvincular",
destinationTypeQueue: "Fila",
destinationTypeExchange: "Exchange",
virtualHost: "Virtual host",
namePlaceholder: "ex.: dbx-events",
cancel: "Cancelar",
create: "Criar",
bind: "Vincular",
bindDialogTitle: "Vincular a {name}",
queueNamePlaceholder: "ex.: dbx-queue",
exchangeNamePlaceholder: "ex.: dbx-events",
routingKeyPlaceholder: "ex.: orders.*",
argumentsPlaceholder: 'Objeto JSON opcional, ex.: {"x-match": "all"}',
nameRequired: "O nome do exchange é obrigatório",
destinationRequired: "O destino é obrigatório",
argumentsMustBeObject: "Os argumentos devem ser um objeto JSON",
readOnly: "Esta conexão é somente leitura e não pode executar operações de escrita.",
confirmDelete: 'Excluir o exchange "{name}"? Esta ação não pode ser desfeita.',
confirmUnbind: 'Remover o binding de "{source}" para "{destination}"?',
},
mqClientConnections: {
searchPlaceholder: "Pesquisar conexões",
refresh: "Atualizar",
refreshing: "Atualizando...",
loading: "Carregando...",
noConnections: "Nenhuma conexão de cliente",
noMatches: "Nenhuma conexão correspondente",
name: "Nome",
user: "Usuário",
peerAddress: "Endereço de origem",
state: "Estado",
channels: "Canais",
recvRate: "Taxa de recepção",
sendRate: "Taxa de envio",
connectedAt: "Conectado em",
actions: "Ações",
closeConnection: "Fechar conexão",
confirmClose: 'Fechar a conexão "{name}"? Todos os seus canais serão fechados e esta ação não pode ser desfeita.',
readOnly: "Esta conexão é somente leitura; operações de escrita estão desabilitadas.",
noChannels: "Esta conexão não possui canais",
channelName: "Canal",
prefetch: "Prefetch",
unacked: "Não confirmados",
consumers: "Consumidores",
},
mqUserPermissions: {
usersTitle: "Usuários",
permissionsTitle: "Permissões",
refresh: "Atualizar",
refreshing: "Atualizando...",
loading: "Carregando...",
createUser: "Criar usuário",
grantPermission: "Conceder permissão",
searchUsers: "Buscar usuários",
noUsers: "Nenhum usuário",
noUserMatches: "Nenhum usuário correspondente",
name: "Nome",
tags: "Tags",
actions: "Ações",
delete: "Excluir",
confirmDeleteUser: 'Excluir o usuário "{name}"? Esta ação não pode ser desfeita.',
password: "Senha",
tagsPlaceholder: "Separadas por vírgula, ex. administrator, monitoring",
nameRequired: "O nome de usuário é obrigatório",
passwordRequired: "A senha é obrigatória",
cancel: "Cancelar",
create: "Criar",
user: "Usuário",
vhost: "Host virtual",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "Nenhuma permissão",
revoke: "Revogar",
confirmRevoke: 'Revogar a permissão do usuário "{user}" no host virtual "{vhost}"?',
grant: "Conceder",
selectUser: "Selecione um usuário",
selectVhost: "Selecione um host virtual",
patternHint: "Expressão regular aplicada aos nomes de recursos; vazio equivale a .* (tudo).",
userRequired: "Selecione um usuário",
vhostRequired: "Selecione um host virtual",
readOnly: "Esta conexão é somente leitura; operações de escrita estão desabilitadas.",
},
mqRabbitMqPolicies: {
title: "Políticas",
refresh: "Atualizar",
refreshing: "Atualizando...",
loading: "Carregando...",
createPolicy: "Criar política",
editPolicy: "Editar política",
noPolicies: "Nenhuma política",
name: "Nome",
vhost: "Host virtual",
pattern: "Padrão",
applyTo: "Aplicar a",
priority: "Prioridade",
definition: "Definição",
actions: "Ações",
edit: "Editar",
delete: "Excluir",
confirmDelete: 'Excluir a política "{name}" no host virtual "{vhost}"?',
cancel: "Cancelar",
save: "Salvar",
selectVhost: "Selecione um host virtual",
definitionKey: "Chave",
definitionValue: "Valor",
addDefinition: "Adicionar entrada de definição",
patternHint: "Expressão regular correspondente a nomes de filas/exchanges.",
nameRequired: "O nome da política é obrigatório",
vhostRequired: "Selecione um host virtual",
patternRequired: "O padrão é obrigatório",
readOnly: "Esta conexão é somente leitura; operações de escrita estão desabilitadas.",
},
mqRabbitMqMonitoring: {
title: "Monitoramento do cluster",
loading: "Carregando...",
overviewTitle: "Visão geral",
messagesReady: "Mensagens prontas",
messagesUnacked: "Mensagens não confirmadas",
publishRate: "Taxa de publicação",
deliverRate: "Taxa de entrega",
ackRate: "Taxa de confirmação",
totalQueues: "Filas",
totalExchanges: "Exchanges",
totalConnections: "Conexões",
totalChannels: "Canais",
totalConsumers: "Consumidores",
nodesTitle: "Nós",
nodeName: "Nó",
status: "Status",
running: "Em execução",
stopped: "Parado",
memory: "Memória",
diskFree: "Disco livre",
fileDescriptors: "Descritores de arquivo",
sockets: "Sockets",
uptime: "Tempo ativo",
noNodes: "Nenhum nó",
},
mqRocketmq: {
consumerGroupTitle: "Grupo de consumidores",
searchConsumerGroup: "Pesquisar grupo de consumidores",

View File

@ -358,6 +358,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers 不能为空",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers 无效",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "地址",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "RabbitMQ 地址不能为空",
mqRabbitmqAddressesInvalid: "RabbitMQ 地址无效",
mqVirtualHost: "虚拟主机",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "留空则按 AMQP 地址派生;支持反代路径前缀,如 https://proxy/rmq",
mqRabbitmqUsernamePlaceholder: "缺省 guest",
mqRabbitmqPasswordPlaceholder: "缺省 guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4473,6 +4485,8 @@ export default withEnglishFallback({
partitionMustIncrease: "新分区数必须大于当前分区数。",
confirmDelete: "确定要删除主题「{name}」吗?此操作不可逆。",
queues: "队列",
tabQueues: "队列",
tabExchanges: "交换机",
queueCount: "{count} 个队列",
clusterName: "集群",
brokerName: "Broker",
@ -4522,6 +4536,172 @@ export default withEnglishFallback({
endOffset: "最大 Offset",
messageCount: "消息数",
},
mqExchanges: {
searchPlaceholder: "搜索交换机",
refresh: "刷新",
refreshing: "刷新中...",
createExchange: "创建交换机",
selectNamespace: "请先选择虚拟主机",
loading: "加载中...",
noExchanges: "该虚拟主机下没有交换机",
noMatches: "没有匹配的交换机",
name: "名称",
type: "类型",
durable: "持久化",
autoDelete: "自动删除",
actions: "操作",
builtin: "内置",
viewBindings: "绑定",
delete: "删除",
yes: "是",
no: "否",
bindingsTitle: "{name} 的绑定",
bindDestination: "绑定",
noBindings: "该交换机暂无绑定",
bindingSource: "源",
bindingDestination: "目标",
bindingType: "类型",
routingKey: "路由键",
arguments: "参数",
unbind: "解绑",
destinationTypeQueue: "队列",
destinationTypeExchange: "交换机",
virtualHost: "虚拟主机",
namePlaceholder: "例如 dbx-events",
cancel: "取消",
create: "创建",
bind: "绑定",
bindDialogTitle: "绑定到 {name}",
queueNamePlaceholder: "例如 dbx-queue",
exchangeNamePlaceholder: "例如 dbx-events",
routingKeyPlaceholder: "例如 orders.*",
argumentsPlaceholder: '可选 JSON 对象,例如 {"x-match": "all"}',
nameRequired: "交换机名称不能为空",
destinationRequired: "绑定目标不能为空",
argumentsMustBeObject: "参数必须是 JSON 对象",
readOnly: "当前连接为只读,无法执行写操作。",
confirmDelete: "确定删除交换机「{name}」吗?此操作不可撤销。",
confirmUnbind: "确定移除「{source}」到「{destination}」的绑定吗?",
},
mqClientConnections: {
searchPlaceholder: "搜索连接",
refresh: "刷新",
refreshing: "刷新中...",
loading: "加载中...",
noConnections: "没有客户端连接",
noMatches: "没有匹配的连接",
name: "名称",
user: "用户",
peerAddress: "来源地址",
state: "状态",
channels: "通道数",
recvRate: "接收速率",
sendRate: "发送速率",
connectedAt: "连接时间",
actions: "操作",
closeConnection: "关闭连接",
confirmClose: "确定关闭连接「{name}」吗?其所有通道都会被关闭,此操作不可撤销。",
readOnly: "当前连接为只读,无法执行写操作。",
noChannels: "该连接没有通道",
channelName: "通道",
prefetch: "Prefetch",
unacked: "未确认",
consumers: "消费者数",
},
mqUserPermissions: {
usersTitle: "用户",
permissionsTitle: "权限",
refresh: "刷新",
refreshing: "刷新中...",
loading: "加载中...",
createUser: "创建用户",
grantPermission: "授予权限",
searchUsers: "搜索用户",
noUsers: "暂无用户",
noUserMatches: "没有匹配的用户",
name: "名称",
tags: "标签",
actions: "操作",
delete: "删除",
confirmDeleteUser: '删除用户 "{name}"?此操作不可撤销。',
password: "密码",
tagsPlaceholder: "逗号分隔,如 administrator, monitoring",
nameRequired: "用户名不能为空",
passwordRequired: "密码不能为空",
cancel: "取消",
create: "创建",
user: "用户",
vhost: "虚拟主机",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "暂无权限",
revoke: "撤销",
confirmRevoke: '撤销用户 "{user}" 在虚拟主机 "{vhost}" 上的权限?',
grant: "授予",
selectUser: "请选择用户",
selectVhost: "请选择虚拟主机",
patternHint: "正则表达式,匹配资源名称;留空默认为 .*(全部)。",
userRequired: "请选择用户",
vhostRequired: "请选择虚拟主机",
readOnly: "当前连接为只读,写操作已禁用。",
},
mqRabbitMqPolicies: {
title: "策略",
refresh: "刷新",
refreshing: "刷新中...",
loading: "加载中...",
createPolicy: "创建策略",
editPolicy: "编辑策略",
noPolicies: "暂无策略",
name: "名称",
vhost: "虚拟主机",
pattern: "匹配模式",
applyTo: "应用到",
priority: "优先级",
definition: "定义",
actions: "操作",
edit: "编辑",
delete: "删除",
confirmDelete: '删除虚拟主机 "{vhost}" 上的策略 "{name}"',
cancel: "取消",
save: "保存",
selectVhost: "选择虚拟主机",
definitionKey: "键",
definitionValue: "值",
addDefinition: "添加定义项",
patternHint: "用于匹配队列/交换机名称的正则表达式。",
nameRequired: "策略名称不能为空",
vhostRequired: "请选择虚拟主机",
patternRequired: "匹配模式不能为空",
readOnly: "当前连接为只读,写操作已禁用。",
},
mqRabbitMqMonitoring: {
title: "集群监控",
loading: "加载中...",
overviewTitle: "概览",
messagesReady: "待投递消息",
messagesUnacked: "未确认消息",
publishRate: "发布速率",
deliverRate: "投递速率",
ackRate: "确认速率",
totalQueues: "队列数",
totalExchanges: "交换机数",
totalConnections: "连接数",
totalChannels: "通道数",
totalConsumers: "消费者数",
nodesTitle: "节点",
nodeName: "节点",
status: "状态",
running: "运行中",
stopped: "已停止",
memory: "内存",
diskFree: "磁盘剩余",
fileDescriptors: "文件句柄",
sockets: "Socket",
uptime: "运行时长",
noNodes: "暂无节点",
},
mqRocketmq: {
consumerGroupTitle: "消费组",
searchConsumerGroup: "搜索消费组",
@ -4551,6 +4731,11 @@ export default withEnglishFallback({
viewNamespace: "查看命名空间",
viewTopic: "查看主题",
readOnly: "只读",
allNamespaces: "全部命名空间",
newNamespace: "新建命名空间",
namespace: "命名空间",
selectNamespaceToCreate: "请选择具体命名空间后再创建",
selectNamespaceToWrite: "请选择具体命名空间后再执行此操作",
tabTenants: "租户",
tabNamespaces: "命名空间",
tabTopics: "主题",
@ -4587,6 +4772,10 @@ export default withEnglishFallback({
topicSearchHint: "输入关键词筛选下拉列表;选中主题后自动查询。再次点击时已选主题置灰,新输入用于筛选。",
messageKey: "消息键 (Key)",
messageTag: "消息标签 (Tag)",
exchange: "交换机 (Exchange)",
exchangePlaceholder: "留空 = 默认交换机",
exchangeHint: "填写后消息将发布到该交换机,而不是直接发送到队列。",
routingKey: "路由键 (Routing Key)",
optional: "可选",
messageContent: "消息内容",
formatJson: "格式化 JSON",
@ -4914,6 +5103,7 @@ export default withEnglishFallback({
refreshing: "刷新中...",
selectTopicFirst: "请先选择一个主题",
rocketmqClusterHint: "展示 Broker 上全部在线 Producer 与 Consumer Group 终端。选择主题后可查看该 Topic 的客户端详情。",
confirmUnload: "确定要卸载该主题吗?活跃的生产者和消费者将重新连接。",
aggregateTopic: "聚合主题",
kafkaPartitionStatus: "Kafka 分区状态",
partitionCount: "{count} 个分区",

View File

@ -486,6 +486,18 @@ export default withEnglishFallback({
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers 不能為空",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers 無效",
mqSystemRabbitMq: "RabbitMQ",
mqRabbitmqAddresses: "位址",
mqRabbitmqAddressesPlaceholder: "127.0.0.1:5672",
mqRabbitmqAddressesRequired: "RabbitMQ 位址不能為空",
mqRabbitmqAddressesInvalid: "RabbitMQ 位址無效",
mqVirtualHost: "虛擬主機",
mqVirtualHostPlaceholder: "/",
mqRabbitmqAdminUrl: "Management URL",
mqRabbitmqAdminUrlPlaceholder: "http://192.168.1.1:15672",
mqRabbitmqAdminUrlHint: "留空則按 AMQP 位址派生;支援反代路徑前綴,如 https://proxy/rmq",
mqRabbitmqUsernamePlaceholder: "預設 guest",
mqRabbitmqPasswordPlaceholder: "預設 guest",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
@ -4353,6 +4365,8 @@ export default withEnglishFallback({
system: "系統",
},
queues: "隊列",
tabQueues: "隊列",
tabExchanges: "交換機",
queueCount: "{count} 個隊列",
clusterName: "集群",
brokerName: "Broker",
@ -4407,6 +4421,11 @@ export default withEnglishFallback({
viewNamespace: "檢視命名空間",
viewTopic: "檢視主題",
readOnly: "唯讀",
allNamespaces: "全部命名空間",
newNamespace: "新建命名空間",
namespace: "命名空間",
selectNamespaceToCreate: "請選擇具體命名空間後再建立",
selectNamespaceToWrite: "請選擇具體命名空間後再執行此操作",
tabTenants: "租用戶",
tabNamespaces: "命名空間",
tabTopics: "主題",
@ -4469,6 +4488,10 @@ export default withEnglishFallback({
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "請選擇主題",
messageTag: "消息標籤 (Tag)",
exchange: "交換機 (Exchange)",
exchangePlaceholder: "留空 = 預設交換機",
exchangeHint: "填寫後訊息將發佈到該交換機,而不是直接傳送到隊列。",
routingKey: "路由鍵 (Routing Key)",
queryTitle: "消息查詢",
queryTabTopic: "按 Topic",
queryTabKey: "按 Key",
@ -5150,6 +5173,172 @@ export default withEnglishFallback({
saturday: "星期六",
},
},
mqExchanges: {
searchPlaceholder: "搜尋交換機",
refresh: "重新整理",
refreshing: "重新整理中...",
createExchange: "建立交換機",
selectNamespace: "請先選擇虛擬主機",
loading: "載入中...",
noExchanges: "此虛擬主機下沒有交換機",
noMatches: "沒有符合的交換機",
name: "名稱",
type: "類型",
durable: "持久化",
autoDelete: "自動刪除",
actions: "操作",
builtin: "內建",
viewBindings: "綁定",
delete: "刪除",
yes: "是",
no: "否",
bindingsTitle: "{name} 的綁定",
bindDestination: "綁定",
noBindings: "此交換機尚無綁定",
bindingSource: "來源",
bindingDestination: "目標",
bindingType: "類型",
routingKey: "路由鍵",
arguments: "參數",
unbind: "解除綁定",
destinationTypeQueue: "隊列",
destinationTypeExchange: "交換機",
virtualHost: "虛擬主機",
namePlaceholder: "例如 dbx-events",
cancel: "取消",
create: "建立",
bind: "綁定",
bindDialogTitle: "綁定到 {name}",
queueNamePlaceholder: "例如 dbx-queue",
exchangeNamePlaceholder: "例如 dbx-events",
routingKeyPlaceholder: "例如 orders.*",
argumentsPlaceholder: '可選 JSON 物件,例如 {"x-match": "all"}',
nameRequired: "交換機名稱不能為空",
destinationRequired: "綁定目標不能為空",
argumentsMustBeObject: "參數必須是 JSON 物件",
readOnly: "目前連線為唯讀,無法執行寫入操作。",
confirmDelete: "確定刪除交換機「{name}」嗎?此操作無法復原。",
confirmUnbind: "確定移除「{source}」到「{destination}」的綁定嗎?",
},
mqClientConnections: {
searchPlaceholder: "搜尋連線",
refresh: "重新整理",
refreshing: "重新整理中...",
loading: "載入中...",
noConnections: "沒有用戶端連線",
noMatches: "沒有符合的連線",
name: "名稱",
user: "使用者",
peerAddress: "來源位址",
state: "狀態",
channels: "通道數",
recvRate: "接收速率",
sendRate: "傳送速率",
connectedAt: "連線時間",
actions: "操作",
closeConnection: "關閉連線",
confirmClose: "確定關閉連線「{name}」嗎?其所有通道都會被關閉,此操作不可復原。",
readOnly: "目前連線為唯讀,無法執行寫入操作。",
noChannels: "此連線沒有通道",
channelName: "通道",
prefetch: "Prefetch",
unacked: "未確認",
consumers: "消費者數",
},
mqUserPermissions: {
usersTitle: "使用者",
permissionsTitle: "權限",
refresh: "重新整理",
refreshing: "重新整理中...",
loading: "載入中...",
createUser: "建立使用者",
grantPermission: "授予權限",
searchUsers: "搜尋使用者",
noUsers: "尚無使用者",
noUserMatches: "沒有符合的使用者",
name: "名稱",
tags: "標籤",
actions: "操作",
delete: "刪除",
confirmDeleteUser: '刪除使用者 "{name}"?此操作無法復原。',
password: "密碼",
tagsPlaceholder: "以逗號分隔,如 administrator, monitoring",
nameRequired: "使用者名稱不能為空",
passwordRequired: "密碼不能為空",
cancel: "取消",
create: "建立",
user: "使用者",
vhost: "虛擬主機",
configure: "Configure",
write: "Write",
read: "Read",
noPermissions: "尚無權限",
revoke: "撤銷",
confirmRevoke: '撤銷使用者 "{user}" 在虛擬主機 "{vhost}" 上的權限?',
grant: "授予",
selectUser: "請選擇使用者",
selectVhost: "請選擇虛擬主機",
patternHint: "正則表達式,比對資源名稱;留空預設為 .*(全部)。",
userRequired: "請選擇使用者",
vhostRequired: "請選擇虛擬主機",
readOnly: "目前連線為唯讀,寫入操作已停用。",
},
mqRabbitMqPolicies: {
title: "策略",
refresh: "重新整理",
refreshing: "重新整理中...",
loading: "載入中...",
createPolicy: "建立策略",
editPolicy: "編輯策略",
noPolicies: "尚無策略",
name: "名稱",
vhost: "虛擬主機",
pattern: "比對模式",
applyTo: "套用至",
priority: "優先順序",
definition: "定義",
actions: "操作",
edit: "編輯",
delete: "刪除",
confirmDelete: '刪除虛擬主機 "{vhost}" 上的策略 "{name}"',
cancel: "取消",
save: "儲存",
selectVhost: "選擇虛擬主機",
definitionKey: "鍵",
definitionValue: "值",
addDefinition: "新增定義項目",
patternHint: "用於比對佇列/交換器名稱的正則表達式。",
nameRequired: "策略名稱不可為空",
vhostRequired: "請選擇虛擬主機",
patternRequired: "比對模式不可為空",
readOnly: "目前連線為唯讀,寫入操作已停用。",
},
mqRabbitMqMonitoring: {
title: "叢集監控",
loading: "載入中...",
overviewTitle: "概覽",
messagesReady: "待投遞訊息",
messagesUnacked: "未確認訊息",
publishRate: "發佈速率",
deliverRate: "投遞速率",
ackRate: "確認速率",
totalQueues: "佇列數",
totalExchanges: "交換器數",
totalConnections: "連線數",
totalChannels: "通道數",
totalConsumers: "消費者數",
nodesTitle: "節點",
nodeName: "節點",
status: "狀態",
running: "執行中",
stopped: "已停止",
memory: "記憶體",
diskFree: "磁碟剩餘",
fileDescriptors: "檔案控制代碼",
sockets: "Socket",
uptime: "執行時間",
noNodes: "尚無節點",
},
mqRocketmq: {
consumerGroupTitle: "消費組",
searchConsumerGroup: "搜尋消費組",

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
const nsRoot = { tenant: "_rabbitmq", namespace: "/" };
describe("mq client connections/channels tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_client_connections with the namespace ref", async () => {
const { mqListClientConnections } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "192.168.1.10:51000 -> 192.168.1.126:5672", user: "jjsd", peerHost: "192.168.1.10", peerPort: 51000, state: "running", channels: 2 }]);
const result = await mqListClientConnections("conn-1", nsRoot);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_connections", { connectionId: "conn-1", ns: nsRoot });
expect(result).toHaveLength(1);
expect(result[0]?.peerHost).toBe("192.168.1.10");
});
it("invokes mq_list_client_channels with optional connection filter", async () => {
const { mqListClientChannels } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "conn (1)", state: "running", prefetch: 10, messagesUnacked: 0, consumerCount: 1 }]);
const result = await mqListClientChannels("conn-1", nsRoot, "conn");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_channels", { connectionId: "conn-1", ns: nsRoot, connection: "conn" });
expect(result[0]?.consumerCount).toBe(1);
await mqListClientChannels("conn-1", nsRoot);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_channels", { connectionId: "conn-1", ns: nsRoot, connection: undefined });
});
it("invokes mq_close_client_connection with the connection name", async () => {
const { mqCloseClientConnection } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCloseClientConnection("conn-1", nsRoot, "192.168.1.10:51000 -> 192.168.1.126:5672");
expect(mocks.invoke).toHaveBeenCalledWith("mq_close_client_connection", { connectionId: "conn-1", ns: nsRoot, name: "192.168.1.10:51000 -> 192.168.1.126:5672" });
});
});
describe("mq client connections/channels HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the client-connections endpoints", async () => {
const fetchMock = stubFetch();
const { mqListClientConnections, mqCloseClientConnection } = await import("@/lib/backend/mq-http");
await mqListClientConnections("conn-1", nsRoot);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/client-connections/list", body: { connectionId: "conn-1", ns: nsRoot } });
await mqCloseClientConnection("conn-1", nsRoot, "conn-name");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/client-connections/close", body: { connectionId: "conn-1", ns: nsRoot, name: "conn-name" } });
});
it("posts to the channels list endpoint with optional connection filter", async () => {
const fetchMock = stubFetch();
const { mqListClientChannels } = await import("@/lib/backend/mq-http");
await mqListClientChannels("conn-1", nsRoot, "conn");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/channels/list", body: { connectionId: "conn-1", ns: nsRoot, connection: "conn" } });
await mqListClientChannels("conn-1", nsRoot);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/channels/list", body: { connectionId: "conn-1", ns: nsRoot, connection: undefined } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("connection not found"),
}),
);
const { mqCloseClientConnection } = await import("@/lib/backend/mq-http");
await expect(mqCloseClientConnection("conn-1", nsRoot, "gone")).rejects.toThrow("connection not found");
});
});

View File

@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
const NS = { tenant: "_rabbitmq", namespace: "/" };
describe("mq exchanges/bindings tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_exchanges with the namespace ref", async () => {
const { mqListExchanges } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-events", type: "topic", durable: true, autoDelete: false, internal: false }]);
const result = await mqListExchanges("conn-1", NS);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_exchanges", { connectionId: "conn-1", ns: NS });
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("dbx-events");
});
it("invokes mq_create_exchange with flattened fields", async () => {
const { mqCreateExchange } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCreateExchange("conn-1", NS, { name: "dbx-events", type: "topic", durable: true, autoDelete: false });
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_exchange", {
connectionId: "conn-1",
ns: NS,
name: "dbx-events",
exchangeType: "topic",
durable: true,
autoDelete: false,
});
});
it("invokes mq_delete_exchange with the exchange name", async () => {
const { mqDeleteExchange } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeleteExchange("conn-1", NS, "dbx-events");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_exchange", { connectionId: "conn-1", ns: NS, name: "dbx-events" });
});
it("invokes mq_list_bindings with optional filters", async () => {
const { mqListBindings } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([]);
await mqListBindings("conn-1", NS, { exchange: "dbx-events" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_bindings", { connectionId: "conn-1", ns: NS, exchange: "dbx-events", queue: undefined });
await mqListBindings("conn-1", NS);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_bindings", { connectionId: "conn-1", ns: NS, exchange: undefined, queue: undefined });
});
it("invokes mq_bind and mq_unbind with the binding object", async () => {
const { mqBind, mqUnbind } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
const binding = { source: "dbx-events", destination: "dbx-queue", destinationType: "queue", routingKey: "orders.*" };
await mqBind("conn-1", NS, binding);
expect(mocks.invoke).toHaveBeenCalledWith("mq_bind", { connectionId: "conn-1", ns: NS, binding });
await mqUnbind("conn-1", NS, binding);
expect(mocks.invoke).toHaveBeenCalledWith("mq_unbind", { connectionId: "conn-1", ns: NS, binding });
});
});
describe("mq exchanges/bindings HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the exchanges endpoints", async () => {
const fetchMock = stubFetch();
const { mqListExchanges, mqCreateExchange, mqDeleteExchange } = await import("@/lib/backend/mq-http");
await mqListExchanges("conn-1", NS);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/exchanges/list", body: { connectionId: "conn-1", ns: NS } });
await mqCreateExchange("conn-1", NS, { name: "dbx-events", type: "fanout", durable: false, autoDelete: true });
expect(lastCall(fetchMock)).toEqual({
url: "/api/mq/exchanges/create",
body: { connectionId: "conn-1", ns: NS, name: "dbx-events", exchangeType: "fanout", durable: false, autoDelete: true },
});
await mqDeleteExchange("conn-1", NS, "dbx-events");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/exchanges/delete", body: { connectionId: "conn-1", ns: NS, name: "dbx-events" } });
});
it("posts to the bindings endpoints", async () => {
const fetchMock = stubFetch();
const { mqListBindings, mqBind, mqUnbind } = await import("@/lib/backend/mq-http");
const binding = { source: "dbx-events", destination: "dbx-queue", destinationType: "queue", routingKey: "orders.*" };
await mqListBindings("conn-1", NS, { queue: "dbx-queue" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/list", body: { connectionId: "conn-1", ns: NS, exchange: undefined, queue: "dbx-queue" } });
await mqBind("conn-1", NS, binding);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/bind", body: { connectionId: "conn-1", ns: NS, binding } });
await mqUnbind("conn-1", NS, binding);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/unbind", body: { connectionId: "conn-1", ns: NS, binding } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("cannot delete built-in exchange"),
}),
);
const { mqDeleteExchange } = await import("@/lib/backend/mq-http");
await expect(mqDeleteExchange("conn-1", NS, "amq.direct")).rejects.toThrow("cannot delete built-in exchange");
});
});

View File

@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
describe("mq policies tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_policies with flattened filters", async () => {
const { mqListPolicies } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-ttl", vhost: "/", pattern: "^dbx-", applyTo: "queues", priority: 0, definition: { "message-ttl": 60000 } }]);
const result = await mqListPolicies("conn-1", { virtualHost: "/" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: "/", allVhosts: undefined });
expect(result[0]?.name).toBe("dbx-ttl");
expect(result[0]?.definition).toEqual({ "message-ttl": 60000 });
await mqListPolicies("conn-1", { allVhosts: true });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: undefined, allVhosts: true });
await mqListPolicies("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: undefined, allVhosts: undefined });
});
it("invokes mq_set_policy with flattened fields", async () => {
const { mqSetPolicy } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqSetPolicy("conn-1", "/", { name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } });
expect(mocks.invoke).toHaveBeenCalledWith("mq_set_policy", {
connectionId: "conn-1",
virtualHost: "/",
name: "dbx-ttl",
pattern: "^dbx-",
applyTo: "queues",
priority: 10,
definition: { "message-ttl": 60000 },
});
await mqSetPolicy("conn-1", "/", { name: "dbx-dlx", pattern: ".*", definition: { "dead-letter-exchange": "dbx-dlx" } });
expect(mocks.invoke).toHaveBeenCalledWith("mq_set_policy", {
connectionId: "conn-1",
virtualHost: "/",
name: "dbx-dlx",
pattern: ".*",
applyTo: undefined,
priority: undefined,
definition: { "dead-letter-exchange": "dbx-dlx" },
});
});
it("invokes mq_delete_policy with vhost and name", async () => {
const { mqDeletePolicy } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeletePolicy("conn-1", "/", "dbx-ttl");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_policy", { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl" });
});
});
describe("mq overview/nodes tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_get_overview with only the connection id", async () => {
const { mqGetOverview } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue({ messagesReady: 3, messagesUnacked: 1, publishRate: 0.5, totalQueues: 2 });
const result = await mqGetOverview("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_get_overview", { connectionId: "conn-1" });
expect(result.messagesReady).toBe(3);
expect(result.totalQueues).toBe(2);
});
it("invokes mq_list_nodes with only the connection id", async () => {
const { mqListNodes } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "rabbit@dbx", running: true, memUsed: 1024, memLimit: 4096, uptimeMs: 60000 }]);
const result = await mqListNodes("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_nodes", { connectionId: "conn-1" });
expect(result[0]?.name).toBe("rabbit@dbx");
expect(result[0]?.running).toBe(true);
});
});
describe("mq policies/overview/nodes HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch(payload: unknown = []) {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(payload),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the policies endpoints", async () => {
const fetchMock = stubFetch();
const { mqListPolicies, mqSetPolicy, mqDeletePolicy } = await import("@/lib/backend/mq-http");
await mqListPolicies("conn-1", { allVhosts: true });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/policies/list", body: { connectionId: "conn-1", virtualHost: undefined, allVhosts: true } });
await mqSetPolicy("conn-1", "/", { name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } });
expect(lastCall(fetchMock)).toEqual({
url: "/api/mq/policies/set",
body: { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } },
});
await mqDeletePolicy("conn-1", "/", "dbx-ttl");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/policies/delete", body: { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl" } });
});
it("posts to the overview and nodes endpoints", async () => {
const fetchMock = stubFetch({});
const { mqGetOverview, mqListNodes } = await import("@/lib/backend/mq-http");
await mqGetOverview("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/overview", body: { connectionId: "conn-1" } });
await mqListNodes("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/nodes", body: { connectionId: "conn-1" } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: vi.fn().mockResolvedValue('virtual host "*" is a listing sentinel'),
}),
);
const { mqSetPolicy } = await import("@/lib/backend/mq-http");
await expect(mqSetPolicy("conn-1", "*", { name: "dbx-ttl", pattern: ".*", definition: {} })).rejects.toThrow('virtual host "*" is a listing sentinel');
});
});

View File

@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
describe("mq users/permissions tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_users with only the connection id", async () => {
const { mqListUsers } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-app", tags: ["administrator"] }]);
const result = await mqListUsers("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_users", { connectionId: "conn-1" });
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("dbx-app");
expect(result[0]?.tags).toEqual(["administrator"]);
});
it("invokes mq_create_user with name, password and optional tags", async () => {
const { mqCreateUser } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCreateUser("conn-1", "dbx-app", "secret", ["monitoring", "management"]);
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_user", { connectionId: "conn-1", name: "dbx-app", password: "secret", tags: ["monitoring", "management"] });
await mqCreateUser("conn-1", "dbx-app2", "secret2");
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_user", { connectionId: "conn-1", name: "dbx-app2", password: "secret2", tags: undefined });
});
it("invokes mq_delete_user with the user name", async () => {
const { mqDeleteUser } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeleteUser("conn-1", "dbx-app");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_user", { connectionId: "conn-1", name: "dbx-app" });
});
it("invokes mq_list_user_permissions with flattened filters", async () => {
const { mqListUserPermissions } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ user: "dbx-app", vhost: "/", configure: ".*", write: ".*", read: ".*" }]);
const result = await mqListUserPermissions("conn-1", { virtualHost: "/" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: "/", user: undefined, allVhosts: undefined });
expect(result[0]?.vhost).toBe("/");
await mqListUserPermissions("conn-1", { allVhosts: true, user: "dbx-app" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: undefined, user: "dbx-app", allVhosts: true });
await mqListUserPermissions("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: undefined, user: undefined, allVhosts: undefined });
});
it("invokes mq_grant_user_permission with optional patterns", async () => {
const { mqGrantUserPermission } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqGrantUserPermission("conn-1", "dbx-app", "/", { configure: "^dbx-", write: ".*", read: ".*" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_grant_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: "^dbx-", write: ".*", read: ".*" });
await mqGrantUserPermission("conn-1", "dbx-app", "/");
expect(mocks.invoke).toHaveBeenCalledWith("mq_grant_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: undefined, write: undefined, read: undefined });
});
it("invokes mq_revoke_user_permission with user and vhost", async () => {
const { mqRevokeUserPermission } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqRevokeUserPermission("conn-1", "dbx-app", "/");
expect(mocks.invoke).toHaveBeenCalledWith("mq_revoke_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/" });
});
});
describe("mq users/permissions HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the users endpoints", async () => {
const fetchMock = stubFetch();
const { mqListUsers, mqCreateUser, mqDeleteUser } = await import("@/lib/backend/mq-http");
await mqListUsers("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/list", body: { connectionId: "conn-1" } });
await mqCreateUser("conn-1", "dbx-app", "secret", ["monitoring"]);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/create", body: { connectionId: "conn-1", name: "dbx-app", password: "secret", tags: ["monitoring"] } });
await mqDeleteUser("conn-1", "dbx-app");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/delete", body: { connectionId: "conn-1", name: "dbx-app" } });
});
it("posts to the user-permissions endpoints", async () => {
const fetchMock = stubFetch();
const { mqListUserPermissions, mqGrantUserPermission, mqRevokeUserPermission } = await import("@/lib/backend/mq-http");
await mqListUserPermissions("conn-1", { virtualHost: "/" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/list", body: { connectionId: "conn-1", virtualHost: "/", user: undefined, allVhosts: undefined } });
await mqGrantUserPermission("conn-1", "dbx-app", "/", { configure: "^dbx-" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/grant", body: { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: "^dbx-", write: undefined, read: undefined } });
await mqRevokeUserPermission("conn-1", "dbx-app", "/");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/revoke", body: { connectionId: "conn-1", user: "dbx-app", virtualHost: "/" } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("cannot delete the current connection user"),
}),
);
const { mqDeleteUser } = await import("@/lib/backend/mq-http");
await expect(mqDeleteUser("conn-1", "jjsd")).rejects.toThrow("cannot delete the current connection user");
});
});

View File

@ -414,6 +414,15 @@ export const mqDeleteTopic = forward("mqDeleteTopic");
export const mqUpdatePartitions = forward("mqUpdatePartitions");
export const mqGetTopicStats = forward("mqGetTopicStats");
export const mqGetTopicInternalStats = forward("mqGetTopicInternalStats");
export const mqListExchanges = forward("mqListExchanges");
export const mqCreateExchange = forward("mqCreateExchange");
export const mqDeleteExchange = forward("mqDeleteExchange");
export const mqListBindings = forward("mqListBindings");
export const mqBind = forward("mqBind");
export const mqUnbind = forward("mqUnbind");
export const mqListClientConnections = forward("mqListClientConnections");
export const mqListClientChannels = forward("mqListClientChannels");
export const mqCloseClientConnection = forward("mqCloseClientConnection");
export const mqListSubscriptions = forward("mqListSubscriptions");
export const mqCreateSubscription = forward("mqCreateSubscription");
export const mqDeleteSubscription = forward("mqDeleteSubscription");
@ -449,6 +458,17 @@ export const mqQueryMessagesByTopic = forward("mqQueryMessagesByTopic");
export const mqQueryMessageTrace = forward("mqQueryMessageTrace");
export const mqRawRequest = forward("mqRawRequest");
export const mqSendMessage = forward("mqSendMessage");
export const mqListUsers = forward("mqListUsers");
export const mqCreateUser = forward("mqCreateUser");
export const mqDeleteUser = forward("mqDeleteUser");
export const mqListUserPermissions = forward("mqListUserPermissions");
export const mqGrantUserPermission = forward("mqGrantUserPermission");
export const mqRevokeUserPermission = forward("mqRevokeUserPermission");
export const mqListPolicies = forward("mqListPolicies");
export const mqSetPolicy = forward("mqSetPolicy");
export const mqDeletePolicy = forward("mqDeletePolicy");
export const mqGetOverview = forward("mqGetOverview");
export const mqListNodes = forward("mqListNodes");
// MongoDB
export const documentListDatabases = forward("documentListDatabases");

View File

@ -36,6 +36,21 @@ import type {
MqRawResponse,
SendMessageRequest,
SendMessageResponse,
MqExchangeInfo,
MqExchangeCreateRequest,
MqBindingInfo,
MqBindingListFilter,
MqClientConnectionInfo,
MqChannelInfo,
MqUserInfo,
MqVhostPermission,
MqUserPermissionListFilter,
MqUserPermissionPatterns,
MqPolicyInfo,
MqPolicyListFilter,
MqPolicyUpsertRequest,
MqOverviewInfo,
MqNodeInfo,
} from "@/types/mq";
async function post<T>(path: string, body: unknown): Promise<T> {
@ -55,6 +70,89 @@ export async function mqTestConnection(connectionId: string): Promise<MqClusterI
return post("/api/mq/test-connection", { connectionId });
}
export async function mqListExchanges(connectionId: string, ns: NamespaceRef): Promise<MqExchangeInfo[]> {
return post("/api/mq/exchanges/list", { connectionId, ns });
}
export async function mqCreateExchange(connectionId: string, ns: NamespaceRef, exchange: MqExchangeCreateRequest): Promise<void> {
return post("/api/mq/exchanges/create", { connectionId, ns, name: exchange.name, exchangeType: exchange.type, durable: exchange.durable, autoDelete: exchange.autoDelete });
}
export async function mqDeleteExchange(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return post("/api/mq/exchanges/delete", { connectionId, ns, name });
}
export async function mqListBindings(connectionId: string, ns: NamespaceRef, filter?: MqBindingListFilter): Promise<MqBindingInfo[]> {
return post("/api/mq/bindings/list", { connectionId, ns, exchange: filter?.exchange, queue: filter?.queue });
}
export async function mqBind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return post("/api/mq/bindings/bind", { connectionId, ns, binding });
}
export async function mqUnbind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return post("/api/mq/bindings/unbind", { connectionId, ns, binding });
}
export async function mqListClientConnections(connectionId: string, ns: NamespaceRef): Promise<MqClientConnectionInfo[]> {
return post("/api/mq/client-connections/list", { connectionId, ns });
}
export async function mqListClientChannels(connectionId: string, ns: NamespaceRef, connection?: string): Promise<MqChannelInfo[]> {
return post("/api/mq/channels/list", { connectionId, ns, connection });
}
export async function mqCloseClientConnection(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return post("/api/mq/client-connections/close", { connectionId, ns, name });
}
// Users & vhost permissions (RabbitMQ)
export async function mqListUsers(connectionId: string): Promise<MqUserInfo[]> {
return post("/api/mq/users/list", { connectionId });
}
export async function mqCreateUser(connectionId: string, name: string, password: string, tags?: string[]): Promise<void> {
return post("/api/mq/users/create", { connectionId, name, password, tags });
}
export async function mqDeleteUser(connectionId: string, name: string): Promise<void> {
return post("/api/mq/users/delete", { connectionId, name });
}
export async function mqListUserPermissions(connectionId: string, filter?: MqUserPermissionListFilter): Promise<MqVhostPermission[]> {
return post("/api/mq/user-permissions/list", { connectionId, virtualHost: filter?.virtualHost, user: filter?.user, allVhosts: filter?.allVhosts });
}
export async function mqGrantUserPermission(connectionId: string, user: string, virtualHost: string, patterns?: MqUserPermissionPatterns): Promise<void> {
return post("/api/mq/user-permissions/grant", { connectionId, user, virtualHost, configure: patterns?.configure, write: patterns?.write, read: patterns?.read });
}
export async function mqRevokeUserPermission(connectionId: string, user: string, virtualHost: string): Promise<void> {
return post("/api/mq/user-permissions/revoke", { connectionId, user, virtualHost });
}
// Policies (RabbitMQ)
export async function mqListPolicies(connectionId: string, filter?: MqPolicyListFilter): Promise<MqPolicyInfo[]> {
return post("/api/mq/policies/list", { connectionId, virtualHost: filter?.virtualHost, allVhosts: filter?.allVhosts });
}
export async function mqSetPolicy(connectionId: string, virtualHost: string, policy: MqPolicyUpsertRequest): Promise<void> {
return post("/api/mq/policies/set", { connectionId, virtualHost, name: policy.name, pattern: policy.pattern, applyTo: policy.applyTo, priority: policy.priority, definition: policy.definition });
}
export async function mqDeletePolicy(connectionId: string, virtualHost: string, name: string): Promise<void> {
return post("/api/mq/policies/delete", { connectionId, virtualHost, name });
}
// Cluster overview & nodes (RabbitMQ)
export async function mqGetOverview(connectionId: string): Promise<MqOverviewInfo> {
return post("/api/mq/overview", { connectionId });
}
export async function mqListNodes(connectionId: string): Promise<MqNodeInfo[]> {
return post("/api/mq/nodes", { connectionId });
}
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
return post("/api/mq/tenants/list", { connectionId });
}

View File

@ -36,6 +36,21 @@ import type {
MqRawResponse,
SendMessageRequest,
SendMessageResponse,
MqExchangeInfo,
MqExchangeCreateRequest,
MqBindingInfo,
MqBindingListFilter,
MqClientConnectionInfo,
MqChannelInfo,
MqUserInfo,
MqVhostPermission,
MqUserPermissionListFilter,
MqUserPermissionPatterns,
MqPolicyInfo,
MqPolicyListFilter,
MqPolicyUpsertRequest,
MqOverviewInfo,
MqNodeInfo,
} from "@/types/mq";
// Connectivity
@ -43,6 +58,91 @@ export async function mqTestConnection(connectionId: string): Promise<MqClusterI
return invoke("mq_test_connection", { connectionId });
}
// Exchanges / Bindings (RabbitMQ)
export async function mqListExchanges(connectionId: string, ns: NamespaceRef): Promise<MqExchangeInfo[]> {
return invoke("mq_list_exchanges", { connectionId, ns });
}
export async function mqCreateExchange(connectionId: string, ns: NamespaceRef, exchange: MqExchangeCreateRequest): Promise<void> {
return invoke("mq_create_exchange", { connectionId, ns, name: exchange.name, exchangeType: exchange.type, durable: exchange.durable, autoDelete: exchange.autoDelete });
}
export async function mqDeleteExchange(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return invoke("mq_delete_exchange", { connectionId, ns, name });
}
export async function mqListBindings(connectionId: string, ns: NamespaceRef, filter?: MqBindingListFilter): Promise<MqBindingInfo[]> {
return invoke("mq_list_bindings", { connectionId, ns, exchange: filter?.exchange, queue: filter?.queue });
}
export async function mqBind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return invoke("mq_bind", { connectionId, ns, binding });
}
export async function mqUnbind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return invoke("mq_unbind", { connectionId, ns, binding });
}
// Client connections / channels (RabbitMQ)
export async function mqListClientConnections(connectionId: string, ns: NamespaceRef): Promise<MqClientConnectionInfo[]> {
return invoke("mq_list_client_connections", { connectionId, ns });
}
export async function mqListClientChannels(connectionId: string, ns: NamespaceRef, connection?: string): Promise<MqChannelInfo[]> {
return invoke("mq_list_client_channels", { connectionId, ns, connection });
}
export async function mqCloseClientConnection(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return invoke("mq_close_client_connection", { connectionId, ns, name });
}
// Users & vhost permissions (RabbitMQ)
export async function mqListUsers(connectionId: string): Promise<MqUserInfo[]> {
return invoke("mq_list_users", { connectionId });
}
export async function mqCreateUser(connectionId: string, name: string, password: string, tags?: string[]): Promise<void> {
return invoke("mq_create_user", { connectionId, name, password, tags });
}
export async function mqDeleteUser(connectionId: string, name: string): Promise<void> {
return invoke("mq_delete_user", { connectionId, name });
}
export async function mqListUserPermissions(connectionId: string, filter?: MqUserPermissionListFilter): Promise<MqVhostPermission[]> {
return invoke("mq_list_user_permissions", { connectionId, virtualHost: filter?.virtualHost, user: filter?.user, allVhosts: filter?.allVhosts });
}
export async function mqGrantUserPermission(connectionId: string, user: string, virtualHost: string, patterns?: MqUserPermissionPatterns): Promise<void> {
return invoke("mq_grant_user_permission", { connectionId, user, virtualHost, configure: patterns?.configure, write: patterns?.write, read: patterns?.read });
}
export async function mqRevokeUserPermission(connectionId: string, user: string, virtualHost: string): Promise<void> {
return invoke("mq_revoke_user_permission", { connectionId, user, virtualHost });
}
// Policies (RabbitMQ)
export async function mqListPolicies(connectionId: string, filter?: MqPolicyListFilter): Promise<MqPolicyInfo[]> {
return invoke("mq_list_policies", { connectionId, virtualHost: filter?.virtualHost, allVhosts: filter?.allVhosts });
}
export async function mqSetPolicy(connectionId: string, virtualHost: string, policy: MqPolicyUpsertRequest): Promise<void> {
return invoke("mq_set_policy", { connectionId, virtualHost, name: policy.name, pattern: policy.pattern, applyTo: policy.applyTo, priority: policy.priority, definition: policy.definition });
}
export async function mqDeletePolicy(connectionId: string, virtualHost: string, name: string): Promise<void> {
return invoke("mq_delete_policy", { connectionId, virtualHost, name });
}
// Cluster overview & nodes (RabbitMQ)
export async function mqGetOverview(connectionId: string): Promise<MqOverviewInfo> {
return invoke("mq_get_overview", { connectionId });
}
export async function mqListNodes(connectionId: string): Promise<MqNodeInfo[]> {
return invoke("mq_list_nodes", { connectionId });
}
// Tenants
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
return invoke("mq_list_tenants", { connectionId });

View File

@ -17,4 +17,21 @@ describe("mqAuth", () => {
}),
).toBe("basic");
});
it("allows basic auth for RabbitMQ", () => {
expect(isMqAuthKindAllowedForSystem("rabbitmq", "basic")).toBe(true);
expect(isMqAuthKindAllowedForSystem("rabbitmq", "kerberos")).toBe(false);
expect(isMqAuthKindAllowedForSystem("rabbitmq", "token")).toBe(false);
});
it("detects RabbitMQ basic auth from config", () => {
expect(
detectMqUiAuthKind({
systemKind: "rabbitmq",
authKind: "basic",
saslMechanism: "",
jaasConfig: "",
}),
).toBe("basic");
});
});

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
describe("RabbitMQ addresses", () => {
it("keeps comma-separated addresses", () => {
expect(normalizeRabbitmqAddresses("node1:5672, node2:5672")).toBe("node1:5672,node2:5672");
});
it("appends the default AMQP port when missing", () => {
expect(normalizeRabbitmqAddresses("127.0.0.1")).toBe("127.0.0.1:5672");
expect(normalizeRabbitmqAddresses("node1, node2:5673")).toBe("node1:5672,node2:5673");
});
it("normalizes common address separators to commas", () => {
expect(normalizeRabbitmqAddresses("node1:5672node2:5672node3:5672\nnode4:5672 node5:5672")).toBe("node1:5672,node2:5672,node3:5672,node4:5672,node5:5672");
});
it("keeps IPv6 addresses", () => {
expect(normalizeRabbitmqAddresses("[::1]:5672;[2001:db8::1]:5672")).toBe("[::1]:5672,[2001:db8::1]:5672");
});
it("rejects empty addresses", () => {
expect(() => normalizeRabbitmqAddresses(" ")).toThrow("RabbitMQ addresses are required");
});
it("rejects addresses with URL schemes", () => {
expect(() => normalizeRabbitmqAddresses("amqp://node1:5672,node2:5672")).toThrow("RabbitMQ addresses must be host:port values without a URL scheme");
});
it("rejects invalid address values", () => {
expect(() => normalizeRabbitmqAddresses("node1:5672/path,node2:5672")).toThrow("RabbitMQ addresses are invalid");
});
});

View File

@ -15,6 +15,7 @@ export function agentDriverInstallKey(dbType: DatabaseType | undefined, driverPr
if (dbType === "mq") {
if (driverProfile === "kafka") return "kafka";
if (driverProfile === "rocketmq") return "rocketmq";
if (driverProfile === "rabbitmq") return "rabbitmq";
return undefined;
}
return driverProfile && driverProfile !== dbType ? driverProfile : dbType;

View File

@ -5,10 +5,12 @@ export type MqUiAuthKind = MqAuthKind | "kerberos";
const KAFKA_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic", "kerberos"]);
const ROCKETMQ_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic"]);
const RABBITMQ_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic"]);
export function isMqAuthKindAllowedForSystem(systemKind: MqSystemKind, authKind: MqUiAuthKind): boolean {
if (systemKind === "kafka") return KAFKA_AUTH_KINDS.has(authKind);
if (systemKind === "rocketmq") return ROCKETMQ_AUTH_KINDS.has(authKind);
if (systemKind === "rabbitmq") return RABBITMQ_AUTH_KINDS.has(authKind);
return authKind !== "kerberos";
}
@ -22,6 +24,9 @@ export function detectMqUiAuthKind({ systemKind, authKind, saslMechanism, jaasCo
if (systemKind === "rocketmq") {
return authKind === "basic" ? "basic" : "none";
}
if (systemKind === "rabbitmq") {
return authKind === "basic" ? "basic" : "none";
}
return authKind || "none";
}

View File

@ -0,0 +1,34 @@
const RABBITMQ_ADDRESS_SEPARATOR = /[\s,;]+/u;
const RABBITMQ_DEFAULT_PORT = "5672";
function requireRabbitmqAddresses(value: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error("RabbitMQ addresses are required");
return trimmed;
}
function normalizeRabbitmqAddress(address: string): string {
if (address.includes("://")) {
throw new Error("RabbitMQ addresses must be host:port values without a URL scheme");
}
let parsed: URL;
try {
parsed = new URL(`amqp://${address}`);
} catch {
throw new Error("RabbitMQ addresses are invalid");
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("RabbitMQ addresses are invalid");
}
return parsed.port ? address : `${address}:${RABBITMQ_DEFAULT_PORT}`;
}
export function normalizeRabbitmqAddresses(value: string): string {
const addresses = requireRabbitmqAddresses(value)
.split(RABBITMQ_ADDRESS_SEPARATOR)
.map((address) => address.trim())
.filter(Boolean)
.map(normalizeRabbitmqAddress);
if (!addresses.length) throw new Error("RabbitMQ addresses are required");
return addresses.join(",");
}

View File

@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { defaultMqCapabilitiesForSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
import {
defaultMqCapabilitiesForSystemKind,
isAllVhostsNamespace,
normalizeMqTabForSystemKind,
RABBITMQ_ALL_VHOSTS,
resolveAvailableMqTabs,
resolveInitialMqTab,
resolveMqRowNamespace,
resolveMqSystemKindFromConnection,
resolveRabbitMqDefaultVhost,
resolveRabbitMqSendNamespace,
} from "@/lib/mq/mqConsoleDefaults";
describe("mqConsoleDefaults", () => {
it("resolves RocketMQ from driver profile before cluster info loads", () => {
@ -34,4 +45,144 @@ describe("mqConsoleDefaults", () => {
const caps = defaultMqCapabilitiesForSystemKind("rocketmq");
expect(resolveAvailableMqTabs({ systemKind: "rocketmq", capabilities: caps })).toEqual(["broker", "topics", "subscriptions", "producers", "messages", "trace", "permissions"]);
});
it("resolves RabbitMQ from driver profile and external config", () => {
const config = {
id: "mq-2",
db_type: "mq",
driver_profile: "rabbitmq",
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" } },
} as ConnectionConfig;
expect(resolveMqSystemKindFromConnection(config)).toBe("rabbitmq");
expect(resolveMqSystemKindFromConnection({ ...config, external_config: undefined })).toBe("rabbitmq");
});
it("treats RabbitMQ as a flat MQ system with vhost namespaces", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsTenants).toBe(false);
expect(caps.supportsNamespaces).toBe(true);
expect(caps.supportsPartitionedTopics).toBe(false);
expect(caps.supportsClearBacklog).toBe(true);
expect(caps.supportsSubscriptions).toBe(true);
expect(caps.supportsPeekMessages).toBe(true);
expect(caps.supportsSendMessage).toBe(true);
expect(resolveInitialMqTab({ systemKind: "rabbitmq", initialTenant: "_flat_mq" })).toBe("topics");
});
it("enables client connection management for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsClientConnections).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsClientConnections).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsClientConnections).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsClientConnections).toBeFalsy();
});
it("exposes the namespaces tab for RabbitMQ vhost management", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: caps })).toEqual(["namespaces", "topics", "subscriptions", "monitoring", "clients", "messages", "broker", "policies", "permissions"]);
});
it("enables policies & cluster monitoring for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsPolicies).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsClusterMonitoring).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsPolicies).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsClusterMonitoring).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsPolicies).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsPolicies).toBeFalsy();
});
it("lights the policies tab via supportsPolicies for RabbitMQ", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsRateLimits).toBe(false);
expect(caps.supportsBacklogQuota).toBe(false);
expect(caps.supportsRetention).toBe(false);
// The tab appears even when Pulsar-style rates/quotas are unsupported.
const rabbitTabs = resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: { ...caps, supportsPolicies: true } });
expect(rabbitTabs).toContain("policies");
// Without any policy capability the tab stays hidden.
const noPolicyCaps = { ...defaultMqCapabilitiesForSystemKind("kafka"), supportsRetention: false, supportsPolicies: false };
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: noPolicyCaps })).not.toContain("policies");
});
it("lights the permissions tab via supportsUserPermissions for RabbitMQ", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsPermissions).toBe(false);
expect(caps.supportsUserPermissions).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsUserPermissions).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsUserPermissions).toBeFalsy();
// The tab appears even when role-grant permissions are unsupported.
const rabbitTabs = resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: { ...caps, supportsPermissions: false, supportsUserPermissions: true } });
expect(rabbitTabs).toContain("permissions");
// Without either capability the tab stays hidden.
const noPermCaps = { ...defaultMqCapabilitiesForSystemKind("kafka"), supportsPermissions: false, supportsUserPermissions: false };
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: noPermCaps })).not.toContain("permissions");
});
it("resolves the RabbitMQ default vhost from the connection config", () => {
expect(resolveRabbitMqDefaultVhost(undefined)).toBe("/");
expect(resolveRabbitMqDefaultVhost({ id: "mq-3", db_type: "mq" } as ConnectionConfig)).toBe("/");
const config = {
id: "mq-3",
db_type: "mq",
driver_profile: "rabbitmq",
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" }, extra: { virtualHost: "orders" } },
} as ConnectionConfig;
expect(resolveRabbitMqDefaultVhost(config)).toBe("orders");
const snakeCase = {
...config,
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" }, extra: { virtual_host: "billing" } },
} as ConnectionConfig;
expect(resolveRabbitMqDefaultVhost(snakeCase)).toBe("billing");
});
it('marks the all-vhosts selection with the "*" namespace', () => {
expect(RABBITMQ_ALL_VHOSTS).toBe("*");
expect(isAllVhostsNamespace("*")).toBe(true);
expect(isAllVhostsNamespace("/")).toBe(false);
expect(isAllVhostsNamespace("orders")).toBe(false);
expect(isAllVhostsNamespace(undefined)).toBe(false);
expect(isAllVhostsNamespace(null)).toBe(false);
});
it("routes row-level operations to the row vhost with selection fallback", () => {
expect(resolveMqRowNamespace({ namespace: "orders" }, "*")).toBe("orders");
expect(resolveMqRowNamespace({ namespace: "orders" }, "/")).toBe("orders");
expect(resolveMqRowNamespace({}, "/")).toBe("/");
expect(resolveMqRowNamespace(undefined, "/")).toBe("/");
expect(resolveMqRowNamespace(undefined, undefined)).toBeUndefined();
});
it("never falls back to the all-vhosts sentinel for row-level operations", () => {
// "*" is a listing sentinel: a row without its own vhost must not resolve
// to it, otherwise a write would fan out across every vhost.
expect(resolveMqRowNamespace({}, "*")).toBeUndefined();
expect(resolveMqRowNamespace(undefined, "*")).toBeUndefined();
});
it("publishes to the row vhost or the connection default in all-vhosts mode", () => {
// A topic entered from a cross-vhost row keeps its own vhost.
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "*")).toBe("orders");
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "/")).toBe("orders");
// All-vhosts mode without a row topic falls back to the connection default vhost.
expect(resolveRabbitMqSendNamespace(undefined, "*")).toBeUndefined();
expect(resolveRabbitMqSendNamespace({}, "*")).toBeUndefined();
// Single-vhost mode keeps the selected namespace.
expect(resolveRabbitMqSendNamespace(undefined, "/")).toBe("/");
});
it("prefers the datalist row vhost, then the fallback topic, when publishing", () => {
// The datalist row (picked/typed topic) wins over the panel's topic prop.
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "*", { namespace: "billing" })).toBe("orders");
// Without a datalist row the fallback topic's vhost is used, even in all-vhosts mode.
expect(resolveRabbitMqSendNamespace(undefined, "*", { namespace: "billing" })).toBe("billing");
expect(resolveRabbitMqSendNamespace({}, "*", { namespace: "billing" })).toBe("billing");
// The fallback topic also wins over a single-vhost selection.
expect(resolveRabbitMqSendNamespace(undefined, "/", { namespace: "billing" })).toBe("billing");
// Nothing to go on in all-vhosts mode: fall back to the connection default vhost.
expect(resolveRabbitMqSendNamespace(undefined, "*", undefined)).toBeUndefined();
});
});

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { defaultMqCapabilitiesForSystemKind } from "@/lib/mq/mqConsoleDefaults";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import type { MqExchangeInfo } from "@/types/mq";
function exchange(partial: Partial<MqExchangeInfo>): MqExchangeInfo {
return { name: "", type: "direct", durable: true, autoDelete: false, internal: false, ...partial };
}
describe("rabbitmqExchanges", () => {
it("offers the four creatable exchange types", () => {
expect(RABBITMQ_EXCHANGE_TYPES).toEqual(["direct", "fanout", "topic", "headers"]);
});
it("renders the default exchange with a display name", () => {
expect(rabbitMqExchangeDisplayName(exchange({ name: "" }))).toBe("(AMQP default)");
expect(rabbitMqExchangeDisplayName(exchange({ name: "dbx-events" }))).toBe("dbx-events");
expect(rabbitMqExchangeDisplayName({ name: "amq.topic" })).toBe("amq.topic");
});
it("marks the default and amq.* exchanges as built-in", () => {
expect(isBuiltinRabbitMqExchange(exchange({ name: "" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amq.direct" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amq.headers", type: "headers" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "dbx-internal", internal: true }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "dbx-events" }))).toBe(false);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amqp.custom" }))).toBe(false);
});
});
describe("mqConsoleDefaults RabbitMQ exchanges capability", () => {
it("enables exchange management for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsExchanges).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsExchanges).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsExchanges).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsExchanges).toBeFalsy();
});
});

View File

@ -23,6 +23,8 @@ const FLAT_MQ_BASE_CAPABILITIES: MqCapabilities = {
supportsMessageQuery: false,
supportsDlq: false,
supportsMessageTrace: false,
supportsExchanges: false,
supportsClientConnections: false,
};
const PULSAR_DEFAULT_CAPABILITIES: MqCapabilities = {
@ -52,17 +54,17 @@ const PULSAR_DEFAULT_CAPABILITIES: MqCapabilities = {
export function resolveMqSystemKindFromConnection(config: ConnectionConfig | undefined): MqSystemKind | undefined {
if (!config || config.db_type !== "mq") return undefined;
const external = config.external_config as Partial<MqAdminConfig> | undefined;
if (external?.systemKind === "kafka" || external?.systemKind === "rocketmq" || external?.systemKind === "pulsar") {
if (external?.systemKind === "kafka" || external?.systemKind === "rocketmq" || external?.systemKind === "rabbitmq" || external?.systemKind === "pulsar") {
return external.systemKind;
}
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "pulsar") {
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq" || config.driver_profile === "pulsar") {
return config.driver_profile;
}
return "pulsar";
}
export function isFlatMqSystemKind(kind: MqSystemKind | undefined): boolean {
return kind === "kafka" || kind === "rocketmq";
return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq";
}
export function defaultMqCapabilitiesForSystemKind(kind: MqSystemKind | undefined): MqCapabilities {
@ -81,9 +83,81 @@ export function defaultMqCapabilitiesForSystemKind(kind: MqSystemKind | undefine
supportsMessageTrace: true,
};
}
if (kind === "rabbitmq") {
return {
...FLAT_MQ_BASE_CAPABILITIES,
// RabbitMQ is an intermediate form between flat and tenant/namespace systems:
// namespaces map to virtual hosts (list/create/delete via the management API),
// and clearing the backlog purges the selected queue.
supportsNamespaces: true,
supportsPartitionedTopics: false,
supportsResetCursor: false,
supportsPermissions: false,
// RabbitMQ manages exchanges & bindings on top of queues.
supportsExchanges: true,
// Client connections / channels come from the management API.
supportsClientConnections: true,
// Users & per-vhost permission triples come from the management API.
supportsUserPermissions: true,
// Virtual-host policies come from the management API.
supportsPolicies: true,
// Cluster overview & node stats come from the management API.
supportsClusterMonitoring: true,
};
}
return { ...PULSAR_DEFAULT_CAPABILITIES };
}
/**
* Synthetic tenant used for RabbitMQ connections. RabbitMQ has no tenant
* concept; the console pins the tenant to this value and exposes virtual
* hosts as namespaces instead.
*/
export const RABBITMQ_MQ_TENANT = "_rabbitmq";
/**
* Marker namespace meaning "all virtual hosts" for RabbitMQ. The backend
* translates it into a vhost-less management API listing where every item
* carries its own vhost.
*/
export const RABBITMQ_ALL_VHOSTS = "*";
export function isAllVhostsNamespace(namespace: string | undefined | null): boolean {
return namespace === RABBITMQ_ALL_VHOSTS;
}
/**
* Resolve the namespace for a row-level operation: prefer the namespace the
* row itself carries (cross-vhost listings), falling back to the currently
* selected namespace. In "all vhosts" mode a row without its own namespace
* resolves to undefined "*" is a listing sentinel and must never reach a
* write operation.
*/
export function resolveMqRowNamespace(row: { namespace?: string } | undefined, selectedNamespace: string | undefined): string | undefined {
if (row?.namespace) return row.namespace;
return isAllVhostsNamespace(selectedNamespace) ? undefined : selectedNamespace;
}
/**
* Resolve the virtual host a RabbitMQ publish targets. A topic chosen from a
* row keeps its own vhost, then the fallback topic (e.g. the panel's selected
* topic prop); in all-vhosts mode without a row topic the publish falls back
* to the connection default vhost (no explicit namespace).
*/
export function resolveRabbitMqSendNamespace(topic: { namespace?: string } | undefined, selectedNamespace: string | undefined, fallbackTopic?: { namespace?: string }): string | undefined {
const namespace = topic?.namespace || fallbackTopic?.namespace;
if (namespace) return namespace;
return isAllVhostsNamespace(selectedNamespace) ? undefined : selectedNamespace;
}
/** Resolve the connection's default virtual host, used as the initial namespace. */
export function resolveRabbitMqDefaultVhost(config: ConnectionConfig | undefined): string {
const external = config?.external_config as Partial<MqAdminConfig> | undefined;
const extra = external?.extra as Record<string, unknown> | undefined;
const vhost = extra?.virtualHost ?? extra?.virtual_host;
return typeof vhost === "string" && vhost.trim() ? vhost.trim() : "/";
}
export type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "producers" | "policies" | "permissions" | "messages" | "raw" | "broker" | "dlq" | "trace";
export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; capabilities: MqCapabilities }): MqTab[] {
@ -105,10 +179,12 @@ export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; cap
tabs.push("clients");
if (capabilities.supportsSendMessage) tabs.push("messages");
tabs.push("broker");
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention) {
// RabbitMQ lights this tab via virtual-host policies instead of Pulsar rates/quotas.
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention || capabilities.supportsPolicies) {
tabs.push("policies");
}
if (capabilities.supportsPermissions) tabs.push("permissions");
// RabbitMQ lights this tab via user/vhost permissions instead of role grants.
if (capabilities.supportsPermissions || capabilities.supportsUserPermissions) tabs.push("permissions");
if (capabilities.supportsRawAdminApi) tabs.push("raw");
return tabs;
}

View File

@ -0,0 +1,20 @@
import type { MqExchangeInfo, MqExchangeType } from "@/types/mq";
/** Exchange types creatable through the RabbitMQ admin API. */
export const RABBITMQ_EXCHANGE_TYPES: MqExchangeType[] = ["direct", "fanout", "topic", "headers"];
/**
* The default exchange has an empty name; display it as "(AMQP default)" in
* the list, the same way the RabbitMQ management UI does.
*/
export function rabbitMqExchangeDisplayName(exchange: Pick<MqExchangeInfo, "name">): string {
return exchange.name || "(AMQP default)";
}
/**
* Built-in exchanges (the default exchange and the amq.* set) must not be
* deleted from the console.
*/
export function isBuiltinRabbitMqExchange(exchange: MqExchangeInfo): boolean {
return exchange.name === "" || exchange.name.startsWith("amq.") || exchange.internal;
}

View File

@ -180,6 +180,49 @@ describe("connectionStore MQ sidebar tree", () => {
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_flat_mq", initialTab: "topics" }]);
});
it("adds a RabbitMQ topics child pinned to the synthetic _rabbitmq tenant", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants: vi.fn(),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = {
...kafkaConnection(),
name: "RabbitMQ",
driver_profile: "rabbitmq",
driver_label: "RabbitMQ",
external_config: {
systemKind: "rabbitmq",
adminUrl: "",
auth: { kind: "none" },
extra: { addresses: "127.0.0.1:5672" },
},
} as ConnectionConfig;
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_rabbitmq", initialTab: "topics" }]);
});
it("detects Kafka from external config when driver profile is missing", async () => {
const mqListTenants = vi.fn();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));

View File

@ -96,6 +96,7 @@ import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
@ -119,9 +120,9 @@ function sidebarObjectGroupPageSize(): number {
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq") return true;
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true;
const kind = (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind;
return kind === "kafka" || kind === "rocketmq";
return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq";
}
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
@ -2561,15 +2562,17 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(connectionId);
if (isFlatMqConnection(config)) {
// Kafka/RocketMQ have no tenant/namespace concept. Create a synthetic child
// that opens the MQ admin console directly when clicked.
// Kafka/RocketMQ have no tenant/namespace concept; RabbitMQ pins a synthetic
// tenant and exposes virtual hosts as namespaces inside the console. Create a
// synthetic child that opens the MQ admin console directly when clicked.
const mqTenant = resolveMqSystemKindFromConnection(config) === "rabbitmq" ? RABBITMQ_MQ_TENANT : "_flat_mq";
setChildren(node, [
{
id: schemaCacheKey(connectionId, "mq-tenant", "_flat_mq"),
id: schemaCacheKey(connectionId, "mq-tenant", mqTenant),
label: "Topics",
type: "mq-tenant" as const,
connectionId,
mqTenant: "_flat_mq",
mqTenant,
mqInitialTab: "topics",
},
]);

View File

@ -65,6 +65,15 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
/* Aliases for legacy MQ panel styles (used across components/mq/*). */
--color-error: var(--destructive);
--color-error-bg: color-mix(in srgb, var(--destructive) 10%, transparent);
--color-hover: var(--accent);
--color-background-secondary: var(--muted);
--color-text-secondary: var(--muted-foreground);
--color-text-tertiary: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
--color-border-light: color-mix(in srgb, var(--border) 60%, transparent);
--color-primary-alpha: color-mix(in srgb, var(--primary) 12%, transparent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@ -1361,470 +1370,470 @@ body.dbx-table-reference-dragging * {
/* Tailwind CSS v4 targets Safari 16.4+. Keep these legacy WebKit fallbacks
* isolated so supported WebViews continue using the generated component CSS. */
@supports not (color: oklch(0.5 0.1 180)) {
@media (min-width: 640px) {
.sm\:flex-row {
flex-direction: row !important;
@media (min-width: 640px) {
.sm\:flex-row {
flex-direction: row !important;
}
.sm\:justify-end {
justify-content: flex-end !important;
}
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
.sm\:justify-end {
justify-content: flex-end !important;
@media (min-width: 768px) {
.md\:w-full {
width: 100% !important;
}
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
.md\:grid-cols-\[1fr_auto\] {
grid-template-columns: 1fr auto !important;
}
.md\:grid-cols-\[minmax\(0\,1fr\)_180px_auto\] {
grid-template-columns: minmax(0, 1fr) 180px auto !important;
}
}
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
@media (min-width: 1024px) {
.lg\:block {
display: block !important;
}
@media (min-width: 768px) {
.md\:w-full {
width: 100% !important;
.lg\:hidden {
display: none !important;
}
.lg\:grid {
display: grid !important;
}
.lg\:flex-row {
flex-direction: row !important;
}
.lg\:flex-col {
flex-direction: column !important;
}
.lg\:items-center {
align-items: center !important;
}
.lg\:justify-between {
justify-content: space-between !important;
}
.lg\:justify-end {
justify-content: flex-end !important;
}
.lg\:w-40 {
width: 10rem !important;
}
.lg\:overflow-x-hidden {
overflow-x: hidden !important;
}
.lg\:overflow-y-auto {
overflow-y: auto !important;
}
.lg\:border-b-0 {
border-bottom-width: 0 !important;
}
.lg\:border-r {
border-right-width: 1px !important;
}
.lg\:pb-0 {
padding-bottom: 0 !important;
}
.lg\:pr-3 {
padding-right: 0.75rem !important;
}
.lg\:text-right {
text-align: right !important;
}
.lg\:grid-cols-6 {
grid-template-columns: repeat(6, minmax(0, 1fr)) !important;
}
.lg\:grid-cols-\[1\.2fr_0\.8fr\] {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) !important;
}
.lg\:grid-cols-\[minmax\(0\,1\.6fr\)_72px_56px_76px_58px_76px_72px\] {
grid-template-columns: minmax(0, 1.6fr) 72px 56px 76px 58px 76px 72px !important;
}
}
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
[data-slot="dialog-content"] {
width: calc(100vw - 2rem);
max-width: 24rem;
border: 1px solid rgb(229, 229, 229);
border-color: rgb(229, 229, 229) !important;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.16);
}
.md\:grid-cols-\[1fr_auto\] {
grid-template-columns: 1fr auto !important;
.dark [data-slot="dialog-content"] {
border-color: rgba(110, 110, 114, 0.28) !important;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.md\:grid-cols-\[minmax\(0\,1fr\)_180px_auto\] {
grid-template-columns: minmax(0, 1fr) 180px auto !important;
}
}
@media (min-width: 1024px) {
.lg\:block {
display: block !important;
}
.lg\:hidden {
display: none !important;
}
.lg\:grid {
display: grid !important;
}
.lg\:flex-row {
flex-direction: row !important;
}
.lg\:flex-col {
flex-direction: column !important;
}
.lg\:items-center {
align-items: center !important;
}
.lg\:justify-between {
justify-content: space-between !important;
}
.lg\:justify-end {
justify-content: flex-end !important;
}
.lg\:w-40 {
width: 10rem !important;
}
.lg\:overflow-x-hidden {
overflow-x: hidden !important;
}
.lg\:overflow-y-auto {
overflow-y: auto !important;
}
.lg\:border-b-0 {
border-bottom-width: 0 !important;
}
.lg\:border-r {
border-right-width: 1px !important;
}
.lg\:pb-0 {
padding-bottom: 0 !important;
}
.lg\:pr-3 {
padding-right: 0.75rem !important;
}
.lg\:text-right {
text-align: right !important;
}
.lg\:grid-cols-6 {
grid-template-columns: repeat(6, minmax(0, 1fr)) !important;
}
.lg\:grid-cols-\[1\.2fr_0\.8fr\] {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) !important;
}
.lg\:grid-cols-\[minmax\(0\,1\.6fr\)_72px_56px_76px_58px_76px_72px\] {
grid-template-columns: minmax(0, 1.6fr) 72px 56px 76px 58px 76px 72px !important;
}
}
[data-slot="dialog-content"] {
width: calc(100vw - 2rem);
max-width: 24rem;
border: 1px solid rgb(229, 229, 229);
border-color: rgb(229, 229, 229) !important;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.16);
}
.dark [data-slot="dialog-content"] {
border-color: rgba(110, 110, 114, 0.28) !important;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
[data-slot="dialog-content"][class~="max-w-sm"] {
max-width: 24rem !important;
}
[data-slot="dialog-content"][class~="max-w-md"] {
max-width: 28rem !important;
}
[data-slot="dialog-content"][class~="max-w-lg"] {
max-width: 32rem !important;
}
[data-slot="dialog-content"][class~="max-w-xl"] {
max-width: 36rem !important;
}
[data-slot="dialog-content"][class~="max-w-2xl"] {
max-width: 42rem !important;
}
[data-slot="dialog-content"][class~="max-w-4xl"] {
max-width: 56rem !important;
}
[data-slot="dialog-content"][class~="max-w-5xl"] {
max-width: 64rem !important;
}
[data-slot="dialog-content"][class~="max-w-190"] {
max-width: 47.5rem !important;
}
[data-slot="dialog-content"][class*="max-w-[92vw]"] {
max-width: 92vw !important;
}
[data-slot="dialog-content"][class*="max-w-[94vw]"] {
max-width: 94vw !important;
}
[data-slot="dialog-content"][class*="max-w-[1100px]"] {
max-width: 1100px !important;
}
[data-slot="tabs"][data-orientation="horizontal"] {
flex-direction: column !important;
}
[data-slot="tabs"][data-orientation="vertical"] {
flex-direction: row !important;
}
[data-slot="tabs-list"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 6px;
color: var(--muted-foreground);
}
[data-slot="tabs-list"][data-variant="default"] {
min-height: 2rem;
padding: 3px;
background: var(--muted);
}
[data-slot="tabs-list"][data-variant="line"] {
gap: 0.25rem;
padding: 3px;
background: transparent;
border-radius: 0;
}
[data-slot="tabs"][data-orientation="vertical"] > [data-slot="tabs-list"] {
flex-direction: column !important;
height: fit-content;
}
[data-slot="tabs-trigger"] {
position: relative;
display: inline-flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
min-width: 0;
height: calc(100% - 1px);
padding: 0.125rem 0.375rem;
gap: 0.375rem;
border: 1px solid transparent;
border-radius: 0.375rem;
color: rgba(10, 10, 10, 0.6);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
white-space: nowrap;
transition:
color 150ms ease,
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
}
[data-slot="tabs"][data-orientation="vertical"] [data-slot="tabs-trigger"] {
width: 100%;
justify-content: flex-start;
}
[data-slot="tabs-trigger"]:hover {
color: var(--foreground);
}
[data-slot="tabs-trigger"]:focus-visible {
border-color: var(--ring);
outline: 1px solid var(--ring);
box-shadow: 0 0 0 3px rgba(161, 161, 161, 0.5);
}
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][aria-selected="true"],
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-state="active"],
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: var(--background);
border-color: transparent;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 1px 1px rgba(0, 0, 0, 0.04);
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"] {
background: transparent;
box-shadow: none;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][aria-selected="true"],
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-state="active"],
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: transparent;
border-color: transparent;
box-shadow: none;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
content: "";
position: absolute;
background: var(--foreground);
opacity: 0;
transition: opacity 150ms ease;
}
[data-slot="tabs"][data-orientation="horizontal"] [data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
left: 0;
right: 0;
bottom: -5px;
height: 2px;
}
[data-slot="tabs"][data-orientation="vertical"] [data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
top: 0;
right: -4px;
bottom: 0;
width: 2px;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][aria-selected="true"]::after,
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-state="active"]::after,
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-active]::after {
opacity: 1;
}
.dark [data-slot="tabs-trigger"] {
color: var(--muted-foreground);
}
.dark [data-slot="tabs-trigger"]:hover {
color: var(--foreground);
}
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][aria-selected="true"],
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-state="active"],
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: rgba(110, 110, 114, 0.3);
border-color: var(--input);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
0 1px 1px rgba(0, 0, 0, 0.16);
}
[data-slot="tabs-content"] {
min-width: 0;
}
.dark [data-grid-root] .data-grid-header-shell,
.dark [data-grid-root] .data-grid-header-cell,
.dark [data-grid-root] .data-grid-transpose-header,
.dark [data-grid-root] [data-grid-column-index] {
background-color: rgb(24, 25, 27) !important;
}
.dark [data-grid-root] .data-grid-header-row,
.dark [data-grid-root] .data-grid-transpose-header {
color: rgb(215, 215, 219) !important;
}
.dark [data-grid-root] .data-grid-header-cell:hover,
.dark [data-grid-root] [data-grid-column-index]:hover {
background-color: rgb(38, 39, 42) !important;
}
@media (min-width: 640px) {
[data-slot="dialog-footer"] {
flex-direction: row !important;
justify-content: flex-end !important;
}
[data-slot="dialog-content"][class~="sm:max-w-sm"] {
[data-slot="dialog-content"][class~="max-w-sm"] {
max-width: 24rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-md"] {
[data-slot="dialog-content"][class~="max-w-md"] {
max-width: 28rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-lg"] {
[data-slot="dialog-content"][class~="max-w-lg"] {
max-width: 32rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-xl"] {
[data-slot="dialog-content"][class~="max-w-xl"] {
max-width: 36rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-2xl"] {
[data-slot="dialog-content"][class~="max-w-2xl"] {
max-width: 42rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-3xl"] {
max-width: 48rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-4xl"] {
[data-slot="dialog-content"][class~="max-w-4xl"] {
max-width: 56rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-5xl"] {
[data-slot="dialog-content"][class~="max-w-5xl"] {
max-width: 64rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-190"] {
[data-slot="dialog-content"][class~="max-w-190"] {
max-width: 47.5rem !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[360px]"] {
max-width: 360px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[380px]"] {
max-width: 380px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[400px]"] {
max-width: 400px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[420px]"] {
max-width: 420px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[440px]"] {
max-width: 440px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[460px]"] {
max-width: 460px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[480px]"] {
max-width: 480px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[500px]"] {
max-width: 500px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[520px]"] {
max-width: 520px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[560px]"] {
max-width: 560px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[660px]"] {
max-width: 660px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[720px]"] {
max-width: 720px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[760px]"] {
max-width: 760px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[780px]"] {
max-width: 780px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[840px]"] {
max-width: 840px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[860px]"] {
max-width: 860px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[900px]"] {
max-width: 900px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[960px]"] {
max-width: 960px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[980px]"] {
max-width: 980px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[calc(100vw-2rem)]"] {
max-width: calc(100vw - 2rem) !important;
}
[data-slot="dialog-content"][class*="sm:!max-w-[92vw]"] {
[data-slot="dialog-content"][class*="max-w-[92vw]"] {
max-width: 92vw !important;
}
[data-slot="dialog-content"][class*="sm:!max-w-[min(920px,calc(100vw-48px))]"] {
max-width: min(920px, calc(100vw - 48px)) !important;
[data-slot="dialog-content"][class*="max-w-[94vw]"] {
max-width: 94vw !important;
}
[data-slot="dialog-content"][class*="max-w-[1100px]"] {
max-width: 1100px !important;
}
[data-slot="tabs"][data-orientation="horizontal"] {
flex-direction: column !important;
}
[data-slot="tabs"][data-orientation="vertical"] {
flex-direction: row !important;
}
[data-slot="tabs-list"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 6px;
color: var(--muted-foreground);
}
[data-slot="tabs-list"][data-variant="default"] {
min-height: 2rem;
padding: 3px;
background: var(--muted);
}
[data-slot="tabs-list"][data-variant="line"] {
gap: 0.25rem;
padding: 3px;
background: transparent;
border-radius: 0;
}
[data-slot="tabs"][data-orientation="vertical"] > [data-slot="tabs-list"] {
flex-direction: column !important;
height: fit-content;
}
[data-slot="tabs-trigger"] {
position: relative;
display: inline-flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
min-width: 0;
height: calc(100% - 1px);
padding: 0.125rem 0.375rem;
gap: 0.375rem;
border: 1px solid transparent;
border-radius: 0.375rem;
color: rgba(10, 10, 10, 0.6);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
white-space: nowrap;
transition:
color 150ms ease,
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
}
[data-slot="tabs"][data-orientation="vertical"] [data-slot="tabs-trigger"] {
width: 100%;
justify-content: flex-start;
}
[data-slot="tabs-trigger"]:hover {
color: var(--foreground);
}
[data-slot="tabs-trigger"]:focus-visible {
border-color: var(--ring);
outline: 1px solid var(--ring);
box-shadow: 0 0 0 3px rgba(161, 161, 161, 0.5);
}
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][aria-selected="true"],
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-state="active"],
[data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: var(--background);
border-color: transparent;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 1px 1px rgba(0, 0, 0, 0.04);
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"] {
background: transparent;
box-shadow: none;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][aria-selected="true"],
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-state="active"],
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: transparent;
border-color: transparent;
box-shadow: none;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
content: "";
position: absolute;
background: var(--foreground);
opacity: 0;
transition: opacity 150ms ease;
}
[data-slot="tabs"][data-orientation="horizontal"] [data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
left: 0;
right: 0;
bottom: -5px;
height: 2px;
}
[data-slot="tabs"][data-orientation="vertical"] [data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"]::after {
top: 0;
right: -4px;
bottom: 0;
width: 2px;
}
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][aria-selected="true"]::after,
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-state="active"]::after,
[data-slot="tabs-list"][data-variant="line"] > [data-slot="tabs-trigger"][data-active]::after {
opacity: 1;
}
.dark [data-slot="tabs-trigger"] {
color: var(--muted-foreground);
}
.dark [data-slot="tabs-trigger"]:hover {
color: var(--foreground);
}
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][aria-selected="true"],
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-state="active"],
.dark [data-slot="tabs-list"][data-variant="default"] > [data-slot="tabs-trigger"][data-active] {
color: var(--foreground);
background: rgba(110, 110, 114, 0.3);
border-color: var(--input);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
0 1px 1px rgba(0, 0, 0, 0.16);
}
[data-slot="tabs-content"] {
min-width: 0;
}
.dark [data-grid-root] .data-grid-header-shell,
.dark [data-grid-root] .data-grid-header-cell,
.dark [data-grid-root] .data-grid-transpose-header,
.dark [data-grid-root] [data-grid-column-index] {
background-color: rgb(24, 25, 27) !important;
}
.dark [data-grid-root] .data-grid-header-row,
.dark [data-grid-root] .data-grid-transpose-header {
color: rgb(215, 215, 219) !important;
}
.dark [data-grid-root] .data-grid-header-cell:hover,
.dark [data-grid-root] [data-grid-column-index]:hover {
background-color: rgb(38, 39, 42) !important;
}
@media (min-width: 640px) {
[data-slot="dialog-footer"] {
flex-direction: row !important;
justify-content: flex-end !important;
}
[data-slot="dialog-content"][class~="sm:max-w-sm"] {
max-width: 24rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-md"] {
max-width: 28rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-lg"] {
max-width: 32rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-xl"] {
max-width: 36rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-2xl"] {
max-width: 42rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-3xl"] {
max-width: 48rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-4xl"] {
max-width: 56rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-5xl"] {
max-width: 64rem !important;
}
[data-slot="dialog-content"][class~="sm:max-w-190"] {
max-width: 47.5rem !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[360px]"] {
max-width: 360px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[380px]"] {
max-width: 380px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[400px]"] {
max-width: 400px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[420px]"] {
max-width: 420px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[440px]"] {
max-width: 440px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[460px]"] {
max-width: 460px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[480px]"] {
max-width: 480px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[500px]"] {
max-width: 500px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[520px]"] {
max-width: 520px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[560px]"] {
max-width: 560px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[660px]"] {
max-width: 660px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[720px]"] {
max-width: 720px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[760px]"] {
max-width: 760px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[780px]"] {
max-width: 780px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[840px]"] {
max-width: 840px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[860px]"] {
max-width: 860px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[900px]"] {
max-width: 900px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[960px]"] {
max-width: 960px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[980px]"] {
max-width: 980px !important;
}
[data-slot="dialog-content"][class*="sm:max-w-[calc(100vw-2rem)]"] {
max-width: calc(100vw - 2rem) !important;
}
[data-slot="dialog-content"][class*="sm:!max-w-[92vw]"] {
max-width: 92vw !important;
}
[data-slot="dialog-content"][class*="sm:!max-w-[min(920px,calc(100vw-48px))]"] {
max-width: min(920px, calc(100vw - 48px)) !important;
}
}
}
}
/* Splitpanes */

View File

@ -1,6 +1,6 @@
// Message queue admin types, matching dbx-core/src/mq/types.rs
export type MqSystemKind = "pulsar" | "kafka" | "rocketmq";
export type MqSystemKind = "pulsar" | "kafka" | "rocketmq" | "rabbitmq";
export interface MqCapabilities {
supportsTenants: boolean;
@ -24,6 +24,14 @@ export interface MqCapabilities {
supportsMessageQuery?: boolean;
supportsDlq?: boolean;
supportsMessageTrace?: boolean;
supportsExchanges?: boolean;
supportsClientConnections?: boolean;
/** RabbitMQ: user & virtual-host permission management. */
supportsUserPermissions?: boolean;
/** RabbitMQ: virtual-host policy management. */
supportsPolicies?: boolean;
/** RabbitMQ: cluster overview & node monitoring via the management API. */
supportsClusterMonitoring?: boolean;
}
export interface MqClusterInfo {
@ -146,6 +154,8 @@ export interface TopicInfo {
persistent: boolean;
internal?: boolean;
messageType?: RocketMqTopicMessageType | string;
/** RabbitMQ: owning virtual host, present when listing across all vhosts. */
namespace?: string;
}
export interface ListTopicsOpts {
@ -293,6 +303,158 @@ export type AuthAction = "produce" | "consume" | "functions" | "sources" | "sink
export type PermissionMap = Record<string, AuthAction[]>;
// Exchange / Binding (RabbitMQ)
export type MqExchangeType = "direct" | "fanout" | "topic" | "headers";
export interface MqExchangeInfo {
name: string;
type: MqExchangeType | string;
durable: boolean;
autoDelete: boolean;
internal: boolean;
/** RabbitMQ: owning virtual host, present when listing across all vhosts. */
namespace?: string;
}
export interface MqExchangeCreateRequest {
name: string;
type: MqExchangeType | string;
durable?: boolean;
autoDelete?: boolean;
}
export type MqBindingDestinationType = "queue" | "exchange";
export interface MqBindingInfo {
source: string;
destination: string;
destinationType: MqBindingDestinationType | string;
routingKey?: string;
arguments?: Record<string, unknown>;
/** RabbitMQ: owning virtual host, present when listing across all vhosts. */
namespace?: string;
}
export interface MqBindingListFilter {
exchange?: string;
queue?: string;
}
// Client connections / channels (RabbitMQ)
export interface MqClientConnectionInfo {
/** Server-side connection name (`host:port -> host:port`). */
name: string;
user: string;
peerHost: string;
peerPort: number;
state: string;
/** Number of channels open on this connection. */
channels: number;
/** Receive rate (bytes/s), when the management API reports it. */
recvRate?: number;
/** Send rate (bytes/s), when the management API reports it. */
sendRate?: number;
/** Connection establishment time (epoch milliseconds), when reported. */
connectedAt?: number;
/** RabbitMQ: virtual host the connection is bound to, present when listing across all vhosts. */
namespace?: string;
}
export interface MqChannelInfo {
/** Channel name (`<connection name> (<channel number>)`). */
name: string;
/** Name of the connection this channel belongs to, when reported. */
connectionName?: string;
state: string;
prefetch?: number;
messagesUnacked?: number;
consumerCount?: number;
/** RabbitMQ: virtual host the channel belongs to, present when listing across all vhosts. */
namespace?: string;
}
// Users & vhost permissions (RabbitMQ)
export interface MqUserInfo {
name: string;
tags: string[];
}
export interface MqVhostPermission {
user: string;
/** RabbitMQ virtual host the permission applies to. */
vhost: string;
configure: string;
write: string;
read: string;
}
export interface MqUserPermissionListFilter {
virtualHost?: string;
user?: string;
/** List across all virtual hosts (every row carries its own vhost). */
allVhosts?: boolean;
}
export interface MqUserPermissionPatterns {
configure?: string;
write?: string;
read?: string;
}
// Policies (RabbitMQ)
export interface MqPolicyInfo {
name: string;
/** RabbitMQ virtual host the policy lives in. */
vhost: string;
pattern: string;
applyTo: string;
priority: number;
definition: Record<string, unknown>;
}
export interface MqPolicyListFilter {
virtualHost?: string;
/** List across all virtual hosts (every policy carries its own vhost). */
allVhosts?: boolean;
}
export interface MqPolicyUpsertRequest {
name: string;
pattern: string;
/** Defaults to "queues" on the server. */
applyTo?: string;
/** Defaults to 0 on the server. */
priority?: number;
definition: Record<string, unknown>;
}
// Cluster overview & nodes (RabbitMQ)
export interface MqOverviewInfo {
messagesReady?: number;
messagesUnacked?: number;
publishRate?: number;
deliverRate?: number;
ackRate?: number;
totalQueues?: number;
totalExchanges?: number;
totalConnections?: number;
totalChannels?: number;
totalConsumers?: number;
}
export interface MqNodeInfo {
name: string;
running: boolean;
memUsed?: number;
memLimit?: number;
diskFree?: number;
fdUsed?: number;
fdTotal?: number;
socketsUsed?: number;
socketsTotal?: number;
uptimeMs?: number;
}
// Raw request
export interface MqRawRequest {
method: string;
@ -315,6 +477,12 @@ export interface SendMessageRequest {
payloadText?: string;
headers: Record<string, string>;
partition?: number;
/** RabbitMQ: target exchange; empty means the default exchange. */
exchange?: string;
/** RabbitMQ: routing key used when publishing to an exchange. */
routingKey?: string;
/** RabbitMQ: virtual host the exchange/queue lives in. */
namespace?: string;
}
export interface SendMessageResponse {

View File

@ -1721,6 +1721,11 @@
"profile": "rocketmq",
"label": "Apache RocketMQ",
"agentKey": "rocketmq"
},
{
"profile": "rabbitmq",
"label": "RabbitMQ",
"agentKey": "rabbitmq"
}
],
"supportLevel": "connect",

View File

@ -42,11 +42,13 @@ const H2_PROFILES: &[AgentDriverProfile] =
const EXTRA_AGENT_LABELS: &[(&str, &str)] = &[
("kafka", "Apache Kafka"),
("rocketmq", "Apache RocketMQ"),
("rabbitmq", "RabbitMQ"),
("sqlserver-legacy", "SQL Server legacy compatibility component"),
];
const EXTRA_DRIVER_STORE_ENTRIES: &[(&str, &str)] = &[
("kafka", "Apache Kafka"),
("rocketmq", "Apache RocketMQ"),
("rabbitmq", "RabbitMQ"),
("sqlserver-legacy", "SQL Server legacy compatibility component"),
];
@ -310,6 +312,7 @@ pub fn agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option
return match driver_profile {
Some("kafka") => Some("kafka"),
Some("rocketmq") => Some("rocketmq"),
Some("rabbitmq") => Some("rabbitmq"),
_ => None,
};
}

View File

@ -14,6 +14,7 @@ mq/
├── service.rs - 服务层函数
└── adapters/
├── pulsar.rs - Pulsar 实现
├── rabbitmq.rs - RabbitMQ 实现 (Java agent)
└── pulsar_version.rs - 版本探测
```

View File

@ -45,6 +45,11 @@ const KAFKA_CAPABILITIES: MqCapabilities = MqCapabilities {
supports_message_query: false,
supports_dlq: false,
supports_message_trace: false,
supports_exchanges: false,
supports_client_connections: false,
supports_user_permissions: false,
supports_policies: false,
supports_cluster_monitoring: false,
};
pub struct KafkaAdmin {
@ -185,6 +190,7 @@ impl MessageQueueAdmin for KafkaAdmin {
persistent: true,
internal: t.get("internal").and_then(|v| v.as_bool()).unwrap_or(false),
message_type: None,
namespace: None,
}
})
.collect())

View File

@ -3,4 +3,5 @@
pub mod kafka;
pub mod pulsar;
pub mod pulsar_version;
pub mod rabbitmq;
pub mod rocketmq;

View File

@ -293,6 +293,7 @@ impl MessageQueueAdmin for PulsarAdmin {
persistent,
internal: false,
message_type: None,
namespace: None,
})
})
.buffered(PARTITION_METADATA_CONCURRENCY)
@ -318,6 +319,7 @@ impl MessageQueueAdmin for PulsarAdmin {
persistent,
internal: false,
message_type: None,
namespace: None,
});
}
}

View File

@ -143,6 +143,11 @@ impl PulsarApiProfile {
supports_message_query: false,
supports_dlq: false,
supports_message_trace: false,
supports_exchanges: false,
supports_client_connections: false,
supports_user_permissions: false,
supports_policies: false,
supports_cluster_monitoring: false,
},
}
}

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,11 @@ const ROCKETMQ_CAPABILITIES: MqCapabilities = MqCapabilities {
supports_message_query: true,
supports_dlq: true,
supports_message_trace: true,
supports_exchanges: false,
supports_client_connections: false,
supports_user_permissions: false,
supports_policies: false,
supports_cluster_monitoring: false,
};
const TOPIC_LIST_PAGE_SIZE: u32 = 200;
@ -726,6 +731,7 @@ fn topic_info_from_agent_value(t: &serde_json::Value) -> TopicInfo {
persistent: true,
internal: t.get("internal").and_then(|v| v.as_bool()).unwrap_or(false),
message_type: t.get("messageType").and_then(|v| v.as_str()).map(String::from),
namespace: None,
}
}

View File

@ -63,10 +63,11 @@ impl MqAdminConfig {
let mut parsed: MqAdminConfig = serde_json::from_value(raw.clone())
.map_err(|e| format!("Failed to parse message queue admin config: {e}"))?;
parsed.admin_url = parsed.admin_url.trim().to_string();
// Kafka and RocketMQ use namesrv/bootstrap from `extra` instead of an admin URL.
// Kafka, RocketMQ and RabbitMQ use namesrv/bootstrap/addresses from `extra` instead of an admin URL.
if parsed.admin_url.is_empty()
&& parsed.system_kind != MqSystemKind::Kafka
&& parsed.system_kind != MqSystemKind::RocketMq
&& parsed.system_kind != MqSystemKind::RabbitMq
{
return Err("Message queue admin URL is empty".to_string());
}
@ -216,6 +217,45 @@ mod tests {
assert_eq!(mqc.extra.get("namesrvAddr").and_then(|v| v.as_str()), Some("127.0.0.1:9876"));
}
#[test]
fn parses_rabbitmq_config_with_empty_admin_url() {
let cfg = connection_with_external(serde_json::json!({
"systemKind": "rabbitmq",
"adminUrl": "",
"auth": { "kind": "basic", "username": "guest", "password": "guest" },
"extra": {
"addresses": "127.0.0.1",
"port": 5672,
"virtualHost": "/"
}
}));
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse valid RabbitMQ config");
assert_eq!(mqc.system_kind, MqSystemKind::RabbitMq);
assert_eq!(mqc.admin_url, "");
assert_eq!(mqc.extra.get("addresses").and_then(|v| v.as_str()), Some("127.0.0.1"));
assert_eq!(mqc.extra.get("virtualHost").and_then(|v| v.as_str()), Some("/"));
}
#[test]
fn parses_rabbitmq_config_with_management_admin_url() {
// An explicit management URL stays untouched: it may carry a reverse
// proxy path prefix, and no http(s)-only scheme restriction applies.
let cfg = connection_with_external(serde_json::json!({
"systemKind": "rabbitmq",
"adminUrl": "http://rabbit.internal:15672/proxy",
"auth": { "kind": "basic", "username": "guest", "password": "guest" },
"extra": {
"addresses": "127.0.0.1",
"port": 5672,
"virtualHost": "/"
}
}));
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse RabbitMQ config with a management URL");
assert_eq!(mqc.system_kind, MqSystemKind::RabbitMq);
assert_eq!(mqc.admin_url, "http://rabbit.internal:15672/proxy");
assert_eq!(mqc.extra.get("addresses").and_then(|v| v.as_str()), Some("127.0.0.1"));
}
#[test]
fn admin_url_with_endpoint_preserves_scheme_path_and_query() {
let rewritten =

View File

@ -29,6 +29,7 @@ use crate::db::agent_driver::AgentLaunchSpec;
use crate::models::connection::ConnectionConfig;
use crate::mq::adapters::kafka::KafkaAdmin;
use crate::mq::adapters::pulsar::PulsarAdmin;
use crate::mq::adapters::rabbitmq::RabbitMqAdmin;
use crate::mq::adapters::rocketmq::RocketMqAdmin;
use crate::mq::config::MqAdminConfig;
use crate::mq::port::MessageQueueAdmin;
@ -156,5 +157,12 @@ async fn build_adapter(
let adapter = RocketMqAdmin::new(mqc, launch).await?;
Ok(Arc::new(adapter))
}
MqSystemKindInternal::RabbitMq => {
let launch = agent_launch.ok_or(
"RabbitMQ adapter requires an agent launch spec. The RabbitMQ agent driver is not installed or not configured.",
)?;
let adapter = RabbitMqAdmin::new(mqc, launch).await?;
Ok(Arc::new(adapter))
}
}
}

View File

@ -43,6 +43,62 @@ pub trait MessageQueueAdmin: Send + Sync {
Err("Topic route is not supported by this MQ system".to_string())
}
// ---- Exchanges / bindings (RabbitMQ) ----
async fn list_exchanges(&self, _ns: &NamespaceRef) -> Result<Vec<MqExchangeInfo>, String> {
Err("Exchanges are not supported by this MQ system".to_string())
}
async fn create_exchange(
&self,
_ns: &NamespaceRef,
_name: &str,
_exchange_type: &str,
_durable: bool,
_auto_delete: bool,
) -> Result<(), String> {
Err("Exchanges are not supported by this MQ system".to_string())
}
async fn delete_exchange(&self, _ns: &NamespaceRef, _name: &str) -> Result<(), String> {
Err("Exchanges are not supported by this MQ system".to_string())
}
async fn list_bindings(
&self,
_ns: &NamespaceRef,
_exchange: Option<&str>,
_queue: Option<&str>,
) -> Result<Vec<MqBindingInfo>, String> {
Err("Bindings are not supported by this MQ system".to_string())
}
async fn bind_queue(&self, _ns: &NamespaceRef, _binding: &MqBindingInfo) -> Result<(), String> {
Err("Bindings are not supported by this MQ system".to_string())
}
async fn unbind_queue(&self, _ns: &NamespaceRef, _binding: &MqBindingInfo) -> Result<(), String> {
Err("Bindings are not supported by this MQ system".to_string())
}
// ---- Client connections / channels (RabbitMQ) ----
async fn list_client_connections(&self, _ns: &NamespaceRef) -> Result<Vec<MqClientConnectionInfo>, String> {
Err("Client connections are not supported by this MQ system".to_string())
}
async fn list_client_channels(
&self,
_ns: &NamespaceRef,
_connection: Option<String>,
) -> Result<Vec<MqChannelInfo>, String> {
Err("Client channels are not supported by this MQ system".to_string())
}
async fn close_client_connection(&self, _ns: &NamespaceRef, _name: &str) -> Result<(), String> {
Err("Closing client connections is not supported by this MQ system".to_string())
}
async fn alter_topic_config(&self, _topic: &TopicRef, _configs: serde_json::Value) -> Result<(), String> {
Err("Alter topic config is not supported by this MQ system".to_string())
}
@ -141,6 +197,65 @@ pub trait MessageQueueAdmin: Send + Sync {
/// typed methods do not.
async fn raw_request(&self, req: MqRawRequest) -> Result<MqRawResponse, String>;
// ---- Users & virtual-host permissions (RabbitMQ) ----
async fn list_users(&self) -> Result<Vec<MqUserInfo>, String> {
Err("User management is not supported by this MQ system".to_string())
}
async fn create_user(&self, _name: &str, _password: &str, _tags: Vec<String>) -> Result<(), String> {
Err("User management is not supported by this MQ system".to_string())
}
async fn delete_user(&self, _name: &str) -> Result<(), String> {
Err("User management is not supported by this MQ system".to_string())
}
async fn list_user_permissions(&self, _ns: &NamespaceRef) -> Result<Vec<MqVhostPermission>, String> {
Err("User permissions are not supported by this MQ system".to_string())
}
async fn grant_user_permission(
&self,
_ns: &NamespaceRef,
_user: &str,
_configure: &str,
_write: &str,
_read: &str,
) -> Result<(), String> {
Err("User permissions are not supported by this MQ system".to_string())
}
async fn revoke_user_permission(&self, _ns: &NamespaceRef, _user: &str) -> Result<(), String> {
Err("User permissions are not supported by this MQ system".to_string())
}
// ---- Policies (RabbitMQ) ----
async fn list_policies(&self, _ns: &NamespaceRef) -> Result<Vec<MqPolicyInfo>, String> {
Err("Policies are not supported by this MQ system".to_string())
}
async fn set_policy(&self, _ns: &NamespaceRef, _policy: &MqPolicyInfo) -> Result<(), String> {
Err("Policies are not supported by this MQ system".to_string())
}
async fn delete_policy(&self, _ns: &NamespaceRef, _name: &str) -> Result<(), String> {
Err("Policies are not supported by this MQ system".to_string())
}
// ---- Cluster monitoring (RabbitMQ) ----
/// Broker-wide queue totals and message rates.
async fn get_overview(&self) -> Result<MqOverviewInfo, String> {
Err("Cluster overview is not supported by this MQ system".to_string())
}
/// Cluster node listing with resource usage.
async fn list_nodes(&self) -> Result<Vec<MqNodeInfo>, String> {
Err("Cluster node monitoring is not supported by this MQ system".to_string())
}
// ---- Message production ----
/// Produce a message to a topic. Adapters that do not support message

View File

@ -174,6 +174,211 @@ pub async fn mq_get_topic_internal_stats_core(
adapter.get_topic_internal_stats(&topic).await
}
// ---- Exchanges / bindings (RabbitMQ) ----
pub async fn mq_list_exchanges_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
) -> Result<Vec<MqExchangeInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_exchanges(&ns).await
}
pub async fn mq_create_exchange_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
name: &str,
exchange_type: &str,
durable: bool,
auto_delete: bool,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create exchange").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_exchange(&ns, name, exchange_type, durable, auto_delete).await
}
pub async fn mq_delete_exchange_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
name: &str,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete exchange").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_exchange(&ns, name).await
}
pub async fn mq_list_bindings_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
exchange: Option<String>,
queue: Option<String>,
) -> Result<Vec<MqBindingInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_bindings(&ns, exchange.as_deref(), queue.as_deref()).await
}
pub async fn mq_bind_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
binding: MqBindingInfo,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create binding").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.bind_queue(&ns, &binding).await
}
pub async fn mq_unbind_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
binding: MqBindingInfo,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete binding").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.unbind_queue(&ns, &binding).await
}
// ---- Client connections / channels (RabbitMQ) ----
pub async fn mq_list_client_connections_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
) -> Result<Vec<MqClientConnectionInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_client_connections(&ns).await
}
pub async fn mq_list_client_channels_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
connection: Option<String>,
) -> Result<Vec<MqChannelInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_client_channels(&ns, connection).await
}
pub async fn mq_close_client_connection_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
name: &str,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Close client connection").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.close_client_connection(&ns, name).await
}
// ---- Users & virtual-host permissions (RabbitMQ) ----
pub async fn mq_list_users_core(state: &AppState, conn_id: &str) -> Result<Vec<MqUserInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_users().await
}
pub async fn mq_create_user_core(
state: &AppState,
conn_id: &str,
name: &str,
password: &str,
tags: Vec<String>,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create user").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.create_user(name, password, tags).await
}
pub async fn mq_delete_user_core(state: &AppState, conn_id: &str, name: &str) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete user").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_user(name).await
}
pub async fn mq_list_user_permissions_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
) -> Result<Vec<MqVhostPermission>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_user_permissions(&ns).await
}
pub async fn mq_grant_user_permission_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
user: &str,
configure: &str,
write: &str,
read: &str,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Grant user permission").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.grant_user_permission(&ns, user, configure, write, read).await
}
pub async fn mq_revoke_user_permission_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
user: &str,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Revoke user permission").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.revoke_user_permission(&ns, user).await
}
// ---- Policies (RabbitMQ) ----
pub async fn mq_list_policies_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
) -> Result<Vec<MqPolicyInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_policies(&ns).await
}
pub async fn mq_set_policy_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
policy: MqPolicyInfo,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Set policy").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.set_policy(&ns, &policy).await
}
pub async fn mq_delete_policy_core(
state: &AppState,
conn_id: &str,
ns: NamespaceRef,
name: &str,
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete policy").await?;
let adapter = get_adapter(state, conn_id).await?;
adapter.delete_policy(&ns, name).await
}
// ---- Cluster monitoring (RabbitMQ) ----
pub async fn mq_get_overview_core(state: &AppState, conn_id: &str) -> Result<MqOverviewInfo, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.get_overview().await
}
pub async fn mq_list_nodes_core(state: &AppState, conn_id: &str) -> Result<Vec<MqNodeInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.list_nodes().await
}
// ---- Subscriptions ----
pub async fn mq_list_subscriptions_core(
@ -599,12 +804,13 @@ async fn get_adapter(
state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await
}
/// Resolve the MQ agent launch spec for agent-backed systems (Kafka, RocketMQ).
/// Resolve the MQ agent launch spec for agent-backed systems (Kafka, RocketMQ, RabbitMQ).
/// Returns `None` for native REST systems so the registry skips agent resolution.
pub fn resolve_mq_agent_launch_spec(mqc: &MqAdminConfig, state: &AppState) -> Option<AgentLaunchSpec> {
let agent_key = match mqc.system_kind {
MqSystemKind::Kafka => "kafka",
MqSystemKind::RocketMq => "rocketmq",
MqSystemKind::RabbitMq => "rabbitmq",
_ => return None,
};
let agent_state = state.agent_manager.load_state();
@ -754,6 +960,19 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mutating_exchange_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "_rabbitmq".to_string() };
let err = mq_create_exchange_core(&state, "readonly-mq", ns, "dbx-events", "topic", true, false)
.await
.expect_err("read-only exchange create should fail");
assert!(err.contains("Read-only mode"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mutating_raw_requests_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
@ -774,6 +993,53 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mutating_user_permission_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "orders".to_string() };
let err = mq_create_user_core(&state, "readonly-mq", "dbx-app", "secret", vec![])
.await
.expect_err("read-only user create should fail");
assert!(err.contains("Read-only mode"));
let err =
mq_delete_user_core(&state, "readonly-mq", "dbx-app").await.expect_err("read-only user delete should fail");
assert!(err.contains("Read-only mode"));
let err = mq_grant_user_permission_core(&state, "readonly-mq", ns.clone(), "dbx-app", ".*", ".*", ".*")
.await
.expect_err("read-only permission grant should fail");
assert!(err.contains("Read-only mode"));
let err = mq_revoke_user_permission_core(&state, "readonly-mq", ns, "dbx-app")
.await
.expect_err("read-only permission revoke should fail");
assert!(err.contains("Read-only mode"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mutating_policy_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "orders".to_string() };
let policy =
MqPolicyInfo { name: "dbx-ttl".to_string(), pattern: "^dbx-".to_string(), ..MqPolicyInfo::default() };
let err = mq_set_policy_core(&state, "readonly-mq", ns.clone(), policy)
.await
.expect_err("read-only policy set should fail");
assert!(err.contains("Read-only mode"));
let err = mq_delete_policy_core(&state, "readonly-mq", ns, "dbx-ttl")
.await
.expect_err("read-only policy delete should fail");
assert!(err.contains("Read-only mode"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn peek_messages_rejects_counts_above_service_limit_before_adapter_call() {
let (state, dir) = test_state_with(mq_connection(false)).await;

View File

@ -14,6 +14,8 @@ pub enum MqSystemKind {
Kafka,
#[serde(rename = "rocketmq")]
RocketMq,
#[serde(rename = "rabbitmq")]
RabbitMq,
}
impl MqSystemKind {
@ -22,6 +24,7 @@ impl MqSystemKind {
MqSystemKind::Pulsar => "pulsar",
MqSystemKind::Kafka => "kafka",
MqSystemKind::RocketMq => "rocketmq",
MqSystemKind::RabbitMq => "rabbitmq",
}
}
}
@ -61,6 +64,21 @@ pub struct MqCapabilities {
/// RocketMQ: message trace lookup (requires broker trace topic).
#[serde(default)]
pub supports_message_trace: bool,
/// RabbitMQ: exchange & binding management.
#[serde(default)]
pub supports_exchanges: bool,
/// RabbitMQ: client connection & channel management (list/close).
#[serde(default)]
pub supports_client_connections: bool,
/// RabbitMQ: user & virtual-host permission management.
#[serde(default)]
pub supports_user_permissions: bool,
/// RabbitMQ: policy management (list/set/delete policies per vhost).
#[serde(default)]
pub supports_policies: bool,
/// RabbitMQ: cluster overview & node monitoring via the management API.
#[serde(default)]
pub supports_cluster_monitoring: bool,
}
/// Result of a connectivity test, including the detected server version and how
@ -309,6 +327,10 @@ pub struct TopicInfo {
/// RocketMQ message type from broker topic config (NORMAL, DELAY, FIFO, etc.).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_type: Option<String>,
/// Namespace (RabbitMQ: virtual host) this item belongs to; set on
/// cross-namespace listings such as the RabbitMQ "all vhosts" mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
@ -638,6 +660,218 @@ pub struct MqRawResponse {
pub text: Option<String>,
}
// ---------------------------------------------------------------------------
// Exchange / Binding (RabbitMQ)
// ---------------------------------------------------------------------------
/// A RabbitMQ exchange. Namespaces map to virtual hosts, so the exchange's
/// vhost is normally carried by the `NamespaceRef` passed alongside it; in
/// "all vhosts" listings the per-item vhost is reported via `namespace`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqExchangeInfo {
pub name: String,
/// `direct` | `fanout` | `topic` | `headers`.
#[serde(rename = "type")]
pub exchange_type: String,
#[serde(default)]
pub durable: bool,
#[serde(default)]
pub auto_delete: bool,
/// Internal exchange (`amq.*`); cannot be deleted and is hidden by default in the UI.
#[serde(default)]
pub internal: bool,
/// Virtual host this exchange belongs to, set on all-vhosts listings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
/// A RabbitMQ binding between an exchange (source) and a queue or another
/// exchange (destination).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqBindingInfo {
/// Source exchange name.
pub source: String,
/// Destination queue or exchange name.
pub destination: String,
/// `queue` | `exchange`.
pub destination_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<HashMap<String, serde_json::Value>>,
/// Virtual host this binding belongs to, set on all-vhosts listings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
// ---------------------------------------------------------------------------
// Client connections / channels (RabbitMQ)
// ---------------------------------------------------------------------------
/// A RabbitMQ client connection as reported by the management API. Virtual
/// host scoping is normally carried by the `NamespaceRef` passed alongside
/// it; in "all vhosts" listings the per-item vhost is reported via
/// `namespace`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqClientConnectionInfo {
/// Server-side connection name (`host:port -> host:port`).
pub name: String,
/// Authenticated username.
#[serde(default)]
pub user: String,
#[serde(default)]
pub peer_host: String,
#[serde(default)]
pub peer_port: i32,
/// `running` | `blocked` | `blocking` | ...
#[serde(default)]
pub state: String,
/// Number of channels open on this connection.
#[serde(default)]
pub channels: u32,
/// Receive rate (bytes/s), when the management API reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recv_rate: Option<f64>,
/// Send rate (bytes/s), when the management API reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_rate: Option<f64>,
/// Connection establishment time (epoch milliseconds), when reported.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connected_at: Option<i64>,
/// Virtual host this connection is attached to, set on all-vhosts listings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
/// A RabbitMQ channel as reported by the management API.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqChannelInfo {
/// Channel name (`<connection name> (<channel number>)`).
pub name: String,
/// Name of the connection this channel belongs to, when reported.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_name: Option<String>,
#[serde(default)]
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefetch: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub messages_unacked: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consumer_count: Option<u32>,
/// Virtual host this channel belongs to, set on all-vhosts listings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
// ---------------------------------------------------------------------------
// Users & virtual-host permissions (RabbitMQ)
// ---------------------------------------------------------------------------
/// A RabbitMQ user account as reported by the management API.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqUserInfo {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
}
/// A RabbitMQ user × virtual host permission triple: the `configure` / `write`
/// / `read` regexes scoped to one virtual host.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqVhostPermission {
pub user: String,
pub vhost: String,
pub configure: String,
pub write: String,
pub read: String,
}
// ---------------------------------------------------------------------------
// Policies & cluster monitoring (RabbitMQ)
// ---------------------------------------------------------------------------
/// A RabbitMQ policy as reported by the management API. Unlike exchanges and
/// bindings, policies always carry their virtual host explicitly (`vhost`),
/// including on single-vhost listings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqPolicyInfo {
pub name: String,
#[serde(default)]
pub vhost: String,
/// Regex matching the queues/exchanges this policy applies to.
#[serde(default)]
pub pattern: String,
/// `queues` | `exchanges` | `all`.
#[serde(default, rename = "applyTo")]
pub apply_to: String,
#[serde(default)]
pub priority: i32,
/// Policy key/value pairs (`max-length`, `message-ttl`, ...).
#[serde(default)]
pub definition: HashMap<String, serde_json::Value>,
}
/// Broker-wide queue totals and message rates from the RabbitMQ management
/// API `overview` endpoint. All fields are optional because the agent omits
/// values the broker does not report.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqOverviewInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub messages_ready: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub messages_unacked: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliver_rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ack_rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_queues: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_exchanges: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_connections: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_channels: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_consumers: Option<i64>,
}
/// One RabbitMQ cluster node as reported by the management API.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqNodeInfo {
pub name: String,
#[serde(default)]
pub running: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_used: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_limit: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_free: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fd_used: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fd_total: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sockets_used: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sockets_total: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uptime_ms: Option<i64>,
}
// ---------------------------------------------------------------------------
// Send message (produce)
// ---------------------------------------------------------------------------
@ -666,6 +900,16 @@ pub struct SendMessageRequest {
/// is used.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition: Option<i32>,
/// RabbitMQ: target exchange. When omitted, the agent publishes to the
/// default exchange with the queue name as routing key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exchange: Option<String>,
/// RabbitMQ: routing key used together with `exchange`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing_key: Option<String>,
/// RabbitMQ: namespace hint that maps to the target virtual host.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
/// Result of a successful message production.
@ -697,4 +941,254 @@ mod tests {
.expect("message-id reset position");
assert!(matches!(pos, ResetPosition::MessageId { ledger_id: 5, entry_id: 9 }));
}
#[test]
fn exchange_info_round_trips_agent_camel_case() {
let exchange: super::MqExchangeInfo = serde_json::from_str(
r#"{"name":"dbx-events","type":"topic","durable":true,"autoDelete":false,"internal":false}"#,
)
.expect("exchange info");
assert_eq!(exchange.name, "dbx-events");
assert_eq!(exchange.exchange_type, "topic");
assert!(exchange.durable);
assert!(!exchange.auto_delete);
assert!(!exchange.internal);
let json = serde_json::to_value(&exchange).expect("serialize exchange info");
assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("topic"));
assert_eq!(json.get("autoDelete").and_then(|v| v.as_bool()), Some(false));
}
#[test]
fn binding_info_skips_absent_routing_key_and_arguments() {
let binding: super::MqBindingInfo = serde_json::from_str(
r#"{"source":"dbx-events","destination":"dbx-queue","destinationType":"queue","routingKey":"orders.*"}"#,
)
.expect("binding info");
assert_eq!(binding.source, "dbx-events");
assert_eq!(binding.destination_type, "queue");
assert_eq!(binding.routing_key.as_deref(), Some("orders.*"));
assert!(binding.arguments.is_none());
let json = serde_json::to_value(&binding).expect("serialize binding info");
assert!(json.get("arguments").is_none());
let bare: super::MqBindingInfo =
serde_json::from_str(r#"{"source":"dbx-a","destination":"dbx-b","destinationType":"exchange"}"#)
.expect("binding without routing key");
let json = serde_json::to_value(&bare).expect("serialize bare binding");
assert!(json.get("routingKey").is_none());
assert!(json.get("arguments").is_none());
}
#[test]
fn send_message_request_defaults_new_rabbitmq_fields() {
let req: super::SendMessageRequest =
serde_json::from_str(r#"{"topic":"dbx-queue","payloadBase64":"aGVsbG8="}"#).expect("send request");
assert!(req.exchange.is_none());
assert!(req.routing_key.is_none());
assert!(req.namespace.is_none());
}
#[test]
fn client_connection_info_round_trips_agent_camel_case() {
let conn: super::MqClientConnectionInfo = serde_json::from_str(
r#"{"name":"192.168.1.10:52344 -> 192.168.1.126:5672","user":"jjsd","peerHost":"192.168.1.10","peerPort":52344,"state":"running","channels":3,"recvRate":12.5,"sendRate":34.0,"connectedAt":1710000000000}"#,
)
.expect("client connection info");
assert_eq!(conn.name, "192.168.1.10:52344 -> 192.168.1.126:5672");
assert_eq!(conn.user, "jjsd");
assert_eq!(conn.peer_host, "192.168.1.10");
assert_eq!(conn.peer_port, 52344);
assert_eq!(conn.state, "running");
assert_eq!(conn.channels, 3);
assert_eq!(conn.recv_rate, Some(12.5));
assert_eq!(conn.send_rate, Some(34.0));
assert_eq!(conn.connected_at, Some(1710000000000));
let json = serde_json::to_value(&conn).expect("serialize client connection info");
assert_eq!(json.get("peerHost").and_then(|v| v.as_str()), Some("192.168.1.10"));
assert_eq!(json.get("peerPort").and_then(|v| v.as_i64()), Some(52344));
assert_eq!(json.get("connectedAt").and_then(|v| v.as_i64()), Some(1710000000000));
}
#[test]
fn client_connection_info_skips_absent_optional_fields() {
let conn: super::MqClientConnectionInfo = serde_json::from_str(
r#"{"name":"a:1 -> b:5672","user":"guest","peerHost":"a","peerPort":1,"state":"running","channels":0}"#,
)
.expect("client connection info without rates");
assert!(conn.recv_rate.is_none());
assert!(conn.send_rate.is_none());
assert!(conn.connected_at.is_none());
let json = serde_json::to_value(&conn).expect("serialize client connection info");
assert!(json.get("recvRate").is_none());
assert!(json.get("sendRate").is_none());
assert!(json.get("connectedAt").is_none());
}
#[test]
fn user_info_round_trips_agent_camel_case() {
let user: super::MqUserInfo =
serde_json::from_str(r#"{"name":"dbx-app","tags":["management","policymaker"]}"#).expect("user info");
assert_eq!(user.name, "dbx-app");
assert_eq!(user.tags, vec!["management".to_string(), "policymaker".to_string()]);
// Users without tags default to an empty list.
let bare: super::MqUserInfo = serde_json::from_str(r#"{"name":"dbx-svc"}"#).expect("user without tags");
assert!(bare.tags.is_empty());
let json = serde_json::to_value(&user).expect("serialize user info");
assert_eq!(json.get("name").and_then(|v| v.as_str()), Some("dbx-app"));
assert_eq!(json.get("tags").and_then(|v| v.as_array()).map(|a| a.len()), Some(2));
}
#[test]
fn vhost_permission_round_trips_agent_fields() {
let perm: super::MqVhostPermission = serde_json::from_str(
r#"{"user":"dbx-app","vhost":"orders","configure":".*","write":"dbx-.*","read":".*"}"#,
)
.expect("vhost permission");
assert_eq!(perm.user, "dbx-app");
assert_eq!(perm.vhost, "orders");
assert_eq!(perm.configure, ".*");
assert_eq!(perm.write, "dbx-.*");
assert_eq!(perm.read, ".*");
let json = serde_json::to_value(&perm).expect("serialize vhost permission");
assert_eq!(json.get("vhost").and_then(|v| v.as_str()), Some("orders"));
assert_eq!(json.get("write").and_then(|v| v.as_str()), Some("dbx-.*"));
}
#[test]
fn capabilities_default_user_permissions_off() {
// The older capability fields are not `serde(default)`, so a payload
// from an adapter predating the new flag is simulated by serializing a
// full struct and stripping the new key: it must deserialize with the
// flag off.
let caps = super::MqCapabilities { supports_tenants: true, ..Default::default() };
let mut json = serde_json::to_value(caps).expect("serialize capabilities");
assert_eq!(json.get("supportsUserPermissions").and_then(|v| v.as_bool()), Some(false));
json.as_object_mut().expect("capabilities object").remove("supportsUserPermissions");
let caps: super::MqCapabilities = serde_json::from_value(json).expect("deserialize without the new field");
assert!(!caps.supports_user_permissions);
assert!(caps.supports_tenants);
}
#[test]
fn channel_info_round_trips_agent_camel_case() {
let channel: super::MqChannelInfo = serde_json::from_str(
r#"{"name":"a:1 -> b:5672 (1)","connectionName":"a:1 -> b:5672","state":"running","prefetch":20,"messagesUnacked":7,"consumerCount":2}"#,
)
.expect("channel info");
assert_eq!(channel.name, "a:1 -> b:5672 (1)");
assert_eq!(channel.connection_name.as_deref(), Some("a:1 -> b:5672"));
assert_eq!(channel.state, "running");
assert_eq!(channel.prefetch, Some(20));
assert_eq!(channel.messages_unacked, Some(7));
assert_eq!(channel.consumer_count, Some(2));
let json = serde_json::to_value(&channel).expect("serialize channel info");
assert_eq!(json.get("connectionName").and_then(|v| v.as_str()), Some("a:1 -> b:5672"));
assert_eq!(json.get("messagesUnacked").and_then(|v| v.as_u64()), Some(7));
assert_eq!(json.get("consumerCount").and_then(|v| v.as_u64()), Some(2));
let bare: super::MqChannelInfo =
serde_json::from_str(r#"{"name":"a:1 -> b:5672 (2)","state":"running"}"#).expect("bare channel info");
let json = serde_json::to_value(&bare).expect("serialize bare channel info");
assert!(json.get("connectionName").is_none());
assert!(json.get("prefetch").is_none());
assert!(json.get("messagesUnacked").is_none());
assert!(json.get("consumerCount").is_none());
}
#[test]
fn policy_info_round_trips_agent_fields() {
let policy: super::MqPolicyInfo = serde_json::from_str(
r#"{"name":"dbx-ttl","vhost":"orders","pattern":"^dbx-","applyTo":"queues","priority":5,"definition":{"message-ttl":60000,"max-length":1000}}"#,
)
.expect("policy info");
assert_eq!(policy.name, "dbx-ttl");
assert_eq!(policy.vhost, "orders");
assert_eq!(policy.pattern, "^dbx-");
assert_eq!(policy.apply_to, "queues");
assert_eq!(policy.priority, 5);
assert_eq!(policy.definition.get("message-ttl").and_then(|v| v.as_i64()), Some(60000));
let json = serde_json::to_value(&policy).expect("serialize policy info");
assert_eq!(json.get("applyTo").and_then(|v| v.as_str()), Some("queues"));
assert_eq!(json.get("vhost").and_then(|v| v.as_str()), Some("orders"));
}
#[test]
fn policy_info_defaults_absent_apply_to_and_priority() {
let policy: super::MqPolicyInfo =
serde_json::from_str(r#"{"name":"dbx-ha","vhost":"/","pattern":".*","definition":{"ha-mode":"all"}}"#)
.expect("policy without applyTo/priority");
assert!(policy.apply_to.is_empty());
assert_eq!(policy.priority, 0);
assert_eq!(policy.definition.get("ha-mode").and_then(|v| v.as_str()), Some("all"));
}
#[test]
fn overview_info_skips_absent_optional_fields() {
let overview: super::MqOverviewInfo = serde_json::from_str(
r#"{"messagesReady":12,"messagesUnacked":3,"publishRate":1.5,"deliverRate":2.0,"ackRate":2.0,"totalQueues":4,"totalExchanges":7,"totalConnections":2,"totalChannels":5,"totalConsumers":6}"#,
)
.expect("overview info");
assert_eq!(overview.messages_ready, Some(12));
assert_eq!(overview.publish_rate, Some(1.5));
assert_eq!(overview.total_consumers, Some(6));
let json = serde_json::to_value(&overview).expect("serialize overview info");
assert_eq!(json.get("messagesReady").and_then(|v| v.as_i64()), Some(12));
assert_eq!(json.get("ackRate").and_then(|v| v.as_f64()), Some(2.0));
let bare: super::MqOverviewInfo = serde_json::from_str(r#"{}"#).expect("empty overview");
assert!(bare.messages_ready.is_none());
let json = serde_json::to_value(&bare).expect("serialize empty overview");
assert!(json.get("messagesReady").is_none());
assert!(json.get("totalQueues").is_none());
}
#[test]
fn node_info_round_trips_agent_camel_case() {
let node: super::MqNodeInfo = serde_json::from_str(
r#"{"name":"rabbit@node1","running":true,"memUsed":1024,"memLimit":2048,"diskFree":4096,"fdUsed":10,"fdTotal":100,"socketsUsed":3,"socketsTotal":50,"uptimeMs":1710000000000}"#,
)
.expect("node info");
assert_eq!(node.name, "rabbit@node1");
assert!(node.running);
assert_eq!(node.mem_used, Some(1024));
assert_eq!(node.fd_total, Some(100));
assert_eq!(node.uptime_ms, Some(1710000000000));
let json = serde_json::to_value(&node).expect("serialize node info");
assert_eq!(json.get("memUsed").and_then(|v| v.as_i64()), Some(1024));
assert_eq!(json.get("socketsTotal").and_then(|v| v.as_i64()), Some(50));
let bare: super::MqNodeInfo =
serde_json::from_str(r#"{"name":"rabbit@node2","running":false}"#).expect("bare node info");
assert!(!bare.running);
let json = serde_json::to_value(&bare).expect("serialize bare node info");
assert!(json.get("memUsed").is_none());
assert!(json.get("uptimeMs").is_none());
}
#[test]
fn capabilities_default_policies_and_cluster_monitoring_off() {
// Adapters predating the new flags omit them; deserialization must
// default both to off.
let caps = super::MqCapabilities { supports_tenants: true, ..Default::default() };
let mut json = serde_json::to_value(caps).expect("serialize capabilities");
json.as_object_mut().expect("capabilities object").remove("supportsPolicies");
json.as_object_mut().expect("capabilities object").remove("supportsClusterMonitoring");
let caps: super::MqCapabilities = serde_json::from_value(json).expect("deserialize without the new fields");
assert!(!caps.supports_policies);
assert!(!caps.supports_cluster_monitoring);
assert!(caps.supports_tenants);
}
}

View File

@ -88,6 +88,12 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
.route("/mq/topics/route", post(routes::mq::get_topic_route))
.route("/mq/topics/alter-config", post(routes::mq::alter_topic_config))
.route("/mq/topics/skip-accumulation", post(routes::mq::skip_topic_accumulation))
.route("/mq/exchanges/list", post(routes::mq::list_exchanges))
.route("/mq/exchanges/create", post(routes::mq::create_exchange))
.route("/mq/exchanges/delete", post(routes::mq::delete_exchange))
.route("/mq/bindings/list", post(routes::mq::list_bindings))
.route("/mq/bindings/bind", post(routes::mq::bind_queue))
.route("/mq/bindings/unbind", post(routes::mq::unbind_queue))
.route("/mq/messages/view", post(routes::mq::view_message))
.route("/mq/messages/query-by-key", post(routes::mq::query_messages_by_key))
.route("/mq/messages/query-by-topic", post(routes::mq::query_messages_by_topic))
@ -105,19 +111,33 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
.route("/mq/producers/list", post(routes::mq::list_producers))
.route("/mq/consumers/list", post(routes::mq::list_consumers))
.route("/mq/topics/unload", post(routes::mq::unload_topic))
.route("/mq/client-connections/list", post(routes::mq::list_client_connections))
.route("/mq/client-connections/close", post(routes::mq::close_client_connection))
.route("/mq/channels/list", post(routes::mq::list_client_channels))
.route("/mq/policies/publish-rate", post(routes::mq::set_publish_rate))
.route("/mq/policies/dispatch-rate", post(routes::mq::set_dispatch_rate))
.route("/mq/policies/subscribe-rate", post(routes::mq::set_subscribe_rate))
.route("/mq/policies/backlog-quota", post(routes::mq::set_backlog_quota))
.route("/mq/policies/retention", post(routes::mq::set_retention))
.route("/mq/policies/effective", post(routes::mq::get_effective_policies))
.route("/mq/policies/list", post(routes::mq::list_policies))
.route("/mq/policies/set", post(routes::mq::set_policy))
.route("/mq/policies/delete", post(routes::mq::delete_policy))
.route("/mq/permissions/grant", post(routes::mq::grant_permission))
.route("/mq/permissions/revoke", post(routes::mq::revoke_permission))
.route("/mq/permissions/list", post(routes::mq::list_permissions))
.route("/mq/users/list", post(routes::mq::list_users))
.route("/mq/users/create", post(routes::mq::create_user))
.route("/mq/users/delete", post(routes::mq::delete_user))
.route("/mq/user-permissions/list", post(routes::mq::list_user_permissions))
.route("/mq/user-permissions/grant", post(routes::mq::grant_user_permission))
.route("/mq/user-permissions/revoke", post(routes::mq::revoke_user_permission))
.route("/mq/tokens/issue", post(routes::mq::issue_token))
.route("/mq/tokens/list", post(routes::mq::list_token_records))
.route("/mq/monitoring/backlog", post(routes::mq::get_backlog))
.route("/mq/monitoring/cluster-info", post(routes::mq::get_cluster_info))
.route("/mq/overview", post(routes::mq::get_overview))
.route("/mq/nodes", post(routes::mq::list_nodes))
.route("/mq/raw", post(routes::mq::raw_request))
.route("/mq/send-message", post(routes::mq::send_message))
}

View File

@ -750,6 +750,7 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn save_connections_drops_cached_mq_adapter_for_updated_config() {
let (state, dir) = test_web_state().await;

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能
| PostgreSQL 兼容类型 | openGauss、KingBase、HighGo、Vastbase、CockroachDB、自定义 PostgreSQL | 复用 PostgreSQL 风格连接能力 |
| 文件型数据库 | SQLite、DuckDB、Microsoft Access、RQLite、Turso | 选择本地数据库文件或 HTTP 端点,不填写主机和端口 |
| 时序与边缘数据库 | Cloudflare D1、InfluxDB、IoTDB、TDengine、Databend、QuestDB | 托管 SQLite、时序、IoT 或边缘场景 |
| 分析与消息服务 | Apache Spark、Dremio、Apache Pulsar、Apache Kafka | 用于分布式分析、消息和流管理场景 |
| 分析与消息服务 | Apache Spark、Dremio、Apache Pulsar、Apache Kafka、Apache RocketMQ、RabbitMQ | 用于分布式分析、消息和流管理场景 |
| Agent/JDBC 扩展类型 | H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、OSCAR、XuguDB、Databricks、IRIS、JDBC | 功能覆盖取决于对应驱动路径 |
## 默认端口
@ -83,6 +83,8 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能
| QuestDB | 8812 |
| Apache Pulsar Admin API | 8080 |
| Apache Kafka | 9092 |
| Apache RocketMQ NameServer | 9876 |
| RabbitMQ AMQP | 5672 |
<Callout type="info">SQLite、DuckDB、Access 和 JDBC 在不需要网络端口时,会在连接模型中保存为端口 `0`。</Callout>

View File

@ -17,7 +17,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive
| PostgreSQL-compatible profiles | openGauss, KingBase, HighGo, Vastbase, CockroachDB, custom PostgreSQL | Reuse PostgreSQL-style connection handling where the engine speaks a compatible protocol |
| File-based engines | SQLite, DuckDB, Microsoft Access, RQLite, Turso | Choose a local database file or HTTP endpoint instead of host and port |
| Time-series and edge | Cloudflare D1, InfluxDB, IoTDB, TDengine, Databend, QuestDB | Managed SQLite, time-series, IoT, or edge workloads |
| Analytics and messaging services | Apache Spark, Dremio, Apache Pulsar, Apache Kafka, Apache RocketMQ | Distributed analytics, messaging, and stream-management workloads |
| Analytics and messaging services | Apache Spark, Dremio, Apache Pulsar, Apache Kafka, Apache RocketMQ, RabbitMQ | Distributed analytics, messaging, and stream-management workloads |
| Agent/JDBC-oriented engines | H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, OSCAR, XuguDB, Databricks, IRIS, JDBC | Feature coverage depends on the driver path used by that engine |
## Default Ports
@ -84,6 +84,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive
| Apache Pulsar Admin API | 8080 |
| Apache Kafka | 9092 |
| Apache RocketMQ NameServer | 9876 |
| RabbitMQ AMQP | 5672 |
<Callout type="info">SQLite, DuckDB, Access, and JDBC profiles use port `0` in the saved connection model when a network port is not part of the connection.</Callout>

View File

@ -2,7 +2,7 @@
## 概述
该功能为 DBX 增加了消息队列Message Queue管理能力支持 **Apache Pulsar**、**Apache Kafka****Apache RocketMQ**。三种系统在连接对话框中为独立顶层入口;控制台复用 `MqAdminConsole` 壳层,按 `systemKind` 与 capabilities 展示可用面板。
该功能为 DBX 增加了消息队列Message Queue管理能力支持 **Apache Pulsar**、**Apache Kafka**、**Apache RocketMQ** 与 **RabbitMQ**。四种系统在连接对话框中为独立顶层入口;控制台复用 `MqAdminConsole` 壳层,按 `systemKind` 与 capabilities 展示可用面板。
## 功能特性
@ -421,6 +421,41 @@ docker run -d --name dbx-rocketmq-broker -p 10911:10911 \
apache/rocketmq:5.3.1 sh mqbroker -n host.docker.internal:9876
```
### RabbitMQ 连接示例
```json
{
"db_type": "mq",
"driver_profile": "rabbitmq",
"driver_label": "RabbitMQ",
"external_config": {
"systemKind": "rabbitmq",
"adminUrl": "",
"auth": { "kind": "basic", "username": "guest", "password": "guest" },
"extra": {
"addresses": "127.0.0.1:5672",
"virtualHost": "/"
}
}
}
```
Agent 构建与安装:
```bash
cd agents
./gradlew :rabbitmq:shadowJar
# 将 shadow JAR 安装到 DBX 数据目录 agents/drivers/rabbitmq/agent.jar
```
Docker 快速启动AMQP 5672 + Management 15672仅用于本地验证
```bash
docker run -d --name dbx-rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
```
说明RabbitMQ 适配器将 topic 映射为队列queue支持队列列表/声明/删除、清空队列purge、消费者列表、消息预览basic.get + requeue与发送basic.publishvhost 映射为 namespace可在控制台查看/创建/删除。AMQP 操作按 vhost 透传agent 侧 per-vhost 通道缓存tenant 语义不适用(固定合成 `_rabbitmq`)。
### Kafka 适配器参考
1. 实现 `MessageQueueAdmin` trait

View File

@ -26,6 +26,8 @@ assertIncludes("apps/desktop/src/stores/queryStore.ts", "openMqAdmin", "Query st
const manifest = JSON.parse(read("crates/dbx-core/assets/database-drivers.manifest.json"));
const drivers = Array.isArray(manifest) ? manifest : manifest.drivers;
assert.ok(drivers?.some((driver) => driver.dbType === "mq"), "Driver manifest must include dbType=mq.");
const mqDriver = drivers.find((driver) => driver.dbType === "mq");
assert.ok(mqDriver.driverProfiles?.some((profile) => profile.profile === "rabbitmq"), "MQ driver manifest entry must include a rabbitmq driver profile.");
const mqHttp = read("apps/desktop/src/lib/backend/mq-http.ts");
assert.ok(!mqHttp.includes('post("/mq/'), "MQ HTTP client must not call unprefixed /mq paths.");
@ -35,6 +37,33 @@ assertIncludes("apps/desktop/src/lib/backend/mq-tauri.ts", 'invoke("mq_get_consu
assertIncludes("crates/dbx-web/src/main.rs", '"/mq/consumers/group-config/get"', "dbx-web must register consumer group config get route.");
assertIncludes("src-tauri/src/lib.rs", "mq_get_consumer_group_config", "Tauri must register consumer group config command.");
for (const route of ["exchanges/list", "exchanges/create", "exchanges/delete", "bindings/list", "bindings/bind", "bindings/unbind", "client-connections/list", "client-connections/close", "channels/list"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-http.ts", `post("/api/mq/${route}"`, `MQ HTTP client must call /api/mq/${route} route.`);
assertIncludes("crates/dbx-web/src/main.rs", `"/mq/${route}"`, `dbx-web must register /mq/${route} route.`);
}
for (const command of ["mq_list_exchanges", "mq_create_exchange", "mq_delete_exchange", "mq_list_bindings", "mq_bind", "mq_unbind", "mq_list_client_connections", "mq_list_client_channels", "mq_close_client_connection"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-tauri.ts", `invoke("${command}"`, `MQ Tauri client must invoke ${command} command.`);
assertIncludes("src-tauri/src/lib.rs", command, `Tauri must register ${command} command.`);
}
for (const route of ["users/list", "users/create", "users/delete", "user-permissions/list", "user-permissions/grant", "user-permissions/revoke"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-http.ts", `post("/api/mq/${route}"`, `MQ HTTP client must call /api/mq/${route} route.`);
assertIncludes("crates/dbx-web/src/main.rs", `"/mq/${route}"`, `dbx-web must register /mq/${route} route.`);
}
for (const command of ["mq_list_users", "mq_create_user", "mq_delete_user", "mq_list_user_permissions", "mq_grant_user_permission", "mq_revoke_user_permission"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-tauri.ts", `invoke("${command}"`, `MQ Tauri client must invoke ${command} command.`);
assertIncludes("src-tauri/src/lib.rs", command, `Tauri must register ${command} command.`);
}
for (const route of ["policies/list", "policies/set", "policies/delete", "overview", "nodes"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-http.ts", `post("/api/mq/${route}"`, `MQ HTTP client must call /api/mq/${route} route.`);
assertIncludes("crates/dbx-web/src/main.rs", `"/mq/${route}"`, `dbx-web must register /mq/${route} route.`);
}
for (const command of ["mq_list_policies", "mq_set_policy", "mq_delete_policy", "mq_get_overview", "mq_list_nodes"]) {
assertIncludes("apps/desktop/src/lib/backend/mq-tauri.ts", `invoke("${command}"`, `MQ Tauri client must invoke ${command} command.`);
assertIncludes("src-tauri/src/lib.rs", command, `Tauri must register ${command} command.`);
}
assertIncludes("apps/desktop/src/lib/backend/api.ts", 'mqTestConnection = forward("mqTestConnection")', "MQ frontend calls must use the shared forward() API layer.");
assertIncludes("apps/desktop/src/lib/backend/api.ts", 'mqGetConsumerGroupConfig = forward("mqGetConsumerGroupConfig")', "MQ frontend must forward consumer group config API.");
assertIncludes("apps/desktop/src/components/connection/ConnectionDialog.vue", "mqAdminUrl", "Connection dialog must include MQ admin URL fields.");
@ -51,5 +80,8 @@ for (const panel of ["PoliciesPanel.vue", "PermissionsPanel.vue", "RawApiPanel.v
for (const panel of ["TenantsPanel.vue", "NamespacesPanel.vue", "TopicsPanel.vue", "SubscriptionsPanel.vue"]) {
assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`);
}
for (const panel of ["ExchangesPanel.vue", "SendMessagePanel.vue", "rabbitmq/RabbitMqClientsPanel.vue", "ProducerConsumerPanel.vue"]) {
assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`);
}
console.log("MQ integration checks passed");

View File

@ -176,6 +176,86 @@ pub async fn mq_get_topic_internal_stats(
dbx_core::mq::service::mq_get_topic_internal_stats_core(&state, &connection_id, topic).await
}
// ---- Exchanges ----
#[tauri::command]
pub async fn mq_list_exchanges(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
) -> Result<Vec<dbx_core::mq::MqExchangeInfo>, String> {
dbx_core::mq::service::mq_list_exchanges_core(&state, &connection_id, ns).await
}
#[tauri::command]
pub async fn mq_create_exchange(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
name: String,
exchange_type: String,
durable: bool,
auto_delete: bool,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Create exchange").await?;
dbx_core::mq::service::mq_create_exchange_core(
&state,
&connection_id,
ns,
&name,
&exchange_type,
durable,
auto_delete,
)
.await
}
#[tauri::command]
pub async fn mq_delete_exchange(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
name: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete exchange").await?;
dbx_core::mq::service::mq_delete_exchange_core(&state, &connection_id, ns, &name).await
}
// ---- Bindings ----
#[tauri::command]
pub async fn mq_list_bindings(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
exchange: Option<String>,
queue: Option<String>,
) -> Result<Vec<dbx_core::mq::MqBindingInfo>, String> {
dbx_core::mq::service::mq_list_bindings_core(&state, &connection_id, ns, exchange, queue).await
}
#[tauri::command]
pub async fn mq_bind(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
binding: dbx_core::mq::MqBindingInfo,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Create binding").await?;
dbx_core::mq::service::mq_bind_core(&state, &connection_id, ns, binding).await
}
#[tauri::command]
pub async fn mq_unbind(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
binding: dbx_core::mq::MqBindingInfo,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete binding").await?;
dbx_core::mq::service::mq_unbind_core(&state, &connection_id, ns, binding).await
}
// ---- Subscriptions ----
#[tauri::command]
@ -321,6 +401,38 @@ pub async fn mq_unload_topic(
dbx_core::mq::service::mq_unload_topic_core(&state, &connection_id, topic).await
}
// ---- Client connections / channels (RabbitMQ) ----
#[tauri::command]
pub async fn mq_list_client_connections(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
) -> Result<Vec<dbx_core::mq::MqClientConnectionInfo>, String> {
dbx_core::mq::service::mq_list_client_connections_core(&state, &connection_id, ns).await
}
#[tauri::command]
pub async fn mq_list_client_channels(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
connection: Option<String>,
) -> Result<Vec<dbx_core::mq::MqChannelInfo>, String> {
dbx_core::mq::service::mq_list_client_channels_core(&state, &connection_id, ns, connection).await
}
#[tauri::command]
pub async fn mq_close_client_connection(
state: State<'_, Arc<AppState>>,
connection_id: String,
ns: dbx_core::mq::NamespaceRef,
name: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Close client connection").await?;
dbx_core::mq::service::mq_close_client_connection_core(&state, &connection_id, ns, &name).await
}
// ---- Rate limits / quotas / retention ----
#[tauri::command]
@ -421,6 +533,167 @@ pub async fn mq_list_permissions(
dbx_core::mq::service::mq_list_permissions_core(&state, &connection_id, scope).await
}
// ---- Users / user permissions (RabbitMQ) ----
#[tauri::command]
pub async fn mq_list_users(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<dbx_core::mq::MqUserInfo>, String> {
dbx_core::mq::service::mq_list_users_core(&state, &connection_id).await
}
#[tauri::command]
pub async fn mq_create_user(
state: State<'_, Arc<AppState>>,
connection_id: String,
name: String,
password: String,
tags: Option<Vec<String>>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Create user").await?;
dbx_core::mq::service::mq_create_user_core(&state, &connection_id, &name, &password, tags.unwrap_or_default()).await
}
#[tauri::command]
pub async fn mq_delete_user(
state: State<'_, Arc<AppState>>,
connection_id: String,
name: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete user").await?;
dbx_core::mq::service::mq_delete_user_core(&state, &connection_id, &name).await
}
/// RabbitMQ user permissions live under the synthetic `_rabbitmq` tenant;
/// `*` is the all-vhosts marker and is only meaningful for listings.
fn user_permission_ns(virtual_host: Option<String>, all_vhosts: Option<bool>) -> dbx_core::mq::NamespaceRef {
let namespace = match (all_vhosts.unwrap_or(false), virtual_host) {
(true, _) => "*".to_string(),
(false, Some(vhost)) if !vhost.trim().is_empty() => vhost,
_ => "*".to_string(),
};
dbx_core::mq::NamespaceRef { tenant: "_rabbitmq".to_string(), namespace }
}
#[tauri::command]
pub async fn mq_list_user_permissions(
state: State<'_, Arc<AppState>>,
connection_id: String,
virtual_host: Option<String>,
user: Option<String>,
all_vhosts: Option<bool>,
) -> Result<Vec<dbx_core::mq::MqVhostPermission>, String> {
let ns = user_permission_ns(virtual_host, all_vhosts);
let mut permissions = dbx_core::mq::service::mq_list_user_permissions_core(&state, &connection_id, ns).await?;
if let Some(user) = user {
permissions.retain(|p| p.user == user);
}
Ok(permissions)
}
#[tauri::command]
pub async fn mq_grant_user_permission(
state: State<'_, Arc<AppState>>,
connection_id: String,
user: String,
virtual_host: String,
configure: Option<String>,
write: Option<String>,
read: Option<String>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Grant user permission").await?;
let ns = user_permission_ns(Some(virtual_host), None);
let all = || ".*".to_string();
dbx_core::mq::service::mq_grant_user_permission_core(
&state,
&connection_id,
ns,
&user,
&configure.unwrap_or_else(all),
&write.unwrap_or_else(all),
&read.unwrap_or_else(all),
)
.await
}
#[tauri::command]
pub async fn mq_revoke_user_permission(
state: State<'_, Arc<AppState>>,
connection_id: String,
user: String,
virtual_host: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Revoke user permission").await?;
let ns = user_permission_ns(Some(virtual_host), None);
dbx_core::mq::service::mq_revoke_user_permission_core(&state, &connection_id, ns, &user).await
}
// ---- Policies & cluster monitoring (RabbitMQ) ----
#[tauri::command]
pub async fn mq_list_policies(
state: State<'_, Arc<AppState>>,
connection_id: String,
virtual_host: Option<String>,
all_vhosts: Option<bool>,
) -> Result<Vec<dbx_core::mq::MqPolicyInfo>, String> {
let ns = user_permission_ns(virtual_host, all_vhosts);
dbx_core::mq::service::mq_list_policies_core(&state, &connection_id, ns).await
}
#[tauri::command]
pub async fn mq_set_policy(
state: State<'_, Arc<AppState>>,
connection_id: String,
virtual_host: String,
name: String,
pattern: String,
apply_to: Option<String>,
priority: Option<i32>,
definition: std::collections::HashMap<String, serde_json::Value>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Set policy").await?;
let ns = user_permission_ns(Some(virtual_host.clone()), None);
let policy = dbx_core::mq::MqPolicyInfo {
name,
vhost: virtual_host,
pattern,
apply_to: apply_to.unwrap_or_default(),
priority: priority.unwrap_or(0),
definition,
};
dbx_core::mq::service::mq_set_policy_core(&state, &connection_id, ns, policy).await
}
#[tauri::command]
pub async fn mq_delete_policy(
state: State<'_, Arc<AppState>>,
connection_id: String,
virtual_host: String,
name: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete policy").await?;
let ns = user_permission_ns(Some(virtual_host), None);
dbx_core::mq::service::mq_delete_policy_core(&state, &connection_id, ns, &name).await
}
#[tauri::command]
pub async fn mq_get_overview(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<dbx_core::mq::MqOverviewInfo, String> {
dbx_core::mq::service::mq_get_overview_core(&state, &connection_id).await
}
#[tauri::command]
pub async fn mq_list_nodes(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<dbx_core::mq::MqNodeInfo>, String> {
dbx_core::mq::service::mq_list_nodes_core(&state, &connection_id).await
}
// ---- Client tokens ----
#[tauri::command]
@ -478,6 +751,7 @@ pub async fn mq_alter_topic_config(
topic: dbx_core::mq::TopicRef,
configs: serde_json::Value,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Alter topic config").await?;
dbx_core::mq::service::mq_alter_topic_config_core(&state, &connection_id, topic, configs).await
}
@ -487,6 +761,7 @@ pub async fn mq_skip_topic_accumulation(
connection_id: String,
topic: dbx_core::mq::TopicRef,
) -> Result<serde_json::Value, String> {
ensure_connection_writable(&state, &connection_id, "Skip topic accumulation").await?;
dbx_core::mq::service::mq_skip_topic_accumulation_core(&state, &connection_id, topic).await
}
@ -557,5 +832,6 @@ pub async fn mq_send_message(
connection_id: String,
req: dbx_core::mq::SendMessageRequest,
) -> Result<dbx_core::mq::SendMessageResponse, String> {
ensure_connection_writable(&state, &connection_id, "Send message").await?;
dbx_core::mq::service::mq_send_message_core(&state, &connection_id, req).await
}

View File

@ -1401,6 +1401,18 @@ pub fn run() {
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_get_topic_internal_stats,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_exchanges,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_create_exchange,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_delete_exchange,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_bindings,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_bind,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_unbind,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_subscriptions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_create_subscription,
@ -1427,6 +1439,12 @@ pub fn run() {
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_unload_topic,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_client_connections,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_client_channels,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_close_client_connection,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_set_publish_rate,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_set_dispatch_rate,
@ -1445,6 +1463,28 @@ pub fn run() {
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_permissions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_users,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_create_user,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_delete_user,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_user_permissions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_grant_user_permission,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_revoke_user_permission,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_policies,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_set_policy,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_delete_policy,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_get_overview,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_nodes,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_issue_token,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_token_records,