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