mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(mq): implement Apache Pulsar message queue admin
* feat(mq): implement Apache Pulsar message queue admin - Add complete MQ admin adapter layer with version detection * Pulsar adapter with multi-version API support * OAuth2 token caching and authentication * JWT token signing for Pulsar clients * Transport layer (SSH/Proxy) integration - Implement comprehensive admin operations * Tenant, namespace, topic management * Subscription and consumer/producer monitoring * Rate limits, backlog quotas, retention policies * Permissions and role-based access control * Raw admin API for advanced operations - Add desktop UI components * MQ admin console with tabbed interface * Tenant, namespace, topic panels * Subscription management and cursor operations * Monitoring panel with stats visualization * Policy configuration UI - Add web routes for MQ admin operations - Add secret store support for MQ auth credentials - Add comprehensive test coverage * fix: del local test files * fix(mq): mark message queue as unsupported mcpMode in driver manifest MQ admin connections do not use the MCP query bridge - they use a separate REST admin API. Mark mcpMode as 'unsupported' to align with the test expectations and reflect the actual architecture. Fixes CI test: driver-manifest.test.ts * fix(mq): fix TypeScript errors in MQ components - Fix typo in SubscriptionsPanel.vue: messagformatError(e)Id -> message.messageId - Use formatError() helper in RawApiPanel.vue instead of accessing e?.message - Fix undefined 'tab' reference in ConnectionTree.vue: use config.db_type instead Fixes frontend CI TypeScript check errors. * fix(mcp): add 'mq' to MCP server connection type description The MCP server's connection type description must include all database types from the driver manifest. Add 'mq' to the list to fix the test: 'add connection database type description includes every manifest database type' Fixes: packages/mcp-server/tests/driver-manifest.test.ts * fix(mq): add missing ConnectionConfig fields for Rust compilation - Add gbase_server and keepalive_interval_secs fields to all ConnectionConfig test constructors in mq/config.rs, mq/service.rs, and storage.rs - Import uses_bare_mysql_pool in connection.rs tests module Fixes compilation errors from upstream ConnectionConfig changes. * chore(ui): simplify Pulsar icon to logo-only version Remove text from Pulsar icon (127x30 -> 35x30), keeping only the wave logo symbol for a cleaner, more consistent appearance with other database icons. * fix(mq): return removed pools from drain_connection_pools --------- Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
Generated
+28
@@ -1907,6 +1907,7 @@ dependencies = [
|
||||
"font-kit",
|
||||
"futures",
|
||||
"iana-time-zone",
|
||||
"jsonwebtoken",
|
||||
"log",
|
||||
"mongodb",
|
||||
"mysql_async",
|
||||
@@ -4029,6 +4030,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.0"
|
||||
@@ -7385,6 +7401,18 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="35" height="30" viewBox="0 0 35 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M34.7333 8.16353H26.2455C23.9513 3.68838 21.5234 0.21138 18.2199 0.21138C13.9192 0.21138 11.9007 4.0266 9.94695 7.74283L9.77671 8.06867H0V10.2753H5.69498C7.70544 10.2753 8.11483 10.9641 8.11483 10.9641C6.53807 13.4553 4.6411 15.9177 1.47948 15.9177H0V18.2316H1.49164C7.62032 18.2316 10.2955 13.1749 12.4438 9.11631C14.4138 5.37533 15.7879 3.11095 18.2239 3.11095C20.7532 3.11095 23.4122 8.70797 25.751 13.6492C26.0915 14.3669 26.4401 15.101 26.7927 15.8311C17.8186 16.1198 13.5423 18.2563 10.0645 20.001C7.51899 21.2755 5.50852 22.8552 2.20098 22.8552H0V25.1732H2.18071C6.16517 25.1732 8.64988 23.9235 11.2927 22.5954C14.9529 20.7641 19.1035 18.6894 28.8883 18.6894H34.5428V16.3714H31.3487C31.0852 16.392 30.8218 16.3508 30.5786 16.2477C30.3354 16.1487 30.1165 15.9919 29.9422 15.7898C29.3828 14.6886 28.8356 13.5296 28.3006 12.3706L27.5588 10.828C27.5588 10.828 28.0898 10.2506 29.5571 10.2506H34.7333V8.16353Z" fill="#188FFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -31,6 +31,7 @@ import "@/i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import * as api from "@/lib/api";
|
||||
import { connectionRedactedNameLabel } from "@/lib/connectionPresentation";
|
||||
import { quickConnectionOpenTarget } from "@/lib/connectionOpenTarget";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext";
|
||||
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
|
||||
@@ -729,11 +730,19 @@ async function openConnectionQuery(connectionId: string) {
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection) return;
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
const tabId = queryStore.createTab(connectionId, resolveDefaultDatabase(connection, []));
|
||||
const initialTarget = quickConnectionOpenTarget(connection);
|
||||
if (initialTarget.kind === "mq-admin") {
|
||||
queryStore.openMqAdmin(connectionId);
|
||||
return;
|
||||
}
|
||||
const tabId = queryStore.createTab(connectionId, initialTarget.database);
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
queryStore.updateDatabase(tabId, resolveDefaultDatabase(connection, options));
|
||||
const target = quickConnectionOpenTarget(connection, options);
|
||||
if (target.kind === "query") {
|
||||
queryStore.updateDatabase(tabId, target.database);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
t("connection.connectFailed", {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -25,6 +26,7 @@ import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
|
||||
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation";
|
||||
import { h2ConnectionModeForConfig, h2FileJdbcUrl, h2FilePathFromJdbcUrl, type H2ConnectionMode } from "@/lib/h2Connection";
|
||||
import { isLocalFileTypeDb } from "@/lib/connectionFile";
|
||||
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mqPinnedVersionOptions";
|
||||
import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
|
||||
@@ -37,6 +39,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
|
||||
type DialogStep = "select" | "config";
|
||||
type DbPickerView = "icon" | "list";
|
||||
type ConfigTab = "connection" | "advanced" | "tls" | "transport";
|
||||
type MqTokenSigningMode = "none" | "hs256" | "rs256";
|
||||
type JdbcDriverSelectItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -132,6 +135,7 @@ const defaultForm = (): ConnectionForm => ({
|
||||
redis_key_separator: ":",
|
||||
etcd_endpoints: "",
|
||||
gbase_server: "",
|
||||
external_config: undefined,
|
||||
read_only: false,
|
||||
visible_databases: undefined,
|
||||
});
|
||||
@@ -279,6 +283,24 @@ const dialogStep = ref<DialogStep>("select");
|
||||
const dbPickerView = ref<DbPickerView>("icon");
|
||||
const dbSearchQuery = ref("");
|
||||
const configTab = ref<ConfigTab>("connection");
|
||||
type MqAuthKind = MqAuth["kind"];
|
||||
const mqAdminUrl = ref("http://127.0.0.1:8080");
|
||||
const mqSystemKind = ref<MqSystemKind>("pulsar");
|
||||
const mqAuthKind = ref<MqAuthKind>("none");
|
||||
const mqToken = ref("");
|
||||
const mqBasicUsername = ref("");
|
||||
const mqBasicPassword = ref("");
|
||||
const mqApiKeyHeader = ref("Authorization");
|
||||
const mqApiKeyValue = ref("");
|
||||
const mqOauthIssuerUrl = ref("");
|
||||
const mqOauthClientId = ref("");
|
||||
const mqOauthClientSecret = ref("");
|
||||
const mqOauthAudience = ref("");
|
||||
const mqOauthScope = ref("");
|
||||
const mqTlsSkipVerify = ref(false);
|
||||
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
|
||||
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
|
||||
const mqTokenSigningKey = ref("");
|
||||
|
||||
const colorOptions = [
|
||||
{ value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" },
|
||||
@@ -476,6 +498,7 @@ const driverProfiles: Record<
|
||||
xugu: { type: "xugu", port: 5138, user: "", label: "虚谷 XuguDB", icon: "xugu" },
|
||||
iotdb: { type: "iotdb", port: 6667, user: "root", label: "Apache IoTDB", icon: "iotdb" },
|
||||
etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" },
|
||||
mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", 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" },
|
||||
custom_mysql: {
|
||||
@@ -511,6 +534,104 @@ function selectedProfile() {
|
||||
return driverProfiles[selectedType.value] ?? driverProfiles.mysql;
|
||||
}
|
||||
|
||||
function resetMqFields(config?: Partial<MqAdminConfig>) {
|
||||
mqSystemKind.value = "pulsar";
|
||||
mqAdminUrl.value = config?.adminUrl?.trim() || "http://127.0.0.1:8080";
|
||||
mqTlsSkipVerify.value = !!config?.tlsSkipVerify;
|
||||
mqPinnedVersion.value = pinnedVersionToSelection(config?.pinnedVersion);
|
||||
const auth = (config?.auth || { kind: "none" }) as MqAuth;
|
||||
mqAuthKind.value = auth.kind || "none";
|
||||
mqToken.value = auth.token || "";
|
||||
mqBasicUsername.value = auth.username || "";
|
||||
mqBasicPassword.value = auth.password || "";
|
||||
mqApiKeyHeader.value = auth.header || "Authorization";
|
||||
mqApiKeyValue.value = auth.value || "";
|
||||
mqOauthIssuerUrl.value = auth.issuerUrl || "";
|
||||
mqOauthClientId.value = auth.clientId || "";
|
||||
mqOauthClientSecret.value = auth.clientSecret || "";
|
||||
mqOauthAudience.value = auth.audience || "";
|
||||
mqOauthScope.value = auth.scope || "";
|
||||
const tokenSigning = config?.tokenSigning;
|
||||
mqTokenSigningMode.value = tokenSigning?.algorithm === "hs256" || tokenSigning?.algorithm === "rs256" ? tokenSigning.algorithm : "none";
|
||||
mqTokenSigningKey.value = tokenSigning?.key || "";
|
||||
}
|
||||
|
||||
function hydrateMqFields(value: unknown) {
|
||||
if (!value || typeof value !== "object") {
|
||||
resetMqFields();
|
||||
return;
|
||||
}
|
||||
resetMqFields(value as Partial<MqAdminConfig>);
|
||||
}
|
||||
|
||||
function requireMqField(value: string, message: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error(message);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildMqAuth(): MqAuth {
|
||||
switch (mqAuthKind.value) {
|
||||
case "token":
|
||||
return { kind: "token", token: requireMqField(mqToken.value, "Token auth requires a token") };
|
||||
case "basic":
|
||||
return {
|
||||
kind: "basic",
|
||||
username: requireMqField(mqBasicUsername.value, "Basic auth requires a username"),
|
||||
password: mqBasicPassword.value,
|
||||
};
|
||||
case "apiKey":
|
||||
return {
|
||||
kind: "apiKey",
|
||||
header: requireMqField(mqApiKeyHeader.value, "API key auth requires a header"),
|
||||
value: requireMqField(mqApiKeyValue.value, "API key auth requires a value"),
|
||||
};
|
||||
case "oauth2":
|
||||
return {
|
||||
kind: "oauth2",
|
||||
issuerUrl: requireMqField(mqOauthIssuerUrl.value, "OAuth2 auth requires an issuer URL"),
|
||||
clientId: requireMqField(mqOauthClientId.value, "OAuth2 auth requires a client ID"),
|
||||
clientSecret: requireMqField(mqOauthClientSecret.value, "OAuth2 auth requires a client secret"),
|
||||
audience: mqOauthAudience.value.trim() || undefined,
|
||||
scope: mqOauthScope.value.trim() || undefined,
|
||||
};
|
||||
default:
|
||||
return { kind: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
function buildMqTokenSigning() {
|
||||
if (mqTokenSigningMode.value === "none") return undefined;
|
||||
return {
|
||||
algorithm: mqTokenSigningMode.value,
|
||||
key: requireMqField(mqTokenSigningKey.value, "Broker token signing key is required"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMqAdminConfig(): MqAdminConfig {
|
||||
return {
|
||||
systemKind: "pulsar",
|
||||
adminUrl: requireMqField(mqAdminUrl.value, "MQ Admin URL is required"),
|
||||
auth: buildMqAuth(),
|
||||
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
|
||||
pinnedVersion: selectionToPinnedVersion(mqPinnedVersion.value),
|
||||
tokenSigning: buildMqTokenSigning(),
|
||||
};
|
||||
}
|
||||
|
||||
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(adminUrl);
|
||||
} catch {
|
||||
throw new Error("MQ Admin URL is invalid");
|
||||
}
|
||||
const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8080);
|
||||
config.host = parsed.hostname;
|
||||
config.port = port;
|
||||
config.ssl = parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
function isCustomCompatibleProfile() {
|
||||
return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres";
|
||||
}
|
||||
@@ -547,6 +668,11 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.jdbc_driver_paths = [];
|
||||
jdbcDriverPathsInput.value = "";
|
||||
}
|
||||
if (profile.type === "mq") {
|
||||
resetMqFields();
|
||||
form.value.database = undefined;
|
||||
form.value.connection_string = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +739,11 @@ watch(
|
||||
read_only: config.read_only || false,
|
||||
visible_databases: config.visible_databases,
|
||||
};
|
||||
if (config.db_type === "mq") {
|
||||
hydrateMqFields(config.external_config);
|
||||
} else {
|
||||
resetMqFields();
|
||||
}
|
||||
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
|
||||
customColorInput.value = config.color || "";
|
||||
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
|
||||
@@ -634,6 +765,7 @@ watch(
|
||||
selectedTransportLayerId.value = null;
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
resetMqFields();
|
||||
oceanbaseSubMode.value = "mysql";
|
||||
h2ConnectionMode.value = "file";
|
||||
dialogStep.value = "select";
|
||||
@@ -767,6 +899,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
xugu: "xugu",
|
||||
iotdb: "iotdb",
|
||||
etcd: "etcd",
|
||||
mq: "mq",
|
||||
dm: "dm",
|
||||
h2: "h2",
|
||||
snowflake: "snowflake",
|
||||
@@ -844,6 +977,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "xugu", label: "虚谷 XuguDB" },
|
||||
{ value: "iotdb", label: "Apache IoTDB" },
|
||||
{ value: "etcd", label: "etcd" },
|
||||
{ value: "mq", label: "Apache Pulsar" },
|
||||
{ value: "influxdb", label: "InfluxDB" },
|
||||
{ value: "iris", label: "IRIS" },
|
||||
{ value: "jdbc", label: "JDBC" },
|
||||
@@ -996,6 +1130,7 @@ const testResultMessage = computed(() => {
|
||||
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
|
||||
});
|
||||
const hasRequiredConnectionTarget = computed(() => {
|
||||
if (form.value.db_type === "mq") return !!mqAdminUrl.value.trim();
|
||||
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
|
||||
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
|
||||
});
|
||||
@@ -1121,6 +1256,18 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
}
|
||||
if (!config.one_time) config.one_time = undefined;
|
||||
if (!config.read_only) config.read_only = undefined;
|
||||
if (config.db_type === "mq") {
|
||||
const mqConfig = buildMqAdminConfig();
|
||||
config.external_config = mqConfig;
|
||||
applyMqAdminUrl(config, mqConfig.adminUrl);
|
||||
config.username = "";
|
||||
config.password = "";
|
||||
config.database = undefined;
|
||||
config.connection_string = undefined;
|
||||
config.url_params = "";
|
||||
} else {
|
||||
config.external_config = undefined;
|
||||
}
|
||||
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
|
||||
config.connection_string = undefined;
|
||||
} else if (config.db_type === "mongodb") {
|
||||
@@ -1575,6 +1722,7 @@ function resetForm() {
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
mongoUseUrl.value = false;
|
||||
resetMqFields();
|
||||
oceanbaseSubMode.value = "mysql";
|
||||
jdbcDriverPathsInput.value = "";
|
||||
selectedJdbcDriverPath.value = "";
|
||||
@@ -2439,6 +2587,124 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Message Queue: admin URL and auth -->
|
||||
<template v-else-if="form.db_type === 'mq'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Admin URL</Label>
|
||||
<Input v-model="mqAdminUrl" class="col-span-3" placeholder="http://127.0.0.1:8080" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">System</Label>
|
||||
<div class="col-span-3 h-9 rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground">Apache Pulsar</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Auth</Label>
|
||||
<div class="col-span-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">None</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">Token</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">Basic</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">API Key</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">OAuth2</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="mqAuthKind === 'token'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Token</Label>
|
||||
<Input v-model="mqToken" type="password" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'basic'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.user") }}</Label>
|
||||
<Input v-model="mqBasicUsername" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.password") }}</Label>
|
||||
<Input v-model="mqBasicPassword" type="password" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'apiKey'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Header</Label>
|
||||
<Input v-model="mqApiKeyHeader" class="col-span-3" placeholder="Authorization" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Value</Label>
|
||||
<Input v-model="mqApiKeyValue" type="password" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'oauth2'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Issuer URL</Label>
|
||||
<Input v-model="mqOauthIssuerUrl" class="col-span-3" placeholder="https://issuer.example.com/oauth/token" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Client ID</Label>
|
||||
<Input v-model="mqOauthClientId" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Client Secret</Label>
|
||||
<Input v-model="mqOauthClientSecret" type="password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Audience</Label>
|
||||
<Input v-model="mqOauthAudience" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Scope</Label>
|
||||
<Input v-model="mqOauthScope" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">TLS</Label>
|
||||
<label class="col-span-3 inline-flex items-center gap-2">
|
||||
<input type="checkbox" v-model="mqTlsSkipVerify" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">Skip certificate verification</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Pinned Version</Label>
|
||||
<Select v-model="mqPinnedVersion">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in MQ_PINNED_VERSION_OPTIONS" :key="option.value" :value="option.value">
|
||||
<div class="grid gap-0.5 text-left">
|
||||
<span>{{ option.label }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ option.description }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">Broker Token 签发</Label>
|
||||
<Select v-model="mqTokenSigningMode">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不配置</SelectItem>
|
||||
<SelectItem value="hs256">HS256 SECRET</SelectItem>
|
||||
<SelectItem value="rs256">RS256 PRIVATE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right">签发密钥</Label>
|
||||
<textarea
|
||||
v-model="mqTokenSigningKey"
|
||||
class="col-span-3 min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
:placeholder="mqTokenSigningMode === 'hs256' ? 'Broker SECRET' : '-----BEGIN PRIVATE KEY-----'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">按 Broker 的 jwt.broker.token.mode 选择:SECRET 使用 HS256,PRIVATE 使用 RS256。密钥会走连接 secret 存储。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redis: host, port, user, password, ssl -->
|
||||
<template v-else-if="form.db_type === 'redis'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
|
||||
@@ -76,6 +76,8 @@ const assetIcons: Record<string, string> = {
|
||||
xugu: "xugu.png",
|
||||
iotdb: "iotdb",
|
||||
etcd: "etcd",
|
||||
mq: "pulsar",
|
||||
pulsar: "pulsar",
|
||||
iris: "iris.png",
|
||||
influxdb: "influxdb",
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
|
||||
@@ -950,6 +951,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mq'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :read-only="activeConnection?.read_only ?? false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Objects mode: virtualized database object browser -->
|
||||
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
|
||||
<ObjectBrowser
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import type { TopicRef, TopicInfo, TopicStats, BacklogStats } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqGetBacklog } from "@/lib/api";
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
topic?: TopicInfo;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
interface MetricPoint {
|
||||
time: string;
|
||||
msgRateIn: number;
|
||||
msgRateOut: number;
|
||||
backlogSize: number;
|
||||
msgBacklog: number;
|
||||
consumerLagMs: number;
|
||||
}
|
||||
|
||||
interface PartitionStatsRow {
|
||||
name: string;
|
||||
shortName: string;
|
||||
msgRateIn: number;
|
||||
msgRateOut: number;
|
||||
msgThroughputIn: number;
|
||||
msgThroughputOut: number;
|
||||
backlogSize: number;
|
||||
msgBacklog: number;
|
||||
storageSize: number;
|
||||
producerCount: number;
|
||||
subscriptionCount: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const stats = ref<TopicStats>();
|
||||
const backlog = ref<BacklogStats>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const autoRefresh = ref(true);
|
||||
const refreshInterval = ref(5); // seconds
|
||||
const selectedPartitionName = ref<string>();
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
const history = ref<MetricPoint[]>([]);
|
||||
const MAX_HISTORY_POINTS = 60;
|
||||
|
||||
const partitionRows = computed(() => extractPartitionRows(stats.value?.raw));
|
||||
const selectedPartition = computed(() => partitionRows.value.find((row) => row.name === selectedPartitionName.value) ?? partitionRows.value[0]);
|
||||
const selectedPartitionPublishers = computed(() => arrayObjects(selectedPartition.value?.raw.publishers));
|
||||
const selectedPartitionSubscriptions = computed(() => {
|
||||
const subscriptions = objectRecord(selectedPartition.value?.raw.subscriptions);
|
||||
return Object.entries(subscriptions).map(([name, value]) => {
|
||||
const body = objectRecord(value);
|
||||
return {
|
||||
name,
|
||||
type: stringField(body.type),
|
||||
msgBacklog: numberField(body.msgBacklog) ?? 0,
|
||||
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
||||
consumerCount: arrayObjects(body.consumers).length,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const rateChartOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { top: 0, data: ["In", "Out"] },
|
||||
grid: { left: 48, right: 18, top: 36, bottom: 32 },
|
||||
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
||||
yAxis: { type: "value", name: "msg/s" },
|
||||
series: [
|
||||
{ name: "In", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateIn) },
|
||||
{ name: "Out", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateOut) },
|
||||
],
|
||||
}));
|
||||
|
||||
const backlogChartOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { top: 0, data: ["Messages", "Bytes"] },
|
||||
grid: { left: 56, right: 54, top: 36, bottom: 32 },
|
||||
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
||||
yAxis: [
|
||||
{ type: "value", name: "msg" },
|
||||
{ type: "value", name: "bytes" },
|
||||
],
|
||||
series: [
|
||||
{ name: "Messages", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgBacklog) },
|
||||
{ name: "Bytes", type: "line", smooth: true, showSymbol: false, yAxisIndex: 1, data: history.value.map((point) => point.backlogSize) },
|
||||
],
|
||||
}));
|
||||
|
||||
const latencyChartOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { top: 0, data: ["Consumer lag"] },
|
||||
grid: { left: 56, right: 18, top: 36, bottom: 32 },
|
||||
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
||||
yAxis: { type: "value", name: "ms" },
|
||||
series: [{ name: "Consumer lag", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.consumerLagMs) }],
|
||||
}));
|
||||
|
||||
function getTopicRef(): TopicRef | null {
|
||||
if (!props.topic || !props.tenant || !props.namespace) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
};
|
||||
}
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
return typeof document !== "undefined" && document.hidden;
|
||||
}
|
||||
|
||||
async function loadStats(options: { skipWhenHidden?: boolean } = {}) {
|
||||
if (options.skipWhenHidden && isDocumentHidden()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) {
|
||||
stats.value = undefined;
|
||||
backlog.value = undefined;
|
||||
history.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const [statsData, backlogData] = await Promise.all([mqGetTopicStats(props.connectionId, topicRef), mqGetBacklog(props.connectionId, topicRef, undefined)]);
|
||||
stats.value = statsData;
|
||||
backlog.value = backlogData;
|
||||
appendHistoryPoint(statsData, backlogData);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function appendHistoryPoint(statsData: TopicStats, backlogData: BacklogStats) {
|
||||
const point: MetricPoint = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
msgRateIn: statsData.msgRateIn,
|
||||
msgRateOut: statsData.msgRateOut,
|
||||
backlogSize: statsData.backlogSize,
|
||||
msgBacklog: backlogData.msgBacklog,
|
||||
consumerLagMs: extractConsumerLagMs(statsData.raw),
|
||||
};
|
||||
history.value = [...history.value.slice(-(MAX_HISTORY_POINTS - 1)), point];
|
||||
}
|
||||
|
||||
function extractConsumerLagMs(raw: unknown): number {
|
||||
if (!raw || typeof raw !== "object") return 0;
|
||||
const subscriptions = (raw as { subscriptions?: unknown }).subscriptions;
|
||||
if (!subscriptions || typeof subscriptions !== "object") return 0;
|
||||
const now = Date.now();
|
||||
let maxLag = 0;
|
||||
for (const subscription of Object.values(subscriptions as Record<string, unknown>)) {
|
||||
if (!subscription || typeof subscription !== "object") continue;
|
||||
const data = subscription as Record<string, unknown>;
|
||||
const timestamp = numberField(data.lastAckedTimestamp) ?? numberField(data.lastConsumedTimestamp) ?? numberField(data.lastMarkDeleteAdvancedTimestamp);
|
||||
if (timestamp && timestamp > 0 && timestamp <= now) {
|
||||
maxLag = Math.max(maxLag, now - timestamp);
|
||||
}
|
||||
}
|
||||
return maxLag;
|
||||
}
|
||||
|
||||
function extractPartitionRows(raw: unknown): PartitionStatsRow[] {
|
||||
const root = objectRecord(raw);
|
||||
const partitions = objectRecord(root.partitions);
|
||||
return Object.entries(partitions).map(([name, value]) => {
|
||||
const body = objectRecord(value);
|
||||
return {
|
||||
name,
|
||||
shortName: partitionShortName(name),
|
||||
msgRateIn: numberField(body.msgRateIn) ?? 0,
|
||||
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
||||
msgThroughputIn: numberField(body.msgThroughputIn) ?? 0,
|
||||
msgThroughputOut: numberField(body.msgThroughputOut) ?? 0,
|
||||
backlogSize: numberField(body.backlogSize) ?? 0,
|
||||
msgBacklog: partitionBacklogMessages(body),
|
||||
storageSize: numberField(body.storageSize) ?? 0,
|
||||
producerCount: arrayObjects(body.publishers).length,
|
||||
subscriptionCount: Object.keys(objectRecord(body.subscriptions)).length,
|
||||
raw: body,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function partitionBacklogMessages(body: Record<string, unknown>): number {
|
||||
const direct = numberField(body.msgBacklog);
|
||||
if (direct !== undefined) return direct;
|
||||
return Object.values(objectRecord(body.subscriptions)).reduce<number>((sum, value) => {
|
||||
return sum + (numberField(objectRecord(value).msgBacklog) ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function partitionShortName(name: string): string {
|
||||
const path = name.includes("://") ? name.split("://", 2)[1] || name : name;
|
||||
return path.split("/").slice(-1)[0] || name;
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && !Array.isArray(item)) : [];
|
||||
}
|
||||
|
||||
function numberField(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
if (autoRefresh.value && props.topic && !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 formatBytes(bytes: number): string {
|
||||
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 formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.topic,
|
||||
() => {
|
||||
history.value = [];
|
||||
selectedPartitionName.value = undefined;
|
||||
void loadStats();
|
||||
startAutoRefresh();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(partitionRows, (rows) => {
|
||||
if (!rows.length) {
|
||||
selectedPartitionName.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (!selectedPartitionName.value || !rows.some((row) => row.name === selectedPartitionName.value)) {
|
||||
selectedPartitionName.value = rows[0].name;
|
||||
}
|
||||
});
|
||||
|
||||
watch(autoRefresh, () => {
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
watch(refreshInterval, () => {
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
stopAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="monitoring-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>监控统计</h3>
|
||||
<div class="toolbar-actions">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="autoRefresh" />
|
||||
自动刷新
|
||||
</label>
|
||||
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
|
||||
<option :value="5">5秒</option>
|
||||
<option :value="10">10秒</option>
|
||||
<option :value="30">30秒</option>
|
||||
<option :value="60">60秒</option>
|
||||
</select>
|
||||
<button @click="refreshNow" :disabled="loading" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "立即刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">请先选择一个主题</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !stats" class="panel-loading">加载中...</div>
|
||||
|
||||
<div v-else-if="stats" class="stats-container">
|
||||
<!-- Overview Section -->
|
||||
<div class="stats-section">
|
||||
<h4>消息速率</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">入站速率</div>
|
||||
<div class="stat-value">{{ stats.msgRateIn.toFixed(2) }} msg/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📤</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">出站速率</div>
|
||||
<div class="stat-value">{{ stats.msgRateOut.toFixed(2) }} msg/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⬇️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">入站吞吐量</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.msgThroughputIn) }}/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⬆️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">出站吞吐量</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.msgThroughputOut) }}/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-panel">
|
||||
<h4>速率趋势</h4>
|
||||
<VChart :option="rateChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<h4>积压趋势</h4>
|
||||
<VChart :option="backlogChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<h4>消费延迟</h4>
|
||||
<VChart :option="latencyChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Section -->
|
||||
<div class="stats-section">
|
||||
<h4>存储与积压</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💾</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">存储大小</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.storageSize) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ warning: stats.backlogSize > 10 * 1024 * 1024 }">
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">积压大小</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.backlogSize) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="backlog">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">积压消息数</div>
|
||||
<div class="stat-value">{{ formatNumber(backlog.msgBacklog) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Counters Section -->
|
||||
<div class="stats-section">
|
||||
<h4>消息计数器</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📨</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">已发布消息</div>
|
||||
<div class="stat-value">{{ formatNumber(stats.msgInCounter) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📬</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">已消费消息</div>
|
||||
<div class="stat-value">{{ formatNumber(stats.msgOutCounter) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connections Section -->
|
||||
<div class="stats-section">
|
||||
<h4>连接统计</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">订阅数量</div>
|
||||
<div class="stat-value">{{ stats.subscriptionCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🚀</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">生产者数量</div>
|
||||
<div class="stat-value">{{ stats.producerCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h4>分区明细</h4>
|
||||
<div v-if="partitionRows.length" class="partition-layout">
|
||||
<div class="partition-table-wrap">
|
||||
<table class="partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>入站</th>
|
||||
<th>出站</th>
|
||||
<th>入站吞吐</th>
|
||||
<th>出站吞吐</th>
|
||||
<th>积压消息</th>
|
||||
<th>积压大小</th>
|
||||
<th>生产者</th>
|
||||
<th>订阅</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="partition in partitionRows" :key="partition.name" :class="{ selected: selectedPartition?.name === partition.name }" @click="selectedPartitionName = partition.name">
|
||||
<td :title="partition.name">{{ partition.shortName }}</td>
|
||||
<td>{{ partition.msgRateIn.toFixed(2) }} msg/s</td>
|
||||
<td>{{ partition.msgRateOut.toFixed(2) }} msg/s</td>
|
||||
<td>{{ formatBytes(partition.msgThroughputIn) }}/s</td>
|
||||
<td>{{ formatBytes(partition.msgThroughputOut) }}/s</td>
|
||||
<td>{{ formatNumber(partition.msgBacklog) }}</td>
|
||||
<td>{{ formatBytes(partition.backlogSize) }}</td>
|
||||
<td>{{ partition.producerCount }}</td>
|
||||
<td>{{ partition.subscriptionCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPartition" class="partition-detail">
|
||||
<h5>{{ selectedPartition.shortName }}</h5>
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-title">生产者</div>
|
||||
<table v-if="selectedPartitionPublishers.length" class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>速率</th>
|
||||
<th>地址</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="publisher in selectedPartitionPublishers" :key="String(publisher.producerName ?? publisher.producerId ?? publisher.address)">
|
||||
<td>{{ publisher.producerName || publisher.producerId || "-" }}</td>
|
||||
<td>{{ (numberField(publisher.msgRateIn) ?? 0).toFixed(2) }} msg/s</td>
|
||||
<td>{{ publisher.address || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state compact">暂无生产者</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-title">订阅</div>
|
||||
<table v-if="selectedPartitionSubscriptions.length" class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>积压</th>
|
||||
<th>消费者</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="subscription in selectedPartitionSubscriptions" :key="subscription.name">
|
||||
<td>{{ subscription.name }}</td>
|
||||
<td>{{ subscription.type || "-" }}</td>
|
||||
<td>{{ formatNumber(subscription.msgBacklog) }}</td>
|
||||
<td>{{ subscription.consumerCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state compact">暂无订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">
|
||||
{{ topic.partitioned ? "当前 Broker 响应未返回分区指标" : "非分区主题没有分区明细" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Indicators -->
|
||||
<div class="stats-section">
|
||||
<h4>健康指标</h4>
|
||||
<div class="health-indicators">
|
||||
<div class="health-item">
|
||||
<span class="health-label">消息流动:</span>
|
||||
<span :class="['health-badge', stats.msgRateIn > 0 || stats.msgRateOut > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.msgRateIn > 0 || stats.msgRateOut > 0 ? "活跃" : "空闲" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">积压状态:</span>
|
||||
<span :class="['health-badge', stats.backlogSize < 10 * 1024 * 1024 ? 'healthy' : 'warning']">
|
||||
{{ stats.backlogSize < 10 * 1024 * 1024 ? "正常" : "偏高" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">生产者:</span>
|
||||
<span :class="['health-badge', stats.producerCount > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.producerCount > 0 ? "已连接" : "无连接" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">订阅:</span>
|
||||
<span :class="['health-badge', stats.subscriptionCount > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.subscriptionCount > 0 ? "活跃" : "无订阅" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.monitoring-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-interval {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-interval:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading,
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
min-height: 260px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.chart-panel h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.warning {
|
||||
border-color: var(--color-warning);
|
||||
background: var(--color-warning-alpha);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.health-indicators {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partition-layout {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partition-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.partition-table,
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.partition-table th,
|
||||
.partition-table td,
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.partition-table th,
|
||||
.detail-table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.partition-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.partition-table tbody tr:hover,
|
||||
.partition-table tbody tr.selected {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.partition-detail {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.partition-detail h5 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.health-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.health-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.health-badge.healthy {
|
||||
background: var(--color-success-alpha);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.health-badge.warning {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.health-badge.idle {
|
||||
background: var(--color-background-tertiary);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import type { MqClusterInfo, TopicInfo } from "@/types/mq";
|
||||
import { mqTestConnection } from "@/lib/api";
|
||||
import { mqClusterOptionsFromExtra } from "@/lib/mqTenantForm";
|
||||
import TenantsPanel from "./TenantsPanel.vue";
|
||||
import NamespacesPanel from "./NamespacesPanel.vue";
|
||||
import TopicsPanel from "./TopicsPanel.vue";
|
||||
import SubscriptionsPanel from "./SubscriptionsPanel.vue";
|
||||
import MonitoringPanel from "./MonitoringPanel.vue";
|
||||
import ProducerConsumerPanel from "./ProducerConsumerPanel.vue";
|
||||
import PoliciesPanel from "./PoliciesPanel.vue";
|
||||
import PermissionsPanel from "./PermissionsPanel.vue";
|
||||
import RawApiPanel from "./RawApiPanel.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
initialTenant?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "policies" | "permissions" | "raw";
|
||||
|
||||
// State
|
||||
const activeTab = ref<MqTab>(props.initialTenant ? "namespaces" : "tenants");
|
||||
const selectedTenant = ref<string | undefined>(props.initialTenant);
|
||||
const selectedNamespace = ref<string>();
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const selectedSubscriptionName = ref<string>();
|
||||
const capabilities = ref<MqClusterInfo["capabilities"]>();
|
||||
const clusterInfo = ref<MqClusterInfo>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
|
||||
// Computed
|
||||
const canManageTenants = computed(() => capabilities.value?.supportsTenants ?? true);
|
||||
const canManageNamespaces = computed(() => capabilities.value?.supportsNamespaces ?? true);
|
||||
const canManagePartitionedTopics = computed(() => capabilities.value?.supportsPartitionedTopics ?? true);
|
||||
const canManageSubscriptions = computed(() => capabilities.value?.supportsSubscriptions ?? true);
|
||||
const canCreateSubscription = computed(() => capabilities.value?.supportsCreateSubscription ?? true);
|
||||
const canResetCursor = computed(() => capabilities.value?.supportsResetCursor ?? true);
|
||||
const canSkipMessages = computed(() => capabilities.value?.supportsSkipMessages ?? true);
|
||||
const canClearBacklog = computed(() => capabilities.value?.supportsClearBacklog ?? true);
|
||||
const canPeekMessages = computed(() => capabilities.value?.supportsPeekMessages ?? false);
|
||||
const canExpireMessages = computed(() => capabilities.value?.supportsExpireMessages ?? true);
|
||||
const canManageRateLimits = computed(() => capabilities.value?.supportsRateLimits ?? true);
|
||||
const canManageBacklogQuota = computed(() => capabilities.value?.supportsBacklogQuota ?? true);
|
||||
const canManageRetention = computed(() => capabilities.value?.supportsRetention ?? true);
|
||||
const canManagePolicies = computed(() => {
|
||||
return canManageRateLimits.value || canManageBacklogQuota.value || canManageRetention.value;
|
||||
});
|
||||
const canManagePermissions = computed(() => capabilities.value?.supportsPermissions ?? true);
|
||||
const canUseRawApi = computed(() => capabilities.value?.supportsRawAdminApi ?? true);
|
||||
const clusterOptions = computed(() => mqClusterOptionsFromExtra(clusterInfo.value?.extra));
|
||||
const availableTabs = computed<MqTab[]>(() => {
|
||||
const tabs: MqTab[] = [];
|
||||
if (canManageTenants.value) tabs.push("tenants");
|
||||
if (canManageNamespaces.value) tabs.push("namespaces");
|
||||
tabs.push("topics");
|
||||
if (canManageSubscriptions.value) tabs.push("subscriptions");
|
||||
tabs.push("monitoring");
|
||||
tabs.push("clients");
|
||||
if (canManagePolicies.value) tabs.push("policies");
|
||||
if (canManagePermissions.value) tabs.push("permissions");
|
||||
if (canUseRawApi.value) tabs.push("raw");
|
||||
return tabs;
|
||||
});
|
||||
|
||||
// Methods
|
||||
async function loadClusterInfo() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
clusterInfo.value = await mqTestConnection(props.connectionId);
|
||||
capabilities.value = clusterInfo.value.capabilities;
|
||||
reconcileActiveTab();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTenant(tenant: string) {
|
||||
selectedTenant.value = tenant;
|
||||
selectedNamespace.value = undefined;
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
if (canManageNamespaces.value) {
|
||||
activeTab.value = "namespaces";
|
||||
} else {
|
||||
activeTab.value = "topics";
|
||||
}
|
||||
}
|
||||
|
||||
function handleTenantSelected(tenant: string) {
|
||||
selectTenant(tenant);
|
||||
}
|
||||
|
||||
function handleNamespaceSelected(namespace: string) {
|
||||
selectedNamespace.value = namespace;
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = "topics";
|
||||
}
|
||||
|
||||
function handleNamespaceRolesSelected(namespace: string) {
|
||||
selectedNamespace.value = namespace;
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = canManagePermissions.value ? "permissions" : "namespaces";
|
||||
}
|
||||
|
||||
function handleTopicSelected(topic: TopicInfo) {
|
||||
selectedTopic.value = topic;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = canManageSubscriptions.value ? "subscriptions" : "monitoring";
|
||||
}
|
||||
|
||||
function handleSubscriptionSelected(subscription: string) {
|
||||
selectedSubscriptionName.value = subscription;
|
||||
activeTab.value = "clients";
|
||||
}
|
||||
|
||||
function goToTenantLevel() {
|
||||
selectedNamespace.value = undefined;
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = canManageTenants.value ? "tenants" : firstAvailableTab();
|
||||
}
|
||||
|
||||
function goToNamespaceLevel() {
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = canManageNamespaces.value ? "namespaces" : "topics";
|
||||
}
|
||||
|
||||
function goToTopicLevel() {
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = "topics";
|
||||
}
|
||||
|
||||
function setActiveTab(tab: MqTab) {
|
||||
activeTab.value = availableTabs.value.includes(tab) ? tab : firstAvailableTab();
|
||||
}
|
||||
|
||||
function firstAvailableTab(): MqTab {
|
||||
return availableTabs.value[0] ?? "topics";
|
||||
}
|
||||
|
||||
function reconcileActiveTab() {
|
||||
if (!availableTabs.value.includes(activeTab.value)) {
|
||||
activeTab.value = firstAvailableTab();
|
||||
}
|
||||
}
|
||||
|
||||
watch(availableTabs, reconcileActiveTab);
|
||||
watch(
|
||||
() => props.initialTenant,
|
||||
(tenant) => {
|
||||
if (tenant && tenant !== selectedTenant.value) {
|
||||
selectTenant(tenant);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadClusterInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mq-admin-console">
|
||||
<!-- Top Toolbar -->
|
||||
<div class="mq-toolbar">
|
||||
<div class="mq-breadcrumb">
|
||||
<span v-if="clusterInfo" class="cluster-info"> {{ clusterInfo.systemKind.toUpperCase() }} {{ clusterInfo.serverVersion || "" }} </span>
|
||||
<span v-if="selectedTenant" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedTenant" class="breadcrumb-button" @click="goToTenantLevel" title="查看租户">{{ selectedTenant }}</button>
|
||||
<span v-if="selectedNamespace" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" title="查看命名空间">{{ selectedNamespace }}</button>
|
||||
<span v-if="selectedTopic" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" title="查看主题">{{ selectedTopic.shortName }}</button>
|
||||
</div>
|
||||
<div class="toolbar-status">
|
||||
<span v-if="readOnly" class="readonly-badge">只读</span>
|
||||
<span v-if="error" class="toolbar-error">{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="mq-tabs">
|
||||
<button v-if="canManageTenants" :class="{ active: activeTab === 'tenants' }" @click="setActiveTab('tenants')">租户</button>
|
||||
<button v-if="canManageNamespaces" :class="{ active: activeTab === 'namespaces' }" @click="setActiveTab('namespaces')">命名空间</button>
|
||||
<button :class="{ active: activeTab === 'topics' }" @click="setActiveTab('topics')">主题</button>
|
||||
<button v-if="canManageSubscriptions" :class="{ active: activeTab === 'subscriptions' }" @click="setActiveTab('subscriptions')">订阅</button>
|
||||
<button :class="{ active: activeTab === 'monitoring' }" @click="setActiveTab('monitoring')">监控</button>
|
||||
<button :class="{ active: activeTab === 'clients' }" @click="setActiveTab('clients')">客户端</button>
|
||||
<button v-if="canManagePolicies" :class="{ active: activeTab === 'policies' }" @click="setActiveTab('policies')">策略</button>
|
||||
<button v-if="canManagePermissions" :class="{ active: activeTab === 'permissions' }" @click="setActiveTab('permissions')">权限</button>
|
||||
<button v-if="canUseRawApi" :class="{ active: activeTab === 'raw' }" @click="setActiveTab('raw')">Raw API</button>
|
||||
</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" />
|
||||
<TopicsPanel v-else-if="activeTab === 'topics'" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" :supports-partitioned-topics="canManagePartitionedTopics" @topic-selected="handleTopicSelected" />
|
||||
<SubscriptionsPanel
|
||||
v-else-if="activeTab === 'subscriptions' && canManageSubscriptions"
|
||||
:connection-id="connectionId"
|
||||
:topic="selectedTopic"
|
||||
:tenant="selectedTenant"
|
||||
:namespace="selectedNamespace"
|
||||
:read-only="readOnly"
|
||||
:supports-create-subscription="canCreateSubscription"
|
||||
:supports-reset-cursor="canResetCursor"
|
||||
:supports-skip-messages="canSkipMessages"
|
||||
:supports-clear-backlog="canClearBacklog"
|
||||
:supports-peek-messages="canPeekMessages"
|
||||
:supports-expire-messages="canExpireMessages"
|
||||
@subscription-selected="handleSubscriptionSelected"
|
||||
/>
|
||||
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" />
|
||||
<ProducerConsumerPanel v-else-if="activeTab === 'clients'" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" :selected-subscription="selectedSubscriptionName" />
|
||||
<PoliciesPanel
|
||||
v-else-if="activeTab === 'policies' && canManagePolicies"
|
||||
:connection-id="connectionId"
|
||||
:topic="selectedTopic"
|
||||
:tenant="selectedTenant"
|
||||
:namespace="selectedNamespace"
|
||||
:read-only="readOnly"
|
||||
:supports-rate-limits="canManageRateLimits"
|
||||
:supports-backlog-quota="canManageBacklogQuota"
|
||||
:supports-retention="canManageRetention"
|
||||
/>
|
||||
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" />
|
||||
<RawApiPanel v-else-if="activeTab === 'raw' && canUseRawApi" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :topic="selectedTopic" :read-only="readOnly" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mq-admin-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.mq-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.mq-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cluster-info {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb-button {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-button:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.toolbar-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-warning);
|
||||
border-radius: 4px;
|
||||
color: var(--color-warning);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mq-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-secondary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mq-tabs button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mq-tabs button:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.mq-tabs button.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.mq-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mq-content :deep(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.mq-content :deep(thead th) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mq-content :deep(tbody td) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mq-content :deep(tbody tr:last-child td) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { ref, watch } from "vue";
|
||||
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
|
||||
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
supportsNamespaces: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
namespaceSelected: [namespace: string];
|
||||
namespaceRolesSelected: [namespace: string];
|
||||
}>();
|
||||
|
||||
const namespaces = ref<NamespaceInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const selectedNamespace = ref<string>();
|
||||
|
||||
const formData = ref({
|
||||
namespace: "",
|
||||
});
|
||||
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadNamespaces() {
|
||||
if (!props.tenant) {
|
||||
namespaces.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
namespaces.value = await mqListNamespaces(props.connectionId, props.tenant);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
formData.value = {
|
||||
namespace: "",
|
||||
};
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!formData.value.namespace.trim() || !props.tenant) {
|
||||
error.value = "Namespace name is required";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const ns: NamespaceRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: formData.value.namespace,
|
||||
};
|
||||
const config: NamespaceConfig = {};
|
||||
await mqCreateNamespace(props.connectionId, ns, config);
|
||||
showCreateDialog.value = false;
|
||||
await loadNamespaces();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(ns: NamespaceInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除命名空间 "${ns.namespace}" 吗?此操作不可逆。`)) return;
|
||||
if (!props.tenant) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const nsRef: NamespaceRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: ns.namespace,
|
||||
};
|
||||
await mqDeleteNamespace(props.connectionId, nsRef, false);
|
||||
if (selectedNamespace.value === ns.namespace) {
|
||||
selectedNamespace.value = undefined;
|
||||
}
|
||||
await loadNamespaces();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNamespace(ns: NamespaceInfo) {
|
||||
selectedNamespace.value = ns.namespace;
|
||||
emit("namespaceSelected", ns.namespace);
|
||||
}
|
||||
|
||||
function editNamespaceRoles(ns: NamespaceInfo) {
|
||||
selectedNamespace.value = ns.namespace;
|
||||
emit("namespaceRolesSelected", ns.namespace);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.tenant,
|
||||
() => {
|
||||
selectedNamespace.value = undefined;
|
||||
loadNamespaces();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="namespaces-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>命名空间管理</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant" class="btn-primary">+ 创建命名空间</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!supportsNamespaces" class="panel-placeholder">当前消息队列系统不支持命名空间管理</div>
|
||||
|
||||
<div v-else-if="!tenant" class="panel-placeholder">请先选择一个租户</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !namespaces.length" class="panel-loading">加载中...</div>
|
||||
|
||||
<div v-else class="namespaces-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>租户</th>
|
||||
<th>管理角色</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<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">无</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button @click.stop="editNamespaceRoles(ns)" class="btn-sm">编辑角色</button>
|
||||
<button @click.stop="handleDelete(ns)" :disabled="readOnly" class="btn-sm btn-danger">删除</button>
|
||||
</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>创建命名空间</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>租户</label>
|
||||
<input type="text" :value="tenant" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>命名空间名称*</label>
|
||||
<input v-model="formData.namespace" type="text" placeholder="例如: my-namespace" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.namespaces-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.namespaces-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-background-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.namespace-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Dialog styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 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;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,821 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { AuthAction, MqIssuedToken, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
|
||||
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/api";
|
||||
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mqTokenErrors";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
topic?: TopicInfo;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
|
||||
|
||||
const permissions = ref<PermissionMap>({});
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const notice = ref<string>();
|
||||
const roleName = ref("");
|
||||
const roleNameInput = ref<HTMLInputElement | null>(null);
|
||||
const roleNameError = ref("");
|
||||
const selectedActions = ref<AuthAction[]>(["produce", "consume"]);
|
||||
const actionsError = ref("");
|
||||
const tokenRole = ref("");
|
||||
const tokenActions = ref<AuthAction[]>([]);
|
||||
const tokenExpiresUnlimited = ref(true);
|
||||
const tokenExpiresDays = ref(30);
|
||||
const tokenNote = ref("");
|
||||
const tokenRecords = ref<MqTokenRecord[]>([]);
|
||||
const tokenLoading = ref(false);
|
||||
const tokenError = ref<string>();
|
||||
const tokenIssueError = ref<MqTokenIssueErrorView>();
|
||||
const issuedToken = ref<MqIssuedToken>();
|
||||
const showTokenDialog = ref(false);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
const scope = computed<PolicyScope | null>(() => {
|
||||
if (!props.tenant || !props.namespace) return null;
|
||||
if (props.topic) {
|
||||
return {
|
||||
level: "topic",
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "namespace",
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
};
|
||||
});
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const current = scope.value;
|
||||
if (!current) return "";
|
||||
if (current.level === "topic") {
|
||||
return `${current.tenant}/${current.namespace}/${current.topic}`;
|
||||
}
|
||||
return `${current.tenant}/${current.namespace}`;
|
||||
});
|
||||
|
||||
const permissionRows = computed(() => {
|
||||
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
const current = scope.value;
|
||||
permissions.value = {};
|
||||
notice.value = undefined;
|
||||
error.value = undefined;
|
||||
if (!current) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
permissions.value = await mqListPermissions(props.connectionId, current);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function grantPermission() {
|
||||
if (!guardWritable()) return;
|
||||
const current = scope.value;
|
||||
const role = roleName.value.trim();
|
||||
roleNameError.value = "";
|
||||
actionsError.value = "";
|
||||
if (!current) {
|
||||
error.value = "请先选择命名空间或主题";
|
||||
return;
|
||||
}
|
||||
if (!role) {
|
||||
error.value = undefined;
|
||||
roleNameError.value = "请输入角色名";
|
||||
await nextTick();
|
||||
roleNameInput.value?.focus();
|
||||
return;
|
||||
}
|
||||
if (!selectedActions.value.length) {
|
||||
error.value = undefined;
|
||||
actionsError.value = "请至少选择一个权限动作";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await mqGrantPermission(props.connectionId, current, role, [...selectedActions.value]);
|
||||
notice.value = `已授权 ${role}`;
|
||||
roleName.value = "";
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokePermission(role: string) {
|
||||
if (!guardWritable()) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = "请先选择命名空间或主题";
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定要撤销角色 "${role}" 的权限吗?`)) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await mqRevokePermission(props.connectionId, current, role);
|
||||
notice.value = `已撤销 ${role}`;
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openTokenDialog(role: string) {
|
||||
const row = permissionRows.value.find((item) => item.role === role);
|
||||
tokenRole.value = role;
|
||||
tokenActions.value = row?.actions?.length ? [...row.actions] : ["produce", "consume"];
|
||||
tokenExpiresUnlimited.value = true;
|
||||
tokenExpiresDays.value = 30;
|
||||
tokenNote.value = "";
|
||||
tokenError.value = undefined;
|
||||
tokenIssueError.value = undefined;
|
||||
issuedToken.value = undefined;
|
||||
tokenRecords.value = [];
|
||||
showTokenDialog.value = true;
|
||||
void loadTokenRecords();
|
||||
}
|
||||
|
||||
function closeTokenDialog() {
|
||||
showTokenDialog.value = false;
|
||||
issuedToken.value = undefined;
|
||||
}
|
||||
|
||||
async function loadTokenRecords() {
|
||||
if (!tokenRole.value) return;
|
||||
tokenLoading.value = true;
|
||||
tokenError.value = undefined;
|
||||
try {
|
||||
tokenRecords.value = await mqListTokenRecords(props.connectionId, tokenRole.value);
|
||||
} catch (e: unknown) {
|
||||
if (isTokenRecordListUnavailable(e)) {
|
||||
tokenRecords.value = [];
|
||||
return;
|
||||
}
|
||||
tokenError.value = formatError(e);
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function issueToken() {
|
||||
if (props.readOnly) {
|
||||
tokenError.value = readOnlyMessage;
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
tokenError.value = "请先选择命名空间或主题";
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const subject = tokenRole.value.trim();
|
||||
if (!subject) {
|
||||
tokenError.value = "角色名不能为空";
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
let expiresInSeconds: number | undefined;
|
||||
if (!tokenExpiresUnlimited.value) {
|
||||
const days = Number(tokenExpiresDays.value);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
tokenError.value = "有效期必须大于 0 天";
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
expiresInSeconds = Math.round(days * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
tokenLoading.value = true;
|
||||
tokenError.value = undefined;
|
||||
tokenIssueError.value = undefined;
|
||||
issuedToken.value = undefined;
|
||||
try {
|
||||
issuedToken.value = await mqIssueToken(props.connectionId, {
|
||||
subject,
|
||||
expiresInSeconds,
|
||||
scope: current,
|
||||
actions: [...tokenActions.value],
|
||||
note: tokenNote.value.trim() || undefined,
|
||||
});
|
||||
await loadTokenRecords();
|
||||
} catch (e: unknown) {
|
||||
const formatted = formatMqTokenIssueError(e);
|
||||
if (formatted.kind === "missingSigningKey") {
|
||||
tokenIssueError.value = formatted;
|
||||
tokenError.value = undefined;
|
||||
} else {
|
||||
tokenError.value = formatted.message;
|
||||
}
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIssuedToken() {
|
||||
if (!issuedToken.value?.token) return;
|
||||
await navigator.clipboard?.writeText(issuedToken.value.token);
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "长期";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function shortFingerprint(value: string) {
|
||||
if (value.length <= 22) return value;
|
||||
return `${value.slice(0, 14)}...${value.slice(-8)}`;
|
||||
}
|
||||
|
||||
function isTokenRecordListUnavailable(e: unknown) {
|
||||
const message = e instanceof Error ? formatError(e) : String(e ?? "");
|
||||
return message.includes("/api/mq/tokens/list returned 404");
|
||||
}
|
||||
|
||||
watch(roleName, (value) => {
|
||||
if (value.trim()) {
|
||||
roleNameError.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedActions, (value) => {
|
||||
if (value.length) {
|
||||
actionsError.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace, props.topic?.name, props.topic?.persistent],
|
||||
() => {
|
||||
loadPermissions();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permissions-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h3>权限管理</h3>
|
||||
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
|
||||
</div>
|
||||
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!scope" class="panel-placeholder">请先选择命名空间或主题</div>
|
||||
|
||||
<div v-else class="permissions-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,授权和撤销已禁用。</div>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-if="notice" class="panel-notice">{{ notice }}</div>
|
||||
|
||||
<section class="grant-panel">
|
||||
<h4>授权角色</h4>
|
||||
<div class="grant-row">
|
||||
<label>
|
||||
角色名
|
||||
<input ref="roleNameInput" v-model="roleName" type="text" placeholder="例如: app-producer" :disabled="readOnly" :class="{ invalid: roleNameError }" :aria-invalid="!!roleNameError" />
|
||||
<span v-if="roleNameError" class="field-error">{{ roleNameError }}</span>
|
||||
</label>
|
||||
<div class="actions-group" :class="{ invalid: actionsError }">
|
||||
<span>权限动作</span>
|
||||
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
|
||||
<input v-model="selectedActions" type="checkbox" :value="action" :disabled="readOnly" />
|
||||
{{ action }}
|
||||
</label>
|
||||
<span v-if="actionsError" class="field-error actions-error">{{ actionsError }}</span>
|
||||
</div>
|
||||
<button @click="grantPermission" :disabled="loading || readOnly" class="btn-primary">授权</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="permissions-table">
|
||||
<h4>当前权限</h4>
|
||||
<table v-if="permissionRows.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>角色</th>
|
||||
<th>动作</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in permissionRows" :key="row.role">
|
||||
<td class="role-cell">{{ row.role }}</td>
|
||||
<td>
|
||||
<span class="tag-list">
|
||||
<span v-for="action in row.actions" :key="action" class="tag">{{ action }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button @click="openTokenDialog(row.role)" class="btn-sm">Token</button>
|
||||
<button @click="revokePermission(row.role)" :disabled="readOnly" class="btn-sm btn-danger">撤销</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">暂无权限记录</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="showTokenDialog" class="dialog-overlay" @click="closeTokenDialog">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>客户端 Token: {{ tokenRole }}</h3>
|
||||
<button @click="closeTokenDialog" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div v-if="tokenError" class="panel-error">{{ tokenError }}</div>
|
||||
<div v-if="tokenIssueError" class="token-config-error" role="alert">
|
||||
<strong>{{ tokenIssueError.title }}</strong>
|
||||
<span>{{ tokenIssueError.message }}</span>
|
||||
<small>{{ tokenIssueError.detail }}</small>
|
||||
</div>
|
||||
<div v-if="issuedToken" class="issued-token-box">
|
||||
<div class="token-warning">Token 仅显示一次,请立即复制并保存好。</div>
|
||||
<textarea :value="issuedToken.token" readonly class="token-textarea" />
|
||||
<button class="btn-sm" @click="copyIssuedToken">复制 Token</button>
|
||||
</div>
|
||||
|
||||
<section class="token-section">
|
||||
<h4>签发新 Token</h4>
|
||||
<div class="token-form">
|
||||
<label>
|
||||
角色
|
||||
<input v-model="tokenRole" type="text" />
|
||||
</label>
|
||||
<div class="expiry-control">
|
||||
<label class="checkbox-label expiry-toggle">
|
||||
<input v-model="tokenExpiresUnlimited" type="checkbox" />
|
||||
永不过期
|
||||
</label>
|
||||
<label :class="{ muted: tokenExpiresUnlimited }">
|
||||
有效期(天)
|
||||
<input v-model.number="tokenExpiresDays" type="number" min="1" step="1" :disabled="tokenExpiresUnlimited" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions-group token-actions">
|
||||
<span>权限动作</span>
|
||||
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
|
||||
<input v-model="tokenActions" type="checkbox" :value="action" />
|
||||
{{ action }}
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
备注
|
||||
<input v-model="tokenNote" type="text" placeholder="例如: 发给 rt-erp-server" />
|
||||
</label>
|
||||
<button @click="issueToken" :disabled="tokenLoading || readOnly" class="btn-primary">
|
||||
{{ tokenLoading ? "签发中..." : "生成 Token" }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="token-message">撤销角色权限不会让已签发 JWT 立即失效;需要等待过期,或轮换 Broker 签发密钥。</p>
|
||||
</section>
|
||||
|
||||
<section class="token-section">
|
||||
<h4>签发记录</h4>
|
||||
<table v-if="tokenRecords.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>算法</th>
|
||||
<th>过期</th>
|
||||
<th>指纹</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in tokenRecords" :key="record.id">
|
||||
<td>{{ formatDate(record.createdAt) }}</td>
|
||||
<td>{{ record.algorithm.toUpperCase() }}</td>
|
||||
<td>{{ formatDate(record.expiresAt) }}</td>
|
||||
<td :title="record.tokenFingerprint">{{ shortFingerprint(record.tokenFingerprint) }}</td>
|
||||
<td>{{ record.note || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">{{ tokenLoading ? "加载中..." : "暂无签发记录" }}</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="closeTokenDialog" class="btn-secondary">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permissions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scope-label {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.permissions-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grant-panel,
|
||||
.permissions-table {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.permissions-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grant-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(280px, 2fr) auto;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input.invalid {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 0 2px var(--color-error-bg);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--color-error);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions-group.invalid {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: 6px;
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.actions-group > span {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-error {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.expiry-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.expiry-toggle {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-cell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.panel-placeholder {
|
||||
padding: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-error,
|
||||
.panel-notice,
|
||||
.readonly-hint {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.panel-notice {
|
||||
background: var(--color-success-alpha);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.readonly-hint {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(760px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.token-message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.issued-token-box {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--color-warning);
|
||||
border-radius: 6px;
|
||||
background: var(--color-warning-alpha);
|
||||
}
|
||||
|
||||
.token-config-error {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--color-warning);
|
||||
border-left-width: 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.token-config-error strong {
|
||||
color: var(--color-warning);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.token-config-error small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.token-warning {
|
||||
color: var(--color-warning);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token-textarea {
|
||||
min-height: 96px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.token-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.token-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(120px, 0.6fr);
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.grant-row,
|
||||
.token-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
|
||||
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/api";
|
||||
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mqPolicyForms";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
topic?: TopicInfo;
|
||||
readOnly?: boolean;
|
||||
supportsRateLimits?: boolean;
|
||||
supportsBacklogQuota?: boolean;
|
||||
supportsRetention?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const policies = ref<unknown>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const notice = ref<string>();
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
const defaultForms = defaultMqPolicyForms();
|
||||
|
||||
const publishForm = ref<PublishRate>({ ...defaultForms.publishForm });
|
||||
|
||||
const dispatchForm = ref<DispatchRate>({ ...defaultForms.dispatchForm });
|
||||
|
||||
const subscribeForm = ref<SubscribeRate>({ ...defaultForms.subscribeForm });
|
||||
|
||||
const backlogForm = ref<BacklogQuota>({ ...defaultForms.backlogForm });
|
||||
|
||||
const retentionForm = ref<RetentionPolicy>({ ...defaultForms.retentionForm });
|
||||
|
||||
const scope = computed<PolicyScope | null>(() => {
|
||||
if (!props.tenant || !props.namespace) return null;
|
||||
if (props.topic) {
|
||||
return {
|
||||
level: "topic",
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "namespace",
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
};
|
||||
});
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const current = scope.value;
|
||||
if (!current) return "";
|
||||
if (current.level === "topic") {
|
||||
return `${current.tenant}/${current.namespace}/${current.topic}`;
|
||||
}
|
||||
return `${current.tenant}/${current.namespace}`;
|
||||
});
|
||||
|
||||
const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
const current = scope.value;
|
||||
policies.value = undefined;
|
||||
notice.value = undefined;
|
||||
error.value = undefined;
|
||||
if (!current) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const loaded = await mqGetEffectivePolicies(props.connectionId, current);
|
||||
policies.value = loaded;
|
||||
const hydrated = policyFormsFromEffectivePolicies(loaded, defaultMqPolicyForms());
|
||||
applyPolicyForms(hydrated);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
|
||||
if (!guardWritable()) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = "请先选择命名空间或主题";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await action(current);
|
||||
notice.value = `${kind}已保存`;
|
||||
await loadPolicies();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function savePublishRate() {
|
||||
return applyPolicy("发布限速", (scope) => mqSetPublishRate(props.connectionId, scope, { ...publishForm.value }));
|
||||
}
|
||||
|
||||
function saveDispatchRate() {
|
||||
return applyPolicy("派发限速", (scope) => mqSetDispatchRate(props.connectionId, scope, { ...dispatchForm.value }));
|
||||
}
|
||||
|
||||
function saveSubscribeRate() {
|
||||
return applyPolicy("订阅限速", (scope) => mqSetSubscribeRate(props.connectionId, scope, { ...subscribeForm.value }));
|
||||
}
|
||||
|
||||
function saveBacklogQuota() {
|
||||
return applyPolicy("积压配额", (scope) => mqSetBacklogQuota(props.connectionId, scope, { ...backlogForm.value }));
|
||||
}
|
||||
|
||||
function saveRetention() {
|
||||
return applyPolicy("保留策略", (scope) => mqSetRetention(props.connectionId, scope, { ...retentionForm.value }));
|
||||
}
|
||||
|
||||
function currentPolicyForms() {
|
||||
return {
|
||||
publishForm: { ...publishForm.value },
|
||||
dispatchForm: { ...dispatchForm.value },
|
||||
subscribeForm: { ...subscribeForm.value },
|
||||
backlogForm: { ...backlogForm.value },
|
||||
retentionForm: { ...retentionForm.value },
|
||||
};
|
||||
}
|
||||
|
||||
function applyPolicyForms(forms: ReturnType<typeof currentPolicyForms>) {
|
||||
publishForm.value = forms.publishForm;
|
||||
dispatchForm.value = forms.dispatchForm;
|
||||
subscribeForm.value = forms.subscribeForm;
|
||||
backlogForm.value = forms.backlogForm;
|
||||
retentionForm.value = forms.retentionForm;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace, props.topic?.name, props.topic?.persistent],
|
||||
() => {
|
||||
loadPolicies();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="policies-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h3>策略管理</h3>
|
||||
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
|
||||
</div>
|
||||
<button @click="loadPolicies" :disabled="loading || !scope" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!scope" class="panel-placeholder">请先选择命名空间或主题</div>
|
||||
|
||||
<div v-else class="policies-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,策略编辑已禁用。</div>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-if="notice" class="panel-notice">{{ notice }}</div>
|
||||
|
||||
<div class="policy-grid">
|
||||
<section v-if="supportsRateLimits !== false" class="policy-section">
|
||||
<h4>发布限速</h4>
|
||||
<label>
|
||||
消息数 / 秒
|
||||
<input v-model.number="publishForm.publishThrottlingRateInMsg" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
字节数 / 秒
|
||||
<input v-model.number="publishForm.publishThrottlingRateInByte" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="savePublishRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRateLimits !== false" class="policy-section">
|
||||
<h4>派发限速</h4>
|
||||
<label>
|
||||
消息数 / 周期
|
||||
<input v-model.number="dispatchForm.dispatchThrottlingRateInMsg" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
字节数 / 周期
|
||||
<input v-model.number="dispatchForm.dispatchThrottlingRateInByte" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
周期(秒)
|
||||
<input v-model.number="dispatchForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveDispatchRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRateLimits !== false" class="policy-section">
|
||||
<h4>订阅限速</h4>
|
||||
<label>
|
||||
每消费者消息数 / 周期
|
||||
<input v-model.number="subscribeForm.subscribeThrottlingRatePerConsumer" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
周期(秒)
|
||||
<input v-model.number="subscribeForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveSubscribeRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsBacklogQuota !== false" class="policy-section">
|
||||
<h4>积压配额</h4>
|
||||
<label>
|
||||
大小限制(字节)
|
||||
<input v-model.number="backlogForm.limitSize" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
时间限制(秒)
|
||||
<input v-model.number="backlogForm.limitTime" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
策略
|
||||
<select v-model="backlogForm.policy" :disabled="readOnly">
|
||||
<option value="producer_request_hold">producer_request_hold</option>
|
||||
<option value="producer_exception">producer_exception</option>
|
||||
<option value="consumer_backlog_eviction">consumer_backlog_eviction</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<input v-model="backlogForm.quotaType" type="text" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveBacklogQuota" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRetention !== false" class="policy-section">
|
||||
<h4>消息保留</h4>
|
||||
<label>
|
||||
保留时间(分钟)
|
||||
<input v-model.number="retentionForm.retentionTimeInMinutes" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
保留大小(MB)
|
||||
<input v-model.number="retentionForm.retentionSizeInMb" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveRetention" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="json-section">
|
||||
<h4>当前有效策略</h4>
|
||||
<pre>{{ formattedPolicies }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.policies-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scope-label {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.policies-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.policy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.policy-section,
|
||||
.json-section {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.policy-section h4,
|
||||
.json-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.json-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-notice,
|
||||
.readonly-hint {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-placeholder {
|
||||
margin: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.panel-notice {
|
||||
background: var(--color-success-alpha);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.readonly-hint {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,592 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqListSubscriptions, mqUnloadTopic } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
topic?: TopicInfo;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
selectedSubscription?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface PartitionClientRow {
|
||||
name: string;
|
||||
shortName: string;
|
||||
msgRateIn: number;
|
||||
msgRateOut: number;
|
||||
producerCount: number;
|
||||
subscriptionCount: number;
|
||||
consumerCount: number;
|
||||
producers: ProducerInfo[];
|
||||
subscriptions: SubscriptionInfo[];
|
||||
}
|
||||
|
||||
const producers = ref<ProducerInfo[]>([]);
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const stats = ref<TopicStats>();
|
||||
const selectedSubscription = ref("");
|
||||
const selectedPartitionName = ref("");
|
||||
const loading = ref(false);
|
||||
const unloading = ref(false);
|
||||
const error = ref<string>();
|
||||
|
||||
const topicRef = computed<TopicRef | null>(() => {
|
||||
if (!props.topic || !props.tenant || !props.namespace) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
};
|
||||
});
|
||||
|
||||
const partitionRows = computed(() => extractPartitionClientRows(stats.value?.raw));
|
||||
const selectedPartition = computed(() => {
|
||||
if (!partitionRows.value.length) return undefined;
|
||||
return partitionRows.value.find((row) => row.name === selectedPartitionName.value) ?? partitionRows.value[0];
|
||||
});
|
||||
const subscriptionOptions = computed(() => mergeSubscriptionOptions(subscriptions.value, partitionRows.value));
|
||||
const selectedPartitionSubscription = computed(() => selectedPartition.value?.subscriptions.find((sub) => sub.name === selectedSubscription.value));
|
||||
const aggregateConsumers = computed(() => subscriptions.value.find((sub) => sub.name === selectedSubscription.value)?.consumers ?? []);
|
||||
const displayedProducers = computed(() => selectedPartition.value?.producers ?? producers.value);
|
||||
const displayedConsumers = computed(() => {
|
||||
if (selectedPartition.value) return selectedPartitionSubscription.value?.consumers ?? [];
|
||||
return aggregateConsumers.value;
|
||||
});
|
||||
const selectedScopeLabel = computed(() => selectedPartition.value?.shortName ?? "聚合 topic");
|
||||
|
||||
async function loadRuntimeClients() {
|
||||
const current = topicRef.value;
|
||||
if (!current) {
|
||||
producers.value = [];
|
||||
subscriptions.value = [];
|
||||
stats.value = undefined;
|
||||
selectedSubscription.value = "";
|
||||
selectedPartitionName.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const statsData = await mqGetTopicStats(props.connectionId, current);
|
||||
stats.value = statsData;
|
||||
producers.value = extractProducersFromStats(statsData.raw);
|
||||
const parsedSubscriptions = extractSubscriptionsFromStats(statsData.raw);
|
||||
const partitionSubscriptions = mergeSubscriptionOptions([], extractPartitionClientRows(statsData.raw));
|
||||
subscriptions.value = parsedSubscriptions.length || partitionSubscriptions.length ? mergeSubscriptionOptions(parsedSubscriptions, extractPartitionClientRows(statsData.raw)) : await mqListSubscriptions(props.connectionId, current);
|
||||
syncSelectedSubscription();
|
||||
syncSelectedPartition();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e) || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadTopic() {
|
||||
const current = topicRef.value;
|
||||
if (!current || props.readOnly || unloading.value) return;
|
||||
if (!confirm("确认 unload 当前 topic?活跃生产者和消费者会重新连接。")) return;
|
||||
|
||||
unloading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqUnloadTopic(props.connectionId, current);
|
||||
await loadRuntimeClients();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e) || String(e);
|
||||
} finally {
|
||||
unloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRate(value: number): string {
|
||||
return `${value.toFixed(2)} msg/s`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!value) return "0 B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s", "TB/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 syncSelectedSubscription() {
|
||||
const options = subscriptionOptions.value;
|
||||
if (props.selectedSubscription && options.some((sub) => sub.name === props.selectedSubscription)) {
|
||||
selectedSubscription.value = props.selectedSubscription;
|
||||
} else if (!options.some((sub) => sub.name === selectedSubscription.value)) {
|
||||
selectedSubscription.value = options[0]?.name ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectedPartition() {
|
||||
const rows = partitionRows.value;
|
||||
if (!rows.length) {
|
||||
selectedPartitionName.value = "";
|
||||
return;
|
||||
}
|
||||
const current = rows.find((row) => row.name === selectedPartitionName.value);
|
||||
if (current?.subscriptions.some((sub) => sub.name === selectedSubscription.value)) return;
|
||||
|
||||
const withActiveConsumers = rows.find((row) => row.subscriptions.some((sub) => sub.name === selectedSubscription.value && sub.consumers.length > 0));
|
||||
const withSubscription = rows.find((row) => row.subscriptions.some((sub) => sub.name === selectedSubscription.value));
|
||||
selectedPartitionName.value = (withActiveConsumers ?? withSubscription ?? current ?? rows[0]).name;
|
||||
}
|
||||
|
||||
function syncSelectedSubscriptionForPartition() {
|
||||
const partition = selectedPartition.value;
|
||||
if (!partition) return;
|
||||
if (selectedSubscription.value && partition.subscriptions.some((sub) => sub.name === selectedSubscription.value)) return;
|
||||
|
||||
const active = partition.subscriptions.find((sub) => sub.consumers.length > 0);
|
||||
selectedSubscription.value = (active ?? partition.subscriptions[0])?.name ?? selectedSubscription.value;
|
||||
}
|
||||
|
||||
function extractProducersFromStats(raw: unknown): ProducerInfo[] {
|
||||
const root = objectRecord(raw);
|
||||
const producers = arrayObjects(root.publishers).map(parseProducer);
|
||||
for (const partition of extractPartitionClientRows(raw)) {
|
||||
producers.push(...partition.producers);
|
||||
}
|
||||
return producers;
|
||||
}
|
||||
|
||||
function extractSubscriptionsFromStats(raw: unknown): SubscriptionInfo[] {
|
||||
return Object.entries(objectRecord(objectRecord(raw).subscriptions)).map(([name, body]) => parseSubscription(name, body));
|
||||
}
|
||||
|
||||
function extractPartitionClientRows(raw: unknown): PartitionClientRow[] {
|
||||
return Object.entries(objectRecord(objectRecord(raw).partitions)).map(([name, value]) => {
|
||||
const body = objectRecord(value);
|
||||
const partitionSubscriptions = Object.entries(objectRecord(body.subscriptions)).map(([subscriptionName, subscriptionBody]) => parseSubscription(subscriptionName, subscriptionBody));
|
||||
const partitionProducers = arrayObjects(body.publishers).map(parseProducer);
|
||||
return {
|
||||
name,
|
||||
shortName: partitionShortName(name),
|
||||
msgRateIn: numberField(body.msgRateIn) ?? 0,
|
||||
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
||||
producerCount: partitionProducers.length,
|
||||
subscriptionCount: partitionSubscriptions.length,
|
||||
consumerCount: partitionSubscriptions.reduce((sum, sub) => sum + sub.consumers.length, 0),
|
||||
producers: partitionProducers,
|
||||
subscriptions: partitionSubscriptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeSubscriptionOptions(base: SubscriptionInfo[], partitions: PartitionClientRow[]): SubscriptionInfo[] {
|
||||
const byName = new Map<string, SubscriptionInfo>();
|
||||
for (const sub of base) {
|
||||
byName.set(sub.name, { ...sub, consumers: [...sub.consumers] });
|
||||
}
|
||||
for (const partition of partitions) {
|
||||
for (const sub of partition.subscriptions) {
|
||||
const existing = byName.get(sub.name);
|
||||
if (!existing) {
|
||||
byName.set(sub.name, { ...sub, consumers: [...sub.consumers] });
|
||||
continue;
|
||||
}
|
||||
existing.msgBacklog = Math.max(existing.msgBacklog, sub.msgBacklog);
|
||||
existing.msgRateOut = Math.max(existing.msgRateOut, sub.msgRateOut);
|
||||
existing.msgThroughputOut = Math.max(existing.msgThroughputOut, sub.msgThroughputOut);
|
||||
existing.consumers = [...existing.consumers, ...sub.consumers];
|
||||
if (!existing.subType && sub.subType) existing.subType = sub.subType;
|
||||
}
|
||||
}
|
||||
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function parseSubscription(name: string, value: unknown): SubscriptionInfo {
|
||||
const body = objectRecord(value);
|
||||
return {
|
||||
name,
|
||||
subType: stringField(body.type),
|
||||
msgBacklog: numberField(body.msgBacklog) ?? 0,
|
||||
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
||||
msgThroughputOut: numberField(body.msgThroughputOut) ?? 0,
|
||||
consumers: arrayObjects(body.consumers).map(parseConsumer),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProducer(value: unknown): ProducerInfo {
|
||||
const body = objectRecord(value);
|
||||
return {
|
||||
producerId: numberField(body.producerId) ?? 0,
|
||||
producerName: stringField(body.producerName),
|
||||
msgRateIn: numberField(body.msgRateIn) ?? 0,
|
||||
msgThroughputIn: numberField(body.msgThroughputIn) ?? 0,
|
||||
address: stringField(body.address),
|
||||
clientVersion: stringField(body.clientVersion),
|
||||
};
|
||||
}
|
||||
|
||||
function parseConsumer(value: unknown): ConsumerInfo {
|
||||
const body = objectRecord(value);
|
||||
return {
|
||||
consumerName: stringField(body.consumerName),
|
||||
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
||||
msgThroughputOut: numberField(body.msgThroughputOut) ?? 0,
|
||||
availablePermits: numberField(body.availablePermits) ?? 0,
|
||||
address: stringField(body.address),
|
||||
clientVersion: stringField(body.clientVersion),
|
||||
};
|
||||
}
|
||||
|
||||
function partitionShortName(name: string): string {
|
||||
const path = name.includes("://") ? name.split("://", 2)[1] || name : name;
|
||||
return path.split("/").slice(-1)[0] || name;
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && !Array.isArray(item)) : [];
|
||||
}
|
||||
|
||||
function numberField(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.connectionId, props.topic?.name, props.topic?.partitioned, props.tenant, props.namespace],
|
||||
() => {
|
||||
void loadRuntimeClients();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(selectedSubscription, () => {
|
||||
syncSelectedPartition();
|
||||
});
|
||||
|
||||
watch(selectedPartitionName, () => {
|
||||
syncSelectedSubscriptionForPartition();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selectedSubscription,
|
||||
(subscription) => {
|
||||
if (subscription && subscriptionOptions.value.some((sub) => sub.name === subscription)) {
|
||||
selectedSubscription.value = subscription;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="producer-consumer-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>生产者 / 消费者</h3>
|
||||
<div class="toolbar-actions">
|
||||
<button class="btn-sm danger" :disabled="readOnly || !topic || unloading" @click="unloadTopic">
|
||||
{{ unloading ? "卸载中..." : "Unload topic" }}
|
||||
</button>
|
||||
<button class="btn-sm" :disabled="loading || !topic" @click="loadRuntimeClients">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">请先选择一个 topic</div>
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else class="runtime-content">
|
||||
<section v-if="partitionRows.length" class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>分区客户端</h4>
|
||||
<span>{{ partitionRows.length }} 个分区</span>
|
||||
</div>
|
||||
<table class="runtime-table partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>入站速率</th>
|
||||
<th>出站速率</th>
|
||||
<th>生产者</th>
|
||||
<th>订阅</th>
|
||||
<th>消费者</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="partition in partitionRows" :key="partition.name" :class="{ selected: selectedPartition?.name === partition.name }" @click="selectedPartitionName = partition.name">
|
||||
<td :title="partition.name">{{ partition.shortName }}</td>
|
||||
<td>{{ formatRate(partition.msgRateIn) }}</td>
|
||||
<td>{{ formatRate(partition.msgRateOut) }}</td>
|
||||
<td>{{ partition.producerCount }}</td>
|
||||
<td>{{ partition.subscriptionCount }}</td>
|
||||
<td>{{ partition.consumerCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>活跃生产者</h4>
|
||||
<div class="heading-meta">
|
||||
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
|
||||
<span>{{ displayedProducers.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!displayedProducers.length && !loading" class="empty-state">当前没有活跃生产者</div>
|
||||
<table v-else class="runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>ID</th>
|
||||
<th>地址</th>
|
||||
<th>版本</th>
|
||||
<th>速率</th>
|
||||
<th>吞吐</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="producer in displayedProducers" :key="`${selectedScopeLabel}-${producer.producerId}-${producer.producerName}`">
|
||||
<td>{{ producer.producerName || "-" }}</td>
|
||||
<td>{{ producer.producerId }}</td>
|
||||
<td>{{ producer.address || "-" }}</td>
|
||||
<td>{{ producer.clientVersion || "-" }}</td>
|
||||
<td>{{ formatRate(producer.msgRateIn) }}</td>
|
||||
<td>{{ formatBytes(producer.msgThroughputIn) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>活跃消费者</h4>
|
||||
<div class="subscription-selector">
|
||||
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
|
||||
<span>{{ displayedConsumers.length }}</span>
|
||||
<select v-if="partitionRows.length" v-model="selectedPartitionName" :disabled="loading">
|
||||
<option v-for="partition in partitionRows" :key="partition.name" :value="partition.name">{{ partition.shortName }}</option>
|
||||
</select>
|
||||
<select v-model="selectedSubscription" :disabled="loading || !subscriptionOptions.length">
|
||||
<option v-for="sub in subscriptionOptions" :key="sub.name" :value="sub.name">{{ sub.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!subscriptionOptions.length && !loading" class="empty-state">当前 topic 没有订阅</div>
|
||||
<div v-else-if="!displayedConsumers.length && !loading" class="empty-state">
|
||||
{{ selectedPartition ? "当前分区订阅没有活跃消费者" : "当前订阅没有活跃消费者" }}
|
||||
</div>
|
||||
<table v-else class="runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>地址</th>
|
||||
<th>版本</th>
|
||||
<th>速率</th>
|
||||
<th>吞吐</th>
|
||||
<th>Permits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="consumer in displayedConsumers" :key="`${selectedScopeLabel}-${selectedSubscription}-${consumer.consumerName}-${consumer.address}`">
|
||||
<td>{{ consumer.consumerName || "-" }}</td>
|
||||
<td>{{ consumer.address || "-" }}</td>
|
||||
<td>{{ consumer.clientVersion || "-" }}</td>
|
||||
<td>{{ formatRate(consumer.msgRateOut) }}</td>
|
||||
<td>{{ formatBytes(consumer.msgThroughputOut) }}</td>
|
||||
<td>{{ consumer.availablePermits }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.producer-consumer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm.danger {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.runtime-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-heading h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-heading span {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
max-width: 260px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscription-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.subscription-selector select {
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-table th,
|
||||
.runtime-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.runtime-table th {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runtime-table td {
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.partition-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.partition-table tbody tr:hover,
|
||||
.partition-table tbody tr.selected {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.partition-table tbody tr.selected td:first-child {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.panel-placeholder,
|
||||
.panel-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Message Queue Admin Components
|
||||
|
||||
消息队列管理 UI 组件集。
|
||||
|
||||
## 组件列表
|
||||
|
||||
- **MqAdminConsole.vue** - 主控制台框架
|
||||
- **TenantsPanel.vue** - 租户管理
|
||||
- **NamespacesPanel.vue** - 命名空间管理
|
||||
- **TopicsPanel.vue** - 主题管理
|
||||
- **SubscriptionsPanel.vue** - 订阅管理
|
||||
- **MonitoringPanel.vue** - 监控统计
|
||||
|
||||
## 使用方式
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MqAdminConsole :connection-id="connectionId" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MqAdminConsole from '@/components/mq/MqAdminConsole.vue'
|
||||
|
||||
const connectionId = 'your-mq-connection-id'
|
||||
</script>
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档请参考:`docs/mq-index.md`
|
||||
@@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, ref } from "vue";
|
||||
import type { MqRawResponse, TopicInfo } from "@/types/mq";
|
||||
import { mqRawRequest } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
topic?: TopicInfo;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
|
||||
type RawApiPreset = {
|
||||
label: string;
|
||||
description: string;
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string>;
|
||||
body?: unknown;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
const method = ref("GET");
|
||||
const path = ref("/admin/v2/");
|
||||
const queryText = ref("");
|
||||
const bodyText = ref("");
|
||||
const response = ref<MqRawResponse>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const presetsCollapsed = ref(true);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
const isReadMethod = computed(() => method.value === "GET" || method.value === "HEAD" || method.value === "OPTIONS");
|
||||
const requestDisabled = computed(() => loading.value || (props.readOnly && !isReadMethod.value));
|
||||
const formattedBody = computed(() => JSON.stringify(response.value?.body ?? null, null, 2));
|
||||
const bodyTextareaRows = computed(() => Math.min(24, Math.max(8, bodyText.value.split(/\r?\n/).length + 1)));
|
||||
const topicDomain = computed(() => (props.topic?.persistent === false ? "non-persistent" : "persistent"));
|
||||
const topicPath = computed(() => {
|
||||
if (!props.tenant || !props.namespace || !props.topic) return undefined;
|
||||
return `/admin/v2/${topicDomain.value}/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}/${pathSegment(props.topic.shortName)}`;
|
||||
});
|
||||
const namespacePath = computed(() => {
|
||||
if (!props.tenant || !props.namespace) return undefined;
|
||||
return `/admin/v2/namespaces/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}`;
|
||||
});
|
||||
const tenantPath = computed(() => {
|
||||
if (!props.tenant) return undefined;
|
||||
return `/admin/v2/tenants/${pathSegment(props.tenant)}`;
|
||||
});
|
||||
const presets = computed<RawApiPreset[]>(() => {
|
||||
const tenant = tenantPath.value;
|
||||
const namespace = namespacePath.value;
|
||||
const topic = topicPath.value;
|
||||
return [
|
||||
{
|
||||
label: "Broker 版本",
|
||||
description: "查看 broker 暴露的版本字符串",
|
||||
method: "GET",
|
||||
path: "/admin/v2/brokers/version",
|
||||
},
|
||||
{
|
||||
label: "集群列表",
|
||||
description: "列出 Pulsar 已配置的 clusters",
|
||||
method: "GET",
|
||||
path: "/admin/v2/clusters",
|
||||
},
|
||||
{
|
||||
label: "租户详情",
|
||||
description: "查看 adminRoles / allowedClusters",
|
||||
method: "GET",
|
||||
path: tenant ?? "/admin/v2/tenants/{tenant}",
|
||||
unavailable: !tenant,
|
||||
},
|
||||
{
|
||||
label: "命名空间策略",
|
||||
description: "查看 namespace policies 原始结构",
|
||||
method: "GET",
|
||||
path: namespace ?? "/admin/v2/namespaces/{tenant}/{namespace}",
|
||||
unavailable: !namespace,
|
||||
},
|
||||
{
|
||||
label: "Bundle 列表",
|
||||
description: "查看 namespace bundle 分布",
|
||||
method: "GET",
|
||||
path: namespace ? `${namespace}/bundles` : "/admin/v2/namespaces/{tenant}/{namespace}/bundles",
|
||||
unavailable: !namespace,
|
||||
},
|
||||
{
|
||||
label: "Topic 内部状态",
|
||||
description: "查看 ledger / cursor / backlog 细节",
|
||||
method: "GET",
|
||||
path: topic ? `${topic}/internalStats` : "/admin/v2/persistent/{tenant}/{namespace}/{topic}/internalStats",
|
||||
unavailable: !topic,
|
||||
},
|
||||
{
|
||||
label: "分区明细指标",
|
||||
description: "按分区返回速率、生产者和消费者指标",
|
||||
method: "GET",
|
||||
path: topic ? `${topic}/partitioned-stats` : "/admin/v2/persistent/{tenant}/{namespace}/{topic}/partitioned-stats",
|
||||
query: { perPartition: "true" },
|
||||
unavailable: !topic,
|
||||
},
|
||||
{
|
||||
label: "Schema 最新版本",
|
||||
description: "查看 topic 当前 schema 定义",
|
||||
method: "GET",
|
||||
path: props.tenant && props.namespace && props.topic ? `/admin/v2/schemas/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}/${pathSegment(props.topic.shortName)}/schema` : "/admin/v2/schemas/{tenant}/{namespace}/{topic}/schema",
|
||||
unavailable: !topic,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function pathSegment(value: string) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function parseQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
for (const rawLine of queryText.value.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator === -1) {
|
||||
query[line] = "";
|
||||
continue;
|
||||
}
|
||||
query[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
||||
}
|
||||
return Object.keys(query).length ? query : undefined;
|
||||
}
|
||||
|
||||
function parseBody() {
|
||||
const text = bodyText.value.trim();
|
||||
if (!text) return undefined;
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function formatQuery(query?: Record<string, string>) {
|
||||
return query
|
||||
? Object.entries(query)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n")
|
||||
: "";
|
||||
}
|
||||
|
||||
function formatBody(body?: unknown) {
|
||||
return body === undefined ? "" : JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
function formatJsonBody() {
|
||||
const text = bodyText.value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
bodyText.value = JSON.stringify(JSON.parse(text), null, 2);
|
||||
error.value = undefined;
|
||||
} catch (e: unknown) {
|
||||
error.value = `JSON Body 格式错误: ${formatError(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreset(preset: RawApiPreset) {
|
||||
if (preset.unavailable) return;
|
||||
method.value = preset.method;
|
||||
path.value = preset.path;
|
||||
queryText.value = formatQuery(preset.query);
|
||||
bodyText.value = formatBody(preset.body);
|
||||
response.value = undefined;
|
||||
error.value = undefined;
|
||||
}
|
||||
|
||||
async function executeRequest() {
|
||||
error.value = undefined;
|
||||
response.value = undefined;
|
||||
|
||||
if (props.readOnly && !isReadMethod.value) {
|
||||
error.value = readOnlyMessage;
|
||||
return;
|
||||
}
|
||||
if (!path.value.trim()) {
|
||||
error.value = "请输入请求路径";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
response.value = await mqRawRequest(props.connectionId, {
|
||||
method: method.value,
|
||||
path: path.value.trim(),
|
||||
query: parseQuery(),
|
||||
body: isReadMethod.value ? undefined : parseBody(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raw-api-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>Raw API</h3>
|
||||
<button @click="executeRequest" :disabled="requestDisabled" class="btn-primary">
|
||||
{{ loading ? "请求中..." : "发送请求" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="raw-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,仅允许 GET、HEAD 和 OPTIONS 请求。</div>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<section class="preset-panel" :class="{ collapsed: presetsCollapsed }">
|
||||
<button type="button" class="preset-header" @click="presetsCollapsed = !presetsCollapsed">
|
||||
<h4>常用端点</h4>
|
||||
<span>按当前选择填充请求</span>
|
||||
<span class="preset-toggle">{{ presetsCollapsed ? "展开" : "收起" }}</span>
|
||||
</button>
|
||||
<div v-if="!presetsCollapsed" class="preset-grid">
|
||||
<button v-for="preset in presets" :key="preset.label" type="button" class="preset-button" :disabled="preset.unavailable" @click="applyPreset(preset)">
|
||||
<span class="preset-method">{{ preset.method }}</span>
|
||||
<span class="preset-copy">
|
||||
<strong>{{ preset.label }}</strong>
|
||||
<small>{{ preset.description }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="request-panel">
|
||||
<div class="request-line">
|
||||
<label>
|
||||
方法
|
||||
<select v-model="method">
|
||||
<option v-for="item in methods" :key="item" :value="item">{{ item }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="path-field">
|
||||
路径
|
||||
<input v-model="path" type="text" placeholder="/admin/v2/..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
查询参数
|
||||
<textarea v-model="queryText" rows="4" placeholder="key=value,每行一个"></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span class="field-header">
|
||||
<span>JSON Body</span>
|
||||
<button type="button" class="btn-secondary compact" :disabled="isReadMethod || !bodyText.trim()" @click="formatJsonBody">格式化</button>
|
||||
</span>
|
||||
<textarea v-model="bodyText" class="json-body-textarea" :rows="bodyTextareaRows" placeholder='例如: {"key":"value"}' :disabled="isReadMethod"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="response-panel">
|
||||
<h4>响应</h4>
|
||||
<div v-if="response" class="response-meta">
|
||||
<span>HTTP {{ response.status }}</span>
|
||||
<span v-if="response.text">文本响应</span>
|
||||
</div>
|
||||
<pre v-if="response">{{ response.text || formattedBody }}</pre>
|
||||
<div v-else class="empty-state">尚未发送请求</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.raw-api-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.raw-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.request-panel,
|
||||
.preset-panel,
|
||||
.response-panel {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.request-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.response-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-panel:not(.collapsed) .preset-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preset-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preset-header span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preset-header span:nth-child(2) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.preset-toggle {
|
||||
color: var(--color-primary) !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-button {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-button:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.preset-method {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preset-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preset-copy strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preset-copy small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.request-line {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(260px, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.json-body-textarea {
|
||||
min-height: 180px;
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-error,
|
||||
.readonly-hint {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.readonly-hint {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary.compact {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.request-line {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,848 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { ref, watch } from "vue";
|
||||
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage } from "@/types/mq";
|
||||
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
topic?: TopicInfo;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
supportsCreateSubscription?: boolean;
|
||||
supportsResetCursor?: boolean;
|
||||
supportsSkipMessages?: boolean;
|
||||
supportsClearBacklog?: boolean;
|
||||
supportsPeekMessages?: boolean;
|
||||
supportsExpireMessages?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
subscriptionSelected: [subscription: string];
|
||||
}>();
|
||||
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const showResetDialog = ref(false);
|
||||
const showSkipDialog = ref(false);
|
||||
const showPeekDialog = ref(false);
|
||||
const showExpireDialog = ref(false);
|
||||
const selectedSub = ref<SubscriptionInfo>();
|
||||
const peekedMessages = ref<PeekedMessage[]>([]);
|
||||
const peekLoading = ref(false);
|
||||
const peekCount = ref(5);
|
||||
|
||||
const formData = ref({
|
||||
subName: "",
|
||||
startFrom: "latest" as "earliest" | "latest",
|
||||
});
|
||||
|
||||
const resetFormData = ref({
|
||||
position: "latest" as "earliest" | "latest" | "timestamp",
|
||||
timestampMs: Date.now(),
|
||||
});
|
||||
|
||||
const skipFormData = ref({
|
||||
mode: "count" as "count" | "all",
|
||||
count: 100,
|
||||
});
|
||||
|
||||
const expireSeconds = ref(3600);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTopicRef(): TopicRef | null {
|
||||
if (!props.topic || !props.tenant || !props.namespace) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) {
|
||||
subscriptions.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topicRef);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
formData.value = {
|
||||
subName: "",
|
||||
startFrom: "latest",
|
||||
};
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
function openResetDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
selectedSub.value = sub;
|
||||
resetFormData.value = {
|
||||
position: "latest",
|
||||
timestampMs: Date.now(),
|
||||
};
|
||||
showResetDialog.value = true;
|
||||
}
|
||||
|
||||
function openSkipDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
selectedSub.value = sub;
|
||||
skipFormData.value = {
|
||||
mode: "count",
|
||||
count: 100,
|
||||
};
|
||||
showSkipDialog.value = true;
|
||||
}
|
||||
|
||||
async function openPeekDialog(sub: SubscriptionInfo) {
|
||||
selectedSub.value = sub;
|
||||
peekCount.value = 5;
|
||||
peekedMessages.value = [];
|
||||
showPeekDialog.value = true;
|
||||
await handlePeekMessages();
|
||||
}
|
||||
|
||||
function openExpireDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
selectedSub.value = sub;
|
||||
expireSeconds.value = 3600;
|
||||
showExpireDialog.value = true;
|
||||
}
|
||||
|
||||
function selectSubscription(sub: SubscriptionInfo) {
|
||||
selectedSub.value = sub;
|
||||
emit("subscriptionSelected", sub.name);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!formData.value.subName.trim() || !topicRef) {
|
||||
error.value = "Subscription name is required";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const pos: ResetPosition = { kind: formData.value.startFrom };
|
||||
await mqCreateSubscription(props.connectionId, topicRef, formData.value.subName, pos);
|
||||
showCreateDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除订阅 "${sub.name}" 吗?此操作不可逆。`)) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeleteSubscription(props.connectionId, topicRef, sub.name, false);
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetCursor() {
|
||||
if (!guardWritable()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
let pos: ResetPosition;
|
||||
if (resetFormData.value.position === "timestamp") {
|
||||
pos = { kind: "timestamp", timestampMs: resetFormData.value.timestampMs };
|
||||
} else {
|
||||
pos = { kind: resetFormData.value.position };
|
||||
}
|
||||
await mqResetCursor(props.connectionId, topicRef, selectedSub.value.name, pos);
|
||||
showResetDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkipMessages() {
|
||||
if (!guardWritable()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const count: SkipCount = skipFormData.value.mode === "all" ? { kind: "all" } : { kind: "count", count: skipFormData.value.count };
|
||||
await mqSkipMessages(props.connectionId, topicRef, selectedSub.value.name, count);
|
||||
showSkipDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要清空订阅 "${sub.name}" 的所有积压消息吗?`)) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqClearBacklog(props.connectionId, topicRef, sub.name);
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePeekMessages() {
|
||||
const topicRef = getTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
const count = Math.max(1, Math.min(100, Number(peekCount.value) || 1));
|
||||
peekCount.value = count;
|
||||
peekLoading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
peekedMessages.value = await mqPeekMessages(props.connectionId, topicRef, selectedSub.value.name, count);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
peekLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExpireMessages() {
|
||||
if (!guardWritable()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqExpireMessages(props.connectionId, topicRef, selectedSub.value.name, expireSeconds.value);
|
||||
showExpireDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.topic,
|
||||
() => {
|
||||
selectedSub.value = undefined;
|
||||
loadSubscriptions();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="subscriptions-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>订阅管理</h3>
|
||||
<button v-if="supportsCreateSubscription !== false" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ 创建订阅</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">请先选择一个主题</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !subscriptions.length" class="panel-loading">加载中...</div>
|
||||
|
||||
<div v-else-if="!subscriptions.length" class="panel-placeholder">该主题暂无订阅</div>
|
||||
|
||||
<div v-else class="subscriptions-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订阅名称</th>
|
||||
<th>类型</th>
|
||||
<th>积压消息</th>
|
||||
<th>消费速率</th>
|
||||
<th>消费者</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sub in subscriptions" :key="sub.name" :class="{ selected: selectedSub?.name === sub.name }" @click="selectSubscription(sub)">
|
||||
<td class="sub-name">{{ sub.name }}</td>
|
||||
<td>
|
||||
<span class="badge">{{ sub.subType }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
|
||||
{{ sub.msgBacklog.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ sub.msgRateOut.toFixed(2) }} msg/s</td>
|
||||
<td>{{ sub.consumers.length }} 个</td>
|
||||
<td class="actions">
|
||||
<button v-if="supportsResetCursor !== false" @click.stop="openResetDialog(sub)" :disabled="readOnly" class="btn-sm">重置游标</button>
|
||||
<button v-if="supportsSkipMessages !== false" @click.stop="openSkipDialog(sub)" :disabled="readOnly" class="btn-sm">跳过消息</button>
|
||||
<button v-if="supportsClearBacklog !== false" @click.stop="handleClearBacklog(sub)" :disabled="readOnly" class="btn-sm">清空积压</button>
|
||||
<button v-if="supportsPeekMessages" @click.stop="openPeekDialog(sub)" class="btn-sm">Peek</button>
|
||||
<button v-if="supportsExpireMessages !== false" @click.stop="openExpireDialog(sub)" :disabled="readOnly" class="btn-sm">过期消息</button>
|
||||
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">删除</button>
|
||||
</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>创建订阅</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<input type="text" :value="topic?.shortName" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>订阅名称*</label>
|
||||
<input v-model="formData.subName" type="text" placeholder="例如: my-subscription" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>起始位置</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="formData.startFrom" value="earliest" :disabled="readOnly" />
|
||||
从最早消息开始(Earliest)
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="formData.startFrom" value="latest" :disabled="readOnly" />
|
||||
从最新消息开始(Latest)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Cursor Dialog -->
|
||||
<div v-if="showResetDialog" class="dialog-overlay" @click="showResetDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>重置游标: {{ selectedSub?.name }}</h3>
|
||||
<button @click="showResetDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>重置到</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="earliest" :disabled="readOnly" />
|
||||
最早消息(Earliest)
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="latest" :disabled="readOnly" />
|
||||
最新消息(Latest)
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="timestamp" :disabled="readOnly" />
|
||||
指定时间戳
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resetFormData.position === 'timestamp'" class="form-group">
|
||||
<label>时间戳(毫秒)</label>
|
||||
<input v-model.number="resetFormData.timestampMs" type="number" :disabled="readOnly" />
|
||||
<div class="form-hint">当前时间: {{ new Date(resetFormData.timestampMs).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showResetDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleResetCursor" :disabled="loading || readOnly" class="btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip Messages Dialog -->
|
||||
<div v-if="showSkipDialog" class="dialog-overlay" @click="showSkipDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>跳过消息: {{ selectedSub?.name }}</h3>
|
||||
<button @click="showSkipDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>跳过模式</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="skipFormData.mode" value="count" :disabled="readOnly" />
|
||||
跳过指定数量
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="skipFormData.mode" value="all" :disabled="readOnly" />
|
||||
跳过全部积压
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="skipFormData.mode === 'count'" class="form-group">
|
||||
<label>跳过数量</label>
|
||||
<input v-model.number="skipFormData.count" type="number" min="1" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showSkipDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleSkipMessages" :disabled="loading || readOnly" class="btn-primary">跳过</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peek Messages Dialog -->
|
||||
<div v-if="showPeekDialog" class="dialog-overlay" @click="showPeekDialog = false">
|
||||
<div class="dialog dialog-wide" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>Peek 消息: {{ selectedSub?.name }}</h3>
|
||||
<button @click="showPeekDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="peek-toolbar">
|
||||
<label>
|
||||
数量
|
||||
<input v-model.number="peekCount" type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button @click="handlePeekMessages" :disabled="peekLoading" class="btn-sm">
|
||||
{{ peekLoading ? "加载中..." : "刷新" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
<div v-else-if="peekLoading && !peekedMessages.length" class="panel-loading">加载中...</div>
|
||||
<div v-else-if="!peekedMessages.length" class="panel-placeholder">没有可查看的消息</div>
|
||||
<div v-else class="peek-results">
|
||||
<div v-for="message in peekedMessages" :key="message.position" class="peek-message">
|
||||
<div class="peek-message-header">
|
||||
<span>#{{ message.position }}</span>
|
||||
<span v-if="message.messageId">{{ message.messageId }}</span>
|
||||
<span v-if="message.key">key={{ message.key }}</span>
|
||||
</div>
|
||||
<div v-if="Object.keys(message.properties).length" class="peek-properties">
|
||||
<span v-for="(value, key) in message.properties" :key="key">{{ key }}={{ value }}</span>
|
||||
</div>
|
||||
<pre class="peek-payload">{{ message.payloadText ?? message.payloadBase64 }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPeekDialog = false" class="btn-secondary">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expire Messages Dialog -->
|
||||
<div v-if="showExpireDialog" class="dialog-overlay" @click="showExpireDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>过期消息: {{ selectedSub?.name }}</h3>
|
||||
<button @click="showExpireDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>过期时间(秒)</label>
|
||||
<input v-model.number="expireSeconds" type="number" min="1" :disabled="readOnly" />
|
||||
<div class="form-hint">将删除所有早于 {{ expireSeconds }} 秒的消息</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showExpireDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleExpireMessages" :disabled="loading || readOnly" class="btn-primary">过期</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subscriptions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.subscriptions-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-background-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.sub-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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 styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog-wide {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.radio-label:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.peek-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.peek-toolbar label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.peek-toolbar input {
|
||||
width: 96px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.peek-results {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.peek-message {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.peek-message-header,
|
||||
.peek-properties {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.peek-message-header {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peek-properties {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.peek-payload {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,760 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
import { computed, ref, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import type { TenantInfo, TenantConfig } from "@/types/mq";
|
||||
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/api";
|
||||
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mqTenantForm";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
supportsTenants: boolean;
|
||||
readOnly?: boolean;
|
||||
clusterOptions?: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
tenantSelected: [tenant: string];
|
||||
}>();
|
||||
|
||||
const tenants = ref<TenantInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const editingTenant = ref<TenantInfo>();
|
||||
const selectedTenant = ref<string>();
|
||||
|
||||
const formData = ref<{ name: string; config: TenantConfig }>({
|
||||
name: "",
|
||||
config: {
|
||||
adminRoles: [],
|
||||
allowedClusters: [],
|
||||
},
|
||||
});
|
||||
|
||||
const newRole = ref("");
|
||||
const newCluster = ref("");
|
||||
const clusterDropdownOpen = ref(false);
|
||||
const clusterSelectRef = ref<HTMLElement | null>(null);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
const normalizedClusterOptions = computed(() => normalizeClusterOptions(props.clusterOptions ?? []));
|
||||
const clusterOptionSet = computed(() => new Set(normalizedClusterOptions.value));
|
||||
const selectedAllowedClusters = computed(() => normalizeClusterOptions(formData.value.config.allowedClusters));
|
||||
const displayedSelectedClusters = computed(() => [...normalizedClusterOptions.value.filter((cluster) => selectedAllowedClusters.value.includes(cluster)), ...selectedAllowedClusters.value.filter((cluster) => !clusterOptionSet.value.has(cluster))]);
|
||||
const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
tenants.value = await mqListTenants(props.connectionId);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
formData.value = {
|
||||
name: "",
|
||||
config: defaultTenantConfig(normalizedClusterOptions.value),
|
||||
};
|
||||
newRole.value = "";
|
||||
newCluster.value = "";
|
||||
clusterDropdownOpen.value = false;
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
editingTenant.value = tenant;
|
||||
formData.value = {
|
||||
name: tenant.name,
|
||||
config: {
|
||||
adminRoles: [...tenant.adminRoles],
|
||||
allowedClusters: normalizeClusterOptions(tenant.allowedClusters),
|
||||
},
|
||||
};
|
||||
newRole.value = "";
|
||||
newCluster.value = "";
|
||||
clusterDropdownOpen.value = false;
|
||||
showEditDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
formData.value.config.allowedClusters = selectedAllowedClusters.value;
|
||||
await mqCreateTenant(props.connectionId, formData.value.name, formData.value.config);
|
||||
showCreateDialog.value = false;
|
||||
await loadTenants();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!editingTenant.value) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
formData.value.config.allowedClusters = selectedAllowedClusters.value;
|
||||
await mqUpdateTenant(props.connectionId, editingTenant.value.name, formData.value.config);
|
||||
showEditDialog.value = false;
|
||||
await loadTenants();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除租户 "${tenant.name}" 吗?此操作不可逆。`)) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeleteTenant(props.connectionId, tenant.name, false);
|
||||
if (selectedTenant.value === tenant.name) {
|
||||
selectedTenant.value = undefined;
|
||||
}
|
||||
await loadTenants();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
const role = newRole.value.trim();
|
||||
if (role && !formData.value.config.adminRoles.includes(role)) {
|
||||
formData.value.config.adminRoles.push(role);
|
||||
newRole.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeRole(role: string) {
|
||||
formData.value.config.adminRoles = formData.value.config.adminRoles.filter((r) => r !== role);
|
||||
}
|
||||
|
||||
function addCluster() {
|
||||
const cluster = newCluster.value.trim();
|
||||
if (cluster && !formData.value.config.allowedClusters.includes(cluster)) {
|
||||
formData.value.config.allowedClusters.push(cluster);
|
||||
newCluster.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeCluster(cluster: string) {
|
||||
formData.value.config.allowedClusters = formData.value.config.allowedClusters.filter((c) => c !== cluster);
|
||||
}
|
||||
|
||||
function toggleCluster(cluster: string) {
|
||||
const selected = new Set(selectedAllowedClusters.value);
|
||||
if (selected.has(cluster)) {
|
||||
selected.delete(cluster);
|
||||
} else {
|
||||
selected.add(cluster);
|
||||
}
|
||||
formData.value.config.allowedClusters = [...selected];
|
||||
}
|
||||
|
||||
function isClusterSelected(cluster: string) {
|
||||
return selectedAllowedClusters.value.includes(cluster);
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!clusterDropdownOpen.value) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (clusterSelectRef.value?.contains(target)) return;
|
||||
clusterDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function selectTenant(tenant: TenantInfo) {
|
||||
selectedTenant.value = tenant.name;
|
||||
emit("tenantSelected", tenant.name);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown, true);
|
||||
if (props.supportsTenants) {
|
||||
loadTenants();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
|
||||
});
|
||||
|
||||
watch(normalizedClusterOptions, (clusters) => {
|
||||
if (showCreateDialog.value && formData.value.config.allowedClusters.length === 0) {
|
||||
formData.value.config.allowedClusters = [...clusters];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tenants-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>租户管理</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly" class="btn-primary">+ 创建租户</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!supportsTenants" class="panel-placeholder">当前消息队列系统不支持租户管理</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !tenants.length" class="panel-loading">加载中...</div>
|
||||
|
||||
<div v-else class="tenants-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>管理角色</th>
|
||||
<th>允许集群</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tenant in tenants" :key="tenant.name" :class="{ selected: selectedTenant === tenant.name }" @click="selectTenant(tenant)">
|
||||
<td class="tenant-name">{{ tenant.name }}</td>
|
||||
<td>
|
||||
<span v-if="tenant.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in tenant.adminRoles" :key="role" class="tag">{{ role }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">无</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="tenant.allowedClusters.length" class="tag-list">
|
||||
<span v-for="cluster in tenant.allowedClusters" :key="cluster" class="tag">{{ cluster }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">无</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button @click.stop="openEditDialog(tenant)" :disabled="readOnly" class="btn-sm">编辑</button>
|
||||
<button @click.stop="handleDelete(tenant)" :disabled="readOnly" class="btn-sm btn-danger">删除</button>
|
||||
</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>创建租户</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>租户名称*</label>
|
||||
<input v-model="formData.name" type="text" placeholder="例如: my-tenant" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理角色</label>
|
||||
<div class="input-with-button">
|
||||
<input v-model="newRole" type="text" placeholder="添加角色" :disabled="readOnly" @keyup.enter="addRole" />
|
||||
<button @click="addRole" :disabled="readOnly" class="btn-sm">添加</button>
|
||||
</div>
|
||||
<div v-if="formData.config.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in formData.config.adminRoles" :key="role" class="tag">
|
||||
{{ role }}
|
||||
<button @click="removeRole(role)" :disabled="readOnly" class="tag-remove">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>允许集群*</label>
|
||||
<div ref="clusterSelectRef" class="cluster-select-wrap">
|
||||
<button type="button" class="cluster-select-trigger" :class="{ open: clusterDropdownOpen }" :disabled="readOnly" @click="clusterDropdownOpen = !clusterDropdownOpen">
|
||||
<span v-if="displayedSelectedClusters.length" class="cluster-selected-tags">
|
||||
<span v-for="cluster in displayedSelectedClusters" :key="cluster" class="tag">
|
||||
{{ cluster }}
|
||||
<span class="tag-remove" role="button" tabindex="0" @click.stop="removeCluster(cluster)" @keyup.enter.stop="removeCluster(cluster)">×</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="cluster-placeholder">{{ normalizedClusterOptions.length ? "请选择允许集群" : "未探测到集群,可手动添加" }}</span>
|
||||
<span class="cluster-arrow">{{ clusterDropdownOpen ? "⌃" : "⌄" }}</span>
|
||||
</button>
|
||||
<div v-if="clusterDropdownOpen" class="cluster-options">
|
||||
<button v-for="cluster in normalizedClusterOptions" :key="cluster" type="button" class="cluster-option" :class="{ selected: isClusterSelected(cluster) }" @click="toggleCluster(cluster)">
|
||||
<span>{{ cluster }}</span>
|
||||
<span v-if="isClusterSelected(cluster)" class="cluster-check">✓</span>
|
||||
</button>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">当前连接未返回集群列表</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-with-button cluster-manual">
|
||||
<input v-model="newCluster" type="text" placeholder="手动添加集群" :disabled="readOnly" @keyup.enter="addCluster" />
|
||||
<button @click="addCluster" :disabled="readOnly" class="btn-sm">添加</button>
|
||||
</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">至少选择或添加一个允许集群</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly || !canSubmitTenant" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<div v-if="showEditDialog" class="dialog-overlay" @click="showEditDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>编辑租户: {{ editingTenant?.name }}</h3>
|
||||
<button @click="showEditDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>管理角色</label>
|
||||
<div class="input-with-button">
|
||||
<input v-model="newRole" type="text" placeholder="添加角色" :disabled="readOnly" @keyup.enter="addRole" />
|
||||
<button @click="addRole" :disabled="readOnly" class="btn-sm">添加</button>
|
||||
</div>
|
||||
<div v-if="formData.config.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in formData.config.adminRoles" :key="role" class="tag">
|
||||
{{ role }}
|
||||
<button @click="removeRole(role)" :disabled="readOnly" class="tag-remove">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>允许集群*</label>
|
||||
<div ref="clusterSelectRef" class="cluster-select-wrap">
|
||||
<button type="button" class="cluster-select-trigger" :class="{ open: clusterDropdownOpen }" :disabled="readOnly" @click="clusterDropdownOpen = !clusterDropdownOpen">
|
||||
<span v-if="displayedSelectedClusters.length" class="cluster-selected-tags">
|
||||
<span v-for="cluster in displayedSelectedClusters" :key="cluster" class="tag">
|
||||
{{ cluster }}
|
||||
<span class="tag-remove" role="button" tabindex="0" @click.stop="removeCluster(cluster)" @keyup.enter.stop="removeCluster(cluster)">×</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="cluster-placeholder">{{ normalizedClusterOptions.length ? "请选择允许集群" : "未探测到集群,可手动添加" }}</span>
|
||||
<span class="cluster-arrow">{{ clusterDropdownOpen ? "⌃" : "⌄" }}</span>
|
||||
</button>
|
||||
<div v-if="clusterDropdownOpen" class="cluster-options">
|
||||
<button v-for="cluster in normalizedClusterOptions" :key="cluster" type="button" class="cluster-option" :class="{ selected: isClusterSelected(cluster) }" @click="toggleCluster(cluster)">
|
||||
<span>{{ cluster }}</span>
|
||||
<span v-if="isClusterSelected(cluster)" class="cluster-check">✓</span>
|
||||
</button>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">当前连接未返回集群列表</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-with-button cluster-manual">
|
||||
<input v-model="newCluster" type="text" placeholder="手动添加集群" :disabled="readOnly" @keyup.enter="addCluster" />
|
||||
<button @click="addCluster" :disabled="readOnly" class="btn-sm">添加</button>
|
||||
</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">至少选择或添加一个允许集群</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showEditDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleUpdate" :disabled="loading || readOnly || !canSubmitTenant" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tenants-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.tenants-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-background-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.tenant-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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 styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cluster-select-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cluster-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cluster-select-trigger.open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.cluster-selected-tags {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cluster-placeholder {
|
||||
flex: 1;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cluster-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cluster-options {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 20;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cluster-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cluster-option:hover,
|
||||
.cluster-option.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.cluster-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cluster-check {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cluster-empty {
|
||||
padding: 10px 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cluster-manual {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.form-hint-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,612 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts } from "@/types/mq";
|
||||
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions } from "@/lib/api";
|
||||
import { formatError } from "@/lib/errorUtils";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
supportsPartitionedTopics?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
topicSelected: [topic: TopicInfo];
|
||||
}>();
|
||||
|
||||
const topics = ref<TopicInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const showPartitionsDialog = ref(false);
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const editingTopic = ref<TopicInfo>();
|
||||
|
||||
const formData = ref({
|
||||
topicName: "",
|
||||
persistent: true,
|
||||
partitioned: false,
|
||||
partitions: 4,
|
||||
});
|
||||
|
||||
const newPartitions = ref(4);
|
||||
|
||||
const includeNonPersistent = ref(false);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
const filteredTopics = computed(() => {
|
||||
return topics.value;
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
if (!props.tenant || !props.namespace) {
|
||||
topics.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const ns: NamespaceRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
};
|
||||
const opts: ListTopicsOpts = {
|
||||
includeNonPersistent: includeNonPersistent.value,
|
||||
};
|
||||
topics.value = await mqListTopics(props.connectionId, ns, opts);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
formData.value = {
|
||||
topicName: "",
|
||||
persistent: true,
|
||||
partitioned: false,
|
||||
partitions: 4,
|
||||
};
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
function openPartitionsDialog(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!topic.partitions || topic.partitions < 1) {
|
||||
error.value = "当前分区数未知,无法安全调整分区";
|
||||
return;
|
||||
}
|
||||
editingTopic.value = topic;
|
||||
newPartitions.value = topic.partitions + 1;
|
||||
showPartitionsDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
|
||||
error.value = "Topic name is required";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const topicRef: TopicRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: formData.value.topicName,
|
||||
persistent: formData.value.persistent,
|
||||
};
|
||||
const partitions = props.supportsPartitionedTopics !== false && formData.value.partitioned ? formData.value.partitions : undefined;
|
||||
await mqCreateTopic(props.connectionId, topicRef, partitions);
|
||||
showCreateDialog.value = false;
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除主题 "${topic.shortName}" 吗?此操作不可逆。`)) return;
|
||||
if (!props.tenant || !props.namespace) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const topicRef: TopicRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: topic.shortName,
|
||||
persistent: topic.persistent,
|
||||
};
|
||||
await mqDeleteTopic(props.connectionId, topicRef, false);
|
||||
if (selectedTopic.value?.name === topic.name) {
|
||||
selectedTopic.value = undefined;
|
||||
}
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatePartitions() {
|
||||
if (!guardWritable()) return;
|
||||
if (!editingTopic.value || !props.tenant || !props.namespace) return;
|
||||
const currentPartitions = editingTopic.value.partitions;
|
||||
if (!currentPartitions || currentPartitions < 1) {
|
||||
error.value = "当前分区数未知,无法安全调整分区";
|
||||
return;
|
||||
}
|
||||
if (newPartitions.value <= currentPartitions) {
|
||||
error.value = "新分区数必须大于当前分区数";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const topicRef: TopicRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: editingTopic.value.shortName,
|
||||
persistent: editingTopic.value.persistent,
|
||||
};
|
||||
await mqUpdatePartitions(props.connectionId, topicRef, newPartitions.value);
|
||||
showPartitionsDialog.value = false;
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTopic(topic: TopicInfo) {
|
||||
selectedTopic.value = topic;
|
||||
emit("topicSelected", topic);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace],
|
||||
() => {
|
||||
selectedTopic.value = undefined;
|
||||
loadTopics();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(includeNonPersistent, () => {
|
||||
loadTopics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="topics-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>主题管理</h3>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="includeNonPersistent" />
|
||||
包含非持久化主题
|
||||
</label>
|
||||
</div>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace" class="btn-primary">+ 创建主题</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">请先选择租户和命名空间</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">加载中...</div>
|
||||
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">该命名空间下暂无主题</div>
|
||||
|
||||
<div v-else class="topics-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>分区</th>
|
||||
<th>操作</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">非持久化</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="topic.partitioned ? 'badge-info' : 'badge-default'">
|
||||
{{ topic.partitioned ? "分区主题" : "普通主题" }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? `${topic.partitions} 个分区` : "分区数未知" }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false" @click.stop="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">调整分区</button>
|
||||
<button @click.stop="handleDelete(topic)" :disabled="readOnly" class="btn-sm btn-danger">删除</button>
|
||||
</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>创建主题</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>租户 / 命名空间</label>
|
||||
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题名称*</label>
|
||||
<input v-model="formData.topicName" type="text" placeholder="例如: my-topic" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
|
||||
持久化主题(推荐)
|
||||
</label>
|
||||
<div class="form-hint">持久化主题会将消息保存到磁盘,非持久化主题仅保存在内存中</div>
|
||||
</div>
|
||||
<div v-if="supportsPartitionedTopics !== false" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
|
||||
启用分区
|
||||
</label>
|
||||
<div v-if="formData.partitioned" class="form-subgroup">
|
||||
<label>分区数量*</label>
|
||||
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">分区可以提高并发性能,但会增加资源消耗</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">创建</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>调整分区数: {{ editingTopic?.shortName }}</h3>
|
||||
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>当前分区数</label>
|
||||
<input type="number" :value="editingTopic?.partitions" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新分区数*</label>
|
||||
<input v-model.number="newPartitions" type="number" :min="(editingTopic?.partitions || 0) + 1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">⚠️ 分区数只能增加,不能减少</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPartitionsDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleUpdatePartitions" :disabled="loading || readOnly" class="btn-primary">更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topics-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-left h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.topics-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-background-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.topic-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topic-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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 styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 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;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-subgroup {
|
||||
margin-top: 12px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -79,7 +79,7 @@ const isFiltering = computed(() => !!searchQuery.value.trim() || hasSearchScopeF
|
||||
|
||||
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mongo-db"],
|
||||
database: ["database", "redis-db", "mq-tenant", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
@@ -334,6 +334,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
await store.loadMongoDatabases(connId);
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
await store.loadElasticsearchIndices(connId);
|
||||
} else if (config.db_type === "mq") {
|
||||
await store.loadMqTenants(connId, loadOptions);
|
||||
} else {
|
||||
await store.loadDatabases(connId, loadOptions);
|
||||
}
|
||||
@@ -342,6 +344,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
}
|
||||
}
|
||||
|
||||
if (config.db_type === "mq") return;
|
||||
if (!("database" in target) || !target.database) return;
|
||||
|
||||
// Find the database node
|
||||
|
||||
@@ -229,6 +229,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Zap, colorClass: "text-orange-300" };
|
||||
case "redis-db":
|
||||
return { icon: Database, colorClass: "text-red-400" };
|
||||
case "mq-tenant":
|
||||
return { icon: FolderOpen, colorClass: "text-sky-400" };
|
||||
case "etcd-root":
|
||||
return { icon: Database, colorClass: "text-sky-500" };
|
||||
case "mongo-db":
|
||||
@@ -354,12 +356,16 @@ async function toggle() {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "mq") {
|
||||
await connectionStore.loadMqTenants(node.connectionId);
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId);
|
||||
}
|
||||
} else if (node.type === "redis-db" && node.connectionId && node.database) {
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
|
||||
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
|
||||
} else if (node.type === "mq-tenant" && node.connectionId) {
|
||||
queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label });
|
||||
} else if (node.type === "etcd-root" && node.connectionId) {
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
|
||||
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
|
||||
@@ -2620,7 +2626,7 @@ const nodeIconClass = computed(() => {
|
||||
const canConfigureVisibleDatabases = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type;
|
||||
return dbType !== "elasticsearch" && dbType !== "etcd";
|
||||
return dbType !== "elasticsearch" && dbType !== "etcd" && dbType !== "mq";
|
||||
});
|
||||
const canCopyFinalProxyPort = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { quickConnectionOpenTarget } from "@/lib/connectionOpenTarget";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function connection(dbType: ConnectionConfig["db_type"]): ConnectionConfig {
|
||||
return {
|
||||
id: "conn",
|
||||
name: "conn",
|
||||
db_type: dbType,
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
user: "",
|
||||
password: "",
|
||||
database: "",
|
||||
readonly: false,
|
||||
read_only: false,
|
||||
ssl_mode: "disabled",
|
||||
color: "#888",
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
describe("quickConnectionOpenTarget", () => {
|
||||
it("opens message queue connections in the MQ admin console", () => {
|
||||
expect(quickConnectionOpenTarget(connection("mq"))).toEqual({ kind: "mq-admin" });
|
||||
});
|
||||
|
||||
it("opens regular connections in a query tab", () => {
|
||||
expect(quickConnectionOpenTarget({ ...connection("postgresql"), database: "app" })).toEqual({
|
||||
kind: "query",
|
||||
database: "app",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function componentSource(name: string): string {
|
||||
return readFileSync(new URL(`../../components/mq/${name}`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
describe("MQ list panels", () => {
|
||||
it("does not issue per-tenant detail requests from the tenant list UI", () => {
|
||||
const source = componentSource("TenantsPanel.vue");
|
||||
|
||||
expect(source).not.toContain("mqGetTenant");
|
||||
});
|
||||
|
||||
it("does not issue per-namespace permission requests from the namespace list UI", () => {
|
||||
const source = componentSource("NamespacesPanel.vue");
|
||||
|
||||
expect(source).not.toContain("mqListPermissions");
|
||||
});
|
||||
|
||||
it("refreshing policies hydrates missing fields from defaults instead of dirty form values", () => {
|
||||
const source = componentSource("PoliciesPanel.vue");
|
||||
|
||||
expect(source).not.toContain("policyFormsFromEffectivePolicies(loaded, currentPolicyForms())");
|
||||
expect(source).toContain("policyFormsFromEffectivePolicies(loaded, defaultMqPolicyForms())");
|
||||
});
|
||||
|
||||
it("raw api panel includes common endpoint presets that use the selected mq context", () => {
|
||||
const rawSource = componentSource("RawApiPanel.vue");
|
||||
const consoleSource = componentSource("MqAdminConsole.vue");
|
||||
|
||||
expect(rawSource).toContain("const presets = computed<RawApiPreset[]>");
|
||||
expect(rawSource).toContain("/admin/v2/brokers/version");
|
||||
expect(rawSource).toContain("/internalStats");
|
||||
expect(rawSource).toContain("/partitioned-stats");
|
||||
expect(rawSource).toContain("/schema");
|
||||
expect(rawSource).toContain("presetsCollapsed");
|
||||
expect(rawSource).toContain("formatJsonBody");
|
||||
expect(rawSource).toContain("bodyTextareaRows");
|
||||
expect(rawSource).toContain("body: isReadMethod.value ? undefined : parseBody()");
|
||||
expect(consoleSource).toContain(':tenant="selectedTenant"');
|
||||
expect(consoleSource).toContain(':namespace="selectedNamespace"');
|
||||
expect(consoleSource).toContain(':topic="selectedTopic"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MQ_PINNED_VERSION_AUTO, MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mqPinnedVersionOptions";
|
||||
|
||||
describe("mqPinnedVersionOptions", () => {
|
||||
it("offers auto-detection and the supported Pulsar 3.x profile", () => {
|
||||
expect(MQ_PINNED_VERSION_OPTIONS.map((option) => option.value)).toEqual([MQ_PINNED_VERSION_AUTO, "3.1.x"]);
|
||||
});
|
||||
|
||||
it("normalizes existing Pulsar 3.x pinned values to the supported profile option", () => {
|
||||
expect(pinnedVersionToSelection(undefined)).toBe(MQ_PINNED_VERSION_AUTO);
|
||||
expect(pinnedVersionToSelection("")).toBe(MQ_PINNED_VERSION_AUTO);
|
||||
expect(pinnedVersionToSelection("3.0.7")).toBe("3.1.x");
|
||||
expect(pinnedVersionToSelection("3.1.2")).toBe("3.1.x");
|
||||
expect(pinnedVersionToSelection("3.1.x")).toBe("3.1.x");
|
||||
});
|
||||
|
||||
it("only persists an explicit version when a supported profile is selected", () => {
|
||||
expect(selectionToPinnedVersion(MQ_PINNED_VERSION_AUTO)).toBeUndefined();
|
||||
expect(selectionToPinnedVersion("3.1.x")).toBe("3.1.x");
|
||||
expect(selectionToPinnedVersion("4.0.x")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mqPolicyForms";
|
||||
|
||||
describe("mqPolicyForms", () => {
|
||||
it("hydrates forms from topic effective policies", () => {
|
||||
const forms = policyFormsFromEffectivePolicies(
|
||||
{
|
||||
level: "topic",
|
||||
topicPolicies: {
|
||||
publishRate: {
|
||||
publishThrottlingRateInMsg: 100,
|
||||
publishThrottlingRateInByte: 2048,
|
||||
},
|
||||
dispatchRate: {
|
||||
dispatchThrottlingRateInMsg: 50,
|
||||
dispatchThrottlingRateInByte: 1024,
|
||||
ratePeriodInSecond: 5,
|
||||
},
|
||||
backlogQuota: {
|
||||
limitSize: 4096,
|
||||
limitTime: 60,
|
||||
policy: "producer_exception",
|
||||
quotaType: "message_age",
|
||||
},
|
||||
retention: {
|
||||
retentionTimeInMinutes: 120,
|
||||
retentionSizeInMB: 512,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultMqPolicyForms(),
|
||||
);
|
||||
|
||||
expect(forms.publishForm.publishThrottlingRateInMsg).toBe(100);
|
||||
expect(forms.dispatchForm.ratePeriodInSecond).toBe(5);
|
||||
expect(forms.backlogForm.quotaType).toBe("message_age");
|
||||
expect(forms.retentionForm.retentionSizeInMb).toBe(512);
|
||||
});
|
||||
|
||||
it("hydrates forms from namespace Pulsar policies shape", () => {
|
||||
const forms = policyFormsFromEffectivePolicies(
|
||||
{
|
||||
publishRate: {
|
||||
publishThrottlingRateInMsg: 20,
|
||||
publishThrottlingRateInByte: 3000,
|
||||
},
|
||||
subscribeRate: {
|
||||
subscribeThrottlingRatePerConsumer: 12,
|
||||
ratePeriodInSecond: 3,
|
||||
},
|
||||
backlog_quota_map: {
|
||||
destination_storage: {
|
||||
limitSize: 999,
|
||||
limitTime: 10,
|
||||
policy: "consumer_backlog_eviction",
|
||||
},
|
||||
},
|
||||
retention_policies: {
|
||||
retentionTimeInMinutes: 30,
|
||||
retentionSizeInMB: 64,
|
||||
},
|
||||
},
|
||||
defaultMqPolicyForms(),
|
||||
);
|
||||
|
||||
expect(forms.publishForm.publishThrottlingRateInByte).toBe(3000);
|
||||
expect(forms.subscribeForm.ratePeriodInSecond).toBe(3);
|
||||
expect(forms.backlogForm.limitSize).toBe(999);
|
||||
expect(forms.backlogForm.quotaType).toBe("destination_storage");
|
||||
expect(forms.retentionForm.retentionTimeInMinutes).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultTenantConfig, mqClusterOptionsFromExtra, validateTenantForm } from "@/lib/mqTenantForm";
|
||||
|
||||
describe("mqTenantForm", () => {
|
||||
it("extracts selectable cluster names from Pulsar cluster info extras", () => {
|
||||
expect(mqClusterOptionsFromExtra({ clusters: ["standalone", "ec-pulsar", "standalone", "", 42] })).toEqual(["standalone", "ec-pulsar"]);
|
||||
expect(mqClusterOptionsFromExtra({ clusters: "standalone" })).toEqual([]);
|
||||
expect(mqClusterOptionsFromExtra(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefills a new tenant with detected clusters", () => {
|
||||
const config = defaultTenantConfig(["standalone", "ec-pulsar"]);
|
||||
|
||||
expect(config).toEqual({
|
||||
adminRoles: [],
|
||||
allowedClusters: ["standalone", "ec-pulsar"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires both a tenant name and at least one allowed cluster", () => {
|
||||
expect(validateTenantForm("", { adminRoles: [], allowedClusters: ["standalone"] })).toBe("Tenant name is required");
|
||||
expect(validateTenantForm("tenant-a", { adminRoles: [], allowedClusters: [] })).toBe("Allowed clusters are required");
|
||||
expect(validateTenantForm("tenant-a", { adminRoles: [], allowedClusters: ["standalone"] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMqTokenIssueError } from "../mqTokenErrors";
|
||||
|
||||
describe("MQ token errors", () => {
|
||||
it("turns missing signing configuration backend errors into actionable Chinese copy", () => {
|
||||
const result = formatMqTokenIssueError("/api/mq/tokens/issue returned 500: Token signing is not configured for this MQ connection");
|
||||
|
||||
expect(result.kind).toBe("missingSigningKey");
|
||||
expect(result.title).toBe("未配置 Token 签发密钥");
|
||||
expect(result.message).toContain("无法生成客户端 Token");
|
||||
expect(result.detail).toContain("Broker Token 签发");
|
||||
});
|
||||
|
||||
it("keeps unrelated errors as ordinary messages", () => {
|
||||
const result = formatMqTokenIssueError("network failed");
|
||||
|
||||
expect(result.kind).toBe("generic");
|
||||
expect(result.title).toBeUndefined();
|
||||
expect(result.message).toBe("network failed");
|
||||
});
|
||||
});
|
||||
@@ -275,6 +275,48 @@ export const etcdGet = forward("etcdGet");
|
||||
export const etcdPut = forward("etcdPut");
|
||||
export const etcdDelete = forward("etcdDelete");
|
||||
|
||||
// Message Queue
|
||||
export const mqTestConnection = forward("mqTestConnection");
|
||||
export const mqListTenants = forward("mqListTenants");
|
||||
export const mqGetTenant = forward("mqGetTenant");
|
||||
export const mqCreateTenant = forward("mqCreateTenant");
|
||||
export const mqUpdateTenant = forward("mqUpdateTenant");
|
||||
export const mqDeleteTenant = forward("mqDeleteTenant");
|
||||
export const mqListNamespaces = forward("mqListNamespaces");
|
||||
export const mqCreateNamespace = forward("mqCreateNamespace");
|
||||
export const mqDeleteNamespace = forward("mqDeleteNamespace");
|
||||
export const mqGetNamespacePolicies = forward("mqGetNamespacePolicies");
|
||||
export const mqListTopics = forward("mqListTopics");
|
||||
export const mqCreateTopic = forward("mqCreateTopic");
|
||||
export const mqDeleteTopic = forward("mqDeleteTopic");
|
||||
export const mqUpdatePartitions = forward("mqUpdatePartitions");
|
||||
export const mqGetTopicStats = forward("mqGetTopicStats");
|
||||
export const mqGetTopicInternalStats = forward("mqGetTopicInternalStats");
|
||||
export const mqListSubscriptions = forward("mqListSubscriptions");
|
||||
export const mqCreateSubscription = forward("mqCreateSubscription");
|
||||
export const mqDeleteSubscription = forward("mqDeleteSubscription");
|
||||
export const mqSkipMessages = forward("mqSkipMessages");
|
||||
export const mqResetCursor = forward("mqResetCursor");
|
||||
export const mqClearBacklog = forward("mqClearBacklog");
|
||||
export const mqPeekMessages = forward("mqPeekMessages");
|
||||
export const mqExpireMessages = forward("mqExpireMessages");
|
||||
export const mqListProducers = forward("mqListProducers");
|
||||
export const mqListConsumers = forward("mqListConsumers");
|
||||
export const mqUnloadTopic = forward("mqUnloadTopic");
|
||||
export const mqSetPublishRate = forward("mqSetPublishRate");
|
||||
export const mqSetDispatchRate = forward("mqSetDispatchRate");
|
||||
export const mqSetSubscribeRate = forward("mqSetSubscribeRate");
|
||||
export const mqSetBacklogQuota = forward("mqSetBacklogQuota");
|
||||
export const mqSetRetention = forward("mqSetRetention");
|
||||
export const mqGetEffectivePolicies = forward("mqGetEffectivePolicies");
|
||||
export const mqGrantPermission = forward("mqGrantPermission");
|
||||
export const mqRevokePermission = forward("mqRevokePermission");
|
||||
export const mqListPermissions = forward("mqListPermissions");
|
||||
export const mqIssueToken = forward("mqIssueToken");
|
||||
export const mqListTokenRecords = forward("mqListTokenRecords");
|
||||
export const mqGetBacklog = forward("mqGetBacklog");
|
||||
export const mqRawRequest = forward("mqRawRequest");
|
||||
|
||||
// MongoDB
|
||||
export const mongoListDatabases = forward("mongoListDatabases");
|
||||
export const mongoListCollections = forward("mongoListCollections");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
|
||||
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "query"; database: string };
|
||||
|
||||
export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db_type" | "database">, databaseOptions: string[] = []): QuickConnectionOpenTarget {
|
||||
if (connection.db_type === "mq") {
|
||||
return { kind: "mq-admin" };
|
||||
}
|
||||
return { kind: "query", database: resolveDefaultDatabase(connection, databaseOptions) };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility functions for consistent error handling across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats an unknown error value into a human-readable string.
|
||||
* Handles Error objects, strings, null/undefined, and other types.
|
||||
*
|
||||
* @param e - The error value to format (from a catch block)
|
||||
* @returns A human-readable error message string
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* await someOperation();
|
||||
* } catch (e: unknown) {
|
||||
* errorMessage.value = formatError(e);
|
||||
* }
|
||||
*/
|
||||
export function formatError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
if (typeof e === "string") {
|
||||
return e;
|
||||
}
|
||||
|
||||
if (e === null || e === undefined) {
|
||||
return "Unknown error occurred";
|
||||
}
|
||||
|
||||
// Try to extract message property from object-like values
|
||||
if (typeof e === "object" && "message" in e) {
|
||||
const message = (e as { message: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: attempt to stringify
|
||||
try {
|
||||
return String(e);
|
||||
} catch {
|
||||
return "Unknown error occurred";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an error with a context prefix for better debugging.
|
||||
*
|
||||
* @param e - The error value to format
|
||||
* @param context - The operation context (e.g., "loading topics", "creating tenant")
|
||||
* @returns A formatted error message with context
|
||||
*
|
||||
* @example
|
||||
* catch (e: unknown) {
|
||||
* errorMessage.value = formatErrorWithContext(e, 'loading topics');
|
||||
* }
|
||||
*/
|
||||
export function formatErrorWithContext(e: unknown, context: string): string {
|
||||
const message = formatError(e);
|
||||
return `Failed to ${context}: ${message}`;
|
||||
}
|
||||
@@ -1512,5 +1512,7 @@ export async function loadSidebarLayout(): Promise<SidebarLayout | null> {
|
||||
}
|
||||
|
||||
export async function refreshConnections(): Promise<void> {
|
||||
// Web mode doesn't maintain persistent connection pools 鈥?no-op
|
||||
// Web mode doesn't maintain persistent connection pools - no-op
|
||||
}
|
||||
|
||||
export * from "./mq-http";
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// HTTP fetch API for message queue admin (web mode)
|
||||
import type {
|
||||
MqClusterInfo,
|
||||
TenantInfo,
|
||||
TenantConfig,
|
||||
NamespaceRef,
|
||||
NamespaceInfo,
|
||||
NamespaceConfig,
|
||||
TopicRef,
|
||||
TopicInfo,
|
||||
ListTopicsOpts,
|
||||
TopicStats,
|
||||
SubscriptionInfo,
|
||||
ResetPosition,
|
||||
SkipCount,
|
||||
ConsumerInfo,
|
||||
ProducerInfo,
|
||||
PolicyScope,
|
||||
PublishRate,
|
||||
DispatchRate,
|
||||
SubscribeRate,
|
||||
BacklogQuota,
|
||||
RetentionPolicy,
|
||||
AuthAction,
|
||||
PermissionMap,
|
||||
MqTokenIssueRequest,
|
||||
MqTokenRecord,
|
||||
MqIssuedToken,
|
||||
BacklogStats,
|
||||
PeekedMessage,
|
||||
MqRawRequest,
|
||||
MqRawResponse,
|
||||
} from "@/types/mq";
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = (await resp.text().catch(() => "")).trim();
|
||||
throw new Error(detail ? `${path} returned ${resp.status}: ${detail}` : `${path} returned ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function mqTestConnection(connectionId: string): Promise<MqClusterInfo> {
|
||||
return post("/api/mq/test-connection", { connectionId });
|
||||
}
|
||||
|
||||
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
|
||||
return post("/api/mq/tenants/list", { connectionId });
|
||||
}
|
||||
|
||||
export async function mqGetTenant(connectionId: string, name: string): Promise<TenantInfo> {
|
||||
return post("/api/mq/tenants/get", { connectionId, name });
|
||||
}
|
||||
|
||||
export async function mqCreateTenant(connectionId: string, name: string, config: TenantConfig): Promise<void> {
|
||||
return post("/api/mq/tenants/create", { connectionId, name, config });
|
||||
}
|
||||
|
||||
export async function mqUpdateTenant(connectionId: string, name: string, config: TenantConfig): Promise<void> {
|
||||
return post("/api/mq/tenants/update", { connectionId, name, config });
|
||||
}
|
||||
|
||||
export async function mqDeleteTenant(connectionId: string, name: string, force: boolean): Promise<void> {
|
||||
return post("/api/mq/tenants/delete", { connectionId, name, force });
|
||||
}
|
||||
|
||||
export async function mqListNamespaces(connectionId: string, tenant: string): Promise<NamespaceInfo[]> {
|
||||
return post("/api/mq/namespaces/list", { connectionId, tenant });
|
||||
}
|
||||
|
||||
export async function mqCreateNamespace(connectionId: string, ns: NamespaceRef, config: NamespaceConfig): Promise<void> {
|
||||
return post("/api/mq/namespaces/create", { connectionId, ns, config });
|
||||
}
|
||||
|
||||
export async function mqDeleteNamespace(connectionId: string, ns: NamespaceRef, force: boolean): Promise<void> {
|
||||
return post("/api/mq/namespaces/delete", { connectionId, ns, force });
|
||||
}
|
||||
|
||||
export async function mqGetNamespacePolicies(connectionId: string, ns: NamespaceRef): Promise<unknown> {
|
||||
return post("/api/mq/namespaces/policies", { connectionId, ns });
|
||||
}
|
||||
|
||||
export async function mqListTopics(connectionId: string, ns: NamespaceRef, opts: ListTopicsOpts): Promise<TopicInfo[]> {
|
||||
return post("/api/mq/topics/list", { connectionId, ns, opts });
|
||||
}
|
||||
|
||||
export async function mqCreateTopic(connectionId: string, topic: TopicRef, partitions?: number): Promise<void> {
|
||||
return post("/api/mq/topics/create", { connectionId, topic, partitions });
|
||||
}
|
||||
|
||||
export async function mqDeleteTopic(connectionId: string, topic: TopicRef, force: boolean): Promise<void> {
|
||||
return post("/api/mq/topics/delete", { connectionId, topic, force });
|
||||
}
|
||||
|
||||
export async function mqUpdatePartitions(connectionId: string, topic: TopicRef, partitions: number): Promise<void> {
|
||||
return post("/api/mq/topics/update-partitions", { connectionId, topic, partitions });
|
||||
}
|
||||
|
||||
export async function mqGetTopicStats(connectionId: string, topic: TopicRef): Promise<TopicStats> {
|
||||
return post("/api/mq/topics/stats", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqGetTopicInternalStats(connectionId: string, topic: TopicRef): Promise<unknown> {
|
||||
return post("/api/mq/topics/internal-stats", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqListSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return post("/api/mq/subscriptions/list", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
export async function mqDeleteSubscription(connectionId: string, topic: TopicRef, sub: string, force: boolean): Promise<void> {
|
||||
return post("/api/mq/subscriptions/delete", { connectionId, topic, sub, force });
|
||||
}
|
||||
|
||||
export async function mqSkipMessages(connectionId: string, topic: TopicRef, sub: string, count: SkipCount): Promise<void> {
|
||||
return post("/api/mq/subscriptions/skip-messages", { connectionId, topic, sub, count });
|
||||
}
|
||||
|
||||
export async function mqResetCursor(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return post("/api/mq/subscriptions/reset-cursor", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
export async function mqClearBacklog(connectionId: string, topic: TopicRef, sub: string): Promise<void> {
|
||||
return post("/api/mq/subscriptions/clear-backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number): Promise<PeekedMessage[]> {
|
||||
return post("/api/mq/subscriptions/peek-messages", { connectionId, topic, sub, count });
|
||||
}
|
||||
|
||||
export async function mqExpireMessages(connectionId: string, topic: TopicRef, sub: string, expireSeconds: number): Promise<void> {
|
||||
return post("/api/mq/subscriptions/expire-messages", { connectionId, topic, sub, expireSeconds });
|
||||
}
|
||||
|
||||
export async function mqListProducers(connectionId: string, topic: TopicRef): Promise<ProducerInfo[]> {
|
||||
return post("/api/mq/producers/list", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqListConsumers(connectionId: string, topic: TopicRef, sub: string): Promise<ConsumerInfo[]> {
|
||||
return post("/api/mq/consumers/list", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqUnloadTopic(connectionId: string, topic: TopicRef): Promise<void> {
|
||||
return post("/api/mq/topics/unload", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqSetPublishRate(connectionId: string, scope: PolicyScope, rate: PublishRate): Promise<void> {
|
||||
return post("/api/mq/policies/publish-rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetDispatchRate(connectionId: string, scope: PolicyScope, rate: DispatchRate): Promise<void> {
|
||||
return post("/api/mq/policies/dispatch-rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetSubscribeRate(connectionId: string, scope: PolicyScope, rate: SubscribeRate): Promise<void> {
|
||||
return post("/api/mq/policies/subscribe-rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetBacklogQuota(connectionId: string, scope: PolicyScope, quota: BacklogQuota): Promise<void> {
|
||||
return post("/api/mq/policies/backlog-quota", { connectionId, scope, quota });
|
||||
}
|
||||
|
||||
export async function mqSetRetention(connectionId: string, scope: PolicyScope, retention: RetentionPolicy): Promise<void> {
|
||||
return post("/api/mq/policies/retention", { connectionId, scope, retention });
|
||||
}
|
||||
|
||||
export async function mqGetEffectivePolicies(connectionId: string, scope: PolicyScope): Promise<unknown> {
|
||||
return post("/api/mq/policies/effective", { connectionId, scope });
|
||||
}
|
||||
|
||||
export async function mqGrantPermission(connectionId: string, scope: PolicyScope, role: string, actions: AuthAction[]): Promise<void> {
|
||||
return post("/api/mq/permissions/grant", { connectionId, scope, role, actions });
|
||||
}
|
||||
|
||||
export async function mqRevokePermission(connectionId: string, scope: PolicyScope, role: string): Promise<void> {
|
||||
return post("/api/mq/permissions/revoke", { connectionId, scope, role });
|
||||
}
|
||||
|
||||
export async function mqListPermissions(connectionId: string, scope: PolicyScope): Promise<PermissionMap> {
|
||||
return post("/api/mq/permissions/list", { connectionId, scope });
|
||||
}
|
||||
|
||||
export async function mqIssueToken(connectionId: string, req: MqTokenIssueRequest): Promise<MqIssuedToken> {
|
||||
return post("/api/mq/tokens/issue", { connectionId, req });
|
||||
}
|
||||
|
||||
export async function mqListTokenRecords(connectionId: string, subject?: string): Promise<MqTokenRecord[]> {
|
||||
return post("/api/mq/tokens/list", { connectionId, subject });
|
||||
}
|
||||
|
||||
export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?: string): Promise<BacklogStats> {
|
||||
return post("/api/mq/monitoring/backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
|
||||
return post("/api/mq/raw", { connectionId, req });
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Message queue admin API functions - Tauri invoke layer
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
MqClusterInfo,
|
||||
TenantInfo,
|
||||
TenantConfig,
|
||||
NamespaceRef,
|
||||
NamespaceInfo,
|
||||
NamespaceConfig,
|
||||
TopicRef,
|
||||
TopicInfo,
|
||||
ListTopicsOpts,
|
||||
TopicStats,
|
||||
SubscriptionInfo,
|
||||
ResetPosition,
|
||||
SkipCount,
|
||||
ConsumerInfo,
|
||||
ProducerInfo,
|
||||
PolicyScope,
|
||||
PublishRate,
|
||||
DispatchRate,
|
||||
SubscribeRate,
|
||||
BacklogQuota,
|
||||
RetentionPolicy,
|
||||
AuthAction,
|
||||
PermissionMap,
|
||||
MqTokenIssueRequest,
|
||||
MqTokenRecord,
|
||||
MqIssuedToken,
|
||||
BacklogStats,
|
||||
PeekedMessage,
|
||||
MqRawRequest,
|
||||
MqRawResponse,
|
||||
} from "@/types/mq";
|
||||
|
||||
// Connectivity
|
||||
export async function mqTestConnection(connectionId: string): Promise<MqClusterInfo> {
|
||||
return invoke("mq_test_connection", { connectionId });
|
||||
}
|
||||
|
||||
// Tenants
|
||||
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
|
||||
return invoke("mq_list_tenants", { connectionId });
|
||||
}
|
||||
|
||||
export async function mqGetTenant(connectionId: string, name: string): Promise<TenantInfo> {
|
||||
return invoke("mq_get_tenant", { connectionId, name });
|
||||
}
|
||||
|
||||
export async function mqCreateTenant(connectionId: string, name: string, config: TenantConfig): Promise<void> {
|
||||
return invoke("mq_create_tenant", { connectionId, name, config });
|
||||
}
|
||||
|
||||
export async function mqUpdateTenant(connectionId: string, name: string, config: TenantConfig): Promise<void> {
|
||||
return invoke("mq_update_tenant", { connectionId, name, config });
|
||||
}
|
||||
|
||||
export async function mqDeleteTenant(connectionId: string, name: string, force: boolean): Promise<void> {
|
||||
return invoke("mq_delete_tenant", { connectionId, name, force });
|
||||
}
|
||||
|
||||
// Namespaces
|
||||
export async function mqListNamespaces(connectionId: string, tenant: string): Promise<NamespaceInfo[]> {
|
||||
return invoke("mq_list_namespaces", { connectionId, tenant });
|
||||
}
|
||||
|
||||
export async function mqCreateNamespace(connectionId: string, ns: NamespaceRef, config: NamespaceConfig): Promise<void> {
|
||||
return invoke("mq_create_namespace", { connectionId, ns, config });
|
||||
}
|
||||
|
||||
export async function mqDeleteNamespace(connectionId: string, ns: NamespaceRef, force: boolean): Promise<void> {
|
||||
return invoke("mq_delete_namespace", { connectionId, ns, force });
|
||||
}
|
||||
|
||||
export async function mqGetNamespacePolicies(connectionId: string, ns: NamespaceRef): Promise<unknown> {
|
||||
return invoke("mq_get_namespace_policies", { connectionId, ns });
|
||||
}
|
||||
|
||||
// Topics
|
||||
export async function mqListTopics(connectionId: string, ns: NamespaceRef, opts: ListTopicsOpts): Promise<TopicInfo[]> {
|
||||
return invoke("mq_list_topics", { connectionId, ns, opts });
|
||||
}
|
||||
|
||||
export async function mqCreateTopic(connectionId: string, topic: TopicRef, partitions?: number): Promise<void> {
|
||||
return invoke("mq_create_topic", { connectionId, topic, partitions });
|
||||
}
|
||||
|
||||
export async function mqDeleteTopic(connectionId: string, topic: TopicRef, force: boolean): Promise<void> {
|
||||
return invoke("mq_delete_topic", { connectionId, topic, force });
|
||||
}
|
||||
|
||||
export async function mqUpdatePartitions(connectionId: string, topic: TopicRef, partitions: number): Promise<void> {
|
||||
return invoke("mq_update_partitions", { connectionId, topic, partitions });
|
||||
}
|
||||
|
||||
export async function mqGetTopicStats(connectionId: string, topic: TopicRef): Promise<TopicStats> {
|
||||
return invoke("mq_get_topic_stats", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqGetTopicInternalStats(connectionId: string, topic: TopicRef): Promise<unknown> {
|
||||
return invoke("mq_get_topic_internal_stats", { connectionId, topic });
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
export async function mqListSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return invoke("mq_list_subscriptions", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
export async function mqDeleteSubscription(connectionId: string, topic: TopicRef, sub: string, force: boolean): Promise<void> {
|
||||
return invoke("mq_delete_subscription", { connectionId, topic, sub, force });
|
||||
}
|
||||
|
||||
export async function mqSkipMessages(connectionId: string, topic: TopicRef, sub: string, count: SkipCount): Promise<void> {
|
||||
return invoke("mq_skip_messages", { connectionId, topic, sub, count });
|
||||
}
|
||||
|
||||
export async function mqResetCursor(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return invoke("mq_reset_cursor", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
export async function mqClearBacklog(connectionId: string, topic: TopicRef, sub: string): Promise<void> {
|
||||
return invoke("mq_clear_backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number): Promise<PeekedMessage[]> {
|
||||
return invoke("mq_peek_messages", { connectionId, topic, sub, count });
|
||||
}
|
||||
|
||||
export async function mqExpireMessages(connectionId: string, topic: TopicRef, sub: string, expireSeconds: number): Promise<void> {
|
||||
return invoke("mq_expire_messages", { connectionId, topic, sub, expireSeconds });
|
||||
}
|
||||
|
||||
// Producers / Consumers
|
||||
export async function mqListProducers(connectionId: string, topic: TopicRef): Promise<ProducerInfo[]> {
|
||||
return invoke("mq_list_producers", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqListConsumers(connectionId: string, topic: TopicRef, sub: string): Promise<ConsumerInfo[]> {
|
||||
return invoke("mq_list_consumers", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqUnloadTopic(connectionId: string, topic: TopicRef): Promise<void> {
|
||||
return invoke("mq_unload_topic", { connectionId, topic });
|
||||
}
|
||||
|
||||
// Policies
|
||||
export async function mqSetPublishRate(connectionId: string, scope: PolicyScope, rate: PublishRate): Promise<void> {
|
||||
return invoke("mq_set_publish_rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetDispatchRate(connectionId: string, scope: PolicyScope, rate: DispatchRate): Promise<void> {
|
||||
return invoke("mq_set_dispatch_rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetSubscribeRate(connectionId: string, scope: PolicyScope, rate: SubscribeRate): Promise<void> {
|
||||
return invoke("mq_set_subscribe_rate", { connectionId, scope, rate });
|
||||
}
|
||||
|
||||
export async function mqSetBacklogQuota(connectionId: string, scope: PolicyScope, quota: BacklogQuota): Promise<void> {
|
||||
return invoke("mq_set_backlog_quota", { connectionId, scope, quota });
|
||||
}
|
||||
|
||||
export async function mqSetRetention(connectionId: string, scope: PolicyScope, retention: RetentionPolicy): Promise<void> {
|
||||
return invoke("mq_set_retention", { connectionId, scope, retention });
|
||||
}
|
||||
|
||||
export async function mqGetEffectivePolicies(connectionId: string, scope: PolicyScope): Promise<unknown> {
|
||||
return invoke("mq_get_effective_policies", { connectionId, scope });
|
||||
}
|
||||
|
||||
// Permissions
|
||||
export async function mqGrantPermission(connectionId: string, scope: PolicyScope, role: string, actions: AuthAction[]): Promise<void> {
|
||||
return invoke("mq_grant_permission", { connectionId, scope, role, actions });
|
||||
}
|
||||
|
||||
export async function mqRevokePermission(connectionId: string, scope: PolicyScope, role: string): Promise<void> {
|
||||
return invoke("mq_revoke_permission", { connectionId, scope, role });
|
||||
}
|
||||
|
||||
export async function mqListPermissions(connectionId: string, scope: PolicyScope): Promise<PermissionMap> {
|
||||
return invoke("mq_list_permissions", { connectionId, scope });
|
||||
}
|
||||
|
||||
export async function mqIssueToken(connectionId: string, req: MqTokenIssueRequest): Promise<MqIssuedToken> {
|
||||
return invoke("mq_issue_token", { connectionId, req });
|
||||
}
|
||||
|
||||
export async function mqListTokenRecords(connectionId: string, subject?: string): Promise<MqTokenRecord[]> {
|
||||
return invoke("mq_list_token_records", { connectionId, subject });
|
||||
}
|
||||
|
||||
// Monitoring
|
||||
export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?: string): Promise<BacklogStats> {
|
||||
return invoke("mq_get_backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
// Raw request
|
||||
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
|
||||
return invoke("mq_raw_request", { connectionId, req });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const MQ_PINNED_VERSION_AUTO = "auto";
|
||||
|
||||
export interface MqPinnedVersionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const MQ_PINNED_VERSION_OPTIONS: MqPinnedVersionOption[] = [
|
||||
{
|
||||
value: MQ_PINNED_VERSION_AUTO,
|
||||
label: "Auto detect",
|
||||
description: "Probe /admin/v2/brokers/version and use the default 3.1.x profile if probing fails.",
|
||||
},
|
||||
{
|
||||
value: "3.1.x",
|
||||
label: "Apache Pulsar 3.1.x",
|
||||
description: "Supported Pulsar 3.x admin API profile, compatible with the 3.0/3.1 LTS line.",
|
||||
},
|
||||
];
|
||||
|
||||
export function pinnedVersionToSelection(pinnedVersion: string | null | undefined): string {
|
||||
const value = pinnedVersion?.trim();
|
||||
if (!value) return MQ_PINNED_VERSION_AUTO;
|
||||
if (/^3(?:\.|$)/.test(value)) return "3.1.x";
|
||||
return MQ_PINNED_VERSION_AUTO;
|
||||
}
|
||||
|
||||
export function selectionToPinnedVersion(selection: string): string | undefined {
|
||||
return MQ_PINNED_VERSION_OPTIONS.some((option) => option.value === selection) && selection !== MQ_PINNED_VERSION_AUTO ? selection : undefined;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { BacklogQuota, DispatchRate, PublishRate, RetentionPolicy, SubscribeRate } from "@/types/mq";
|
||||
|
||||
export interface MqPolicyForms {
|
||||
publishForm: PublishRate;
|
||||
dispatchForm: DispatchRate;
|
||||
subscribeForm: SubscribeRate;
|
||||
backlogForm: BacklogQuota;
|
||||
retentionForm: RetentionPolicy;
|
||||
}
|
||||
|
||||
export function defaultMqPolicyForms(): MqPolicyForms {
|
||||
return {
|
||||
publishForm: {
|
||||
publishThrottlingRateInMsg: 0,
|
||||
publishThrottlingRateInByte: 0,
|
||||
},
|
||||
dispatchForm: {
|
||||
dispatchThrottlingRateInMsg: 0,
|
||||
dispatchThrottlingRateInByte: 0,
|
||||
ratePeriodInSecond: 1,
|
||||
},
|
||||
subscribeForm: {
|
||||
subscribeThrottlingRatePerConsumer: 0,
|
||||
ratePeriodInSecond: 1,
|
||||
},
|
||||
backlogForm: {
|
||||
limitSize: -1,
|
||||
limitTime: -1,
|
||||
policy: "producer_request_hold",
|
||||
quotaType: "destination_storage",
|
||||
},
|
||||
retentionForm: {
|
||||
retentionTimeInMinutes: -1,
|
||||
retentionSizeInMb: -1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function policyFormsFromEffectivePolicies(raw: unknown, fallback: MqPolicyForms): MqPolicyForms {
|
||||
const root = objectRecord(raw);
|
||||
const topicPolicies = objectRecord(root.topicPolicies);
|
||||
const namespacePolicies = objectRecord(root.namespacePolicies);
|
||||
const namespaceSource = Object.keys(namespacePolicies).length ? namespacePolicies : root;
|
||||
|
||||
const publish = firstRecord(topicPolicies.publishRate, root.publishRate, namespaceSource.publishRate, namespaceSource.publishMaxMessageRate);
|
||||
const dispatch = firstRecord(topicPolicies.dispatchRate, root.dispatchRate, namespaceSource.dispatchRate);
|
||||
const subscribe = firstRecord(topicPolicies.subscribeRate, root.subscribeRate, namespaceSource.subscribeRate);
|
||||
const backlog = normalizeBacklogQuota(firstDefined(topicPolicies.backlogQuota, root.backlogQuota, namespaceSource.backlogQuota, namespaceSource.backlogQuotaMap, namespaceSource.backlog_quota_map));
|
||||
const retention = firstRecord(topicPolicies.retention, root.retention, namespaceSource.retention, namespaceSource.retentionPolicies, namespaceSource.retention_policies);
|
||||
|
||||
return {
|
||||
publishForm: publish
|
||||
? {
|
||||
publishThrottlingRateInMsg: numberField(publish.publishThrottlingRateInMsg) ?? fallback.publishForm.publishThrottlingRateInMsg,
|
||||
publishThrottlingRateInByte: numberField(publish.publishThrottlingRateInByte) ?? fallback.publishForm.publishThrottlingRateInByte,
|
||||
}
|
||||
: { ...fallback.publishForm },
|
||||
dispatchForm: dispatch
|
||||
? {
|
||||
dispatchThrottlingRateInMsg: numberField(dispatch.dispatchThrottlingRateInMsg) ?? fallback.dispatchForm.dispatchThrottlingRateInMsg,
|
||||
dispatchThrottlingRateInByte: numberField(dispatch.dispatchThrottlingRateInByte) ?? fallback.dispatchForm.dispatchThrottlingRateInByte,
|
||||
ratePeriodInSecond: numberField(dispatch.ratePeriodInSecond) ?? fallback.dispatchForm.ratePeriodInSecond,
|
||||
}
|
||||
: { ...fallback.dispatchForm },
|
||||
subscribeForm: subscribe
|
||||
? {
|
||||
subscribeThrottlingRatePerConsumer: numberField(subscribe.subscribeThrottlingRatePerConsumer) ?? fallback.subscribeForm.subscribeThrottlingRatePerConsumer,
|
||||
ratePeriodInSecond: numberField(subscribe.ratePeriodInSecond) ?? fallback.subscribeForm.ratePeriodInSecond,
|
||||
}
|
||||
: { ...fallback.subscribeForm },
|
||||
backlogForm: backlog
|
||||
? {
|
||||
limitSize: numberField(backlog.limitSize) ?? numberField(backlog.limit) ?? fallback.backlogForm.limitSize,
|
||||
limitTime: numberField(backlog.limitTime) ?? fallback.backlogForm.limitTime,
|
||||
policy: stringField(backlog.policy) || fallback.backlogForm.policy,
|
||||
quotaType: stringField(backlog.quotaType) || fallback.backlogForm.quotaType,
|
||||
}
|
||||
: { ...fallback.backlogForm },
|
||||
retentionForm: retention
|
||||
? {
|
||||
retentionTimeInMinutes: numberField(retention.retentionTimeInMinutes) ?? fallback.retentionForm.retentionTimeInMinutes,
|
||||
retentionSizeInMb: numberField(retention.retentionSizeInMb) ?? numberField(retention.retentionSizeInMB) ?? fallback.retentionForm.retentionSizeInMb,
|
||||
}
|
||||
: { ...fallback.retentionForm },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBacklogQuota(value: unknown): Record<string, unknown> | undefined {
|
||||
const quota = objectRecord(value);
|
||||
if (!Object.keys(quota).length) return undefined;
|
||||
if (hasQuotaFields(quota)) return quota;
|
||||
|
||||
const entries = Object.entries(quota);
|
||||
for (const [quotaType, body] of entries) {
|
||||
const normalized = objectRecord(body);
|
||||
if (hasQuotaFields(normalized)) {
|
||||
return {
|
||||
...normalized,
|
||||
quotaType: normalized.quotaType ?? quotaType,
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasQuotaFields(value: Record<string, unknown>): boolean {
|
||||
return "limit" in value || "limitSize" in value || "limitTime" in value || "policy" in value;
|
||||
}
|
||||
|
||||
function firstRecord(...values: unknown[]): Record<string, unknown> | undefined {
|
||||
for (const value of values) {
|
||||
const record = objectRecord(value);
|
||||
if (Object.keys(record).length) return record;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstDefined(...values: unknown[]) {
|
||||
return values.find((value) => value !== undefined && value !== null);
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function numberField(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { TenantConfig } from "@/types/mq";
|
||||
|
||||
export function normalizeClusterOptions(clusters: readonly unknown[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
if (typeof cluster !== "string") continue;
|
||||
const name = cluster.trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mqClusterOptionsFromExtra(extra: unknown): string[] {
|
||||
if (!extra || typeof extra !== "object") return [];
|
||||
|
||||
const clusters = (extra as { clusters?: unknown }).clusters;
|
||||
if (!Array.isArray(clusters)) return [];
|
||||
|
||||
return normalizeClusterOptions(clusters);
|
||||
}
|
||||
|
||||
export function defaultTenantConfig(clusterOptions: readonly string[]): TenantConfig {
|
||||
return {
|
||||
adminRoles: [],
|
||||
allowedClusters: normalizeClusterOptions(clusterOptions),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTenantForm(name: string | undefined, config: TenantConfig): string | undefined {
|
||||
if (!name?.trim()) {
|
||||
return "Tenant name is required";
|
||||
}
|
||||
if (!normalizeClusterOptions(config.allowedClusters).length) {
|
||||
return "Allowed clusters are required";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type MqTokenIssueErrorView =
|
||||
| {
|
||||
kind: "missingSigningKey";
|
||||
title: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
}
|
||||
| {
|
||||
kind: "generic";
|
||||
title?: undefined;
|
||||
message: string;
|
||||
detail?: undefined;
|
||||
};
|
||||
|
||||
export function formatMqTokenIssueError(error: unknown): MqTokenIssueErrorView {
|
||||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||
if (isMissingTokenSigningConfig(message)) {
|
||||
return {
|
||||
kind: "missingSigningKey",
|
||||
title: "未配置 Token 签发密钥",
|
||||
message: "当前 MQ 连接还没有配置 Broker Token 签发密钥,无法生成客户端 Token。",
|
||||
detail: "请编辑该连接,在 MQ 配置中设置“Broker Token 签发”为 HS256 SECRET 或 RS256 PRIVATE,并填写签发密钥后再生成。",
|
||||
};
|
||||
}
|
||||
return { kind: "generic", message };
|
||||
}
|
||||
|
||||
function isMissingTokenSigningConfig(message: string) {
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes("token signing is not configured") || normalized.includes("token signing key is required") || normalized.includes("broker token signing key is required");
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export interface SavedOpenTab {
|
||||
whereInput?: string;
|
||||
pinned?: boolean;
|
||||
mode?: QueryTab["mode"];
|
||||
mqTenant?: string;
|
||||
structureTableName?: string;
|
||||
objectBrowser?: QueryTab["objectBrowser"];
|
||||
objectSource?: QueryTab["objectSource"];
|
||||
@@ -69,6 +70,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
...(tab.whereInput !== undefined ? { whereInput: tab.whereInput } : {}),
|
||||
pinned: tab.pinned,
|
||||
mode: tab.mode,
|
||||
...(tab.mqTenant !== undefined ? { mqTenant: tab.mqTenant } : {}),
|
||||
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
|
||||
objectBrowser: tab.objectBrowser,
|
||||
objectSource: tab.objectSource,
|
||||
|
||||
@@ -19,6 +19,11 @@ export type ActiveTabSidebarTarget =
|
||||
type: "etcd-root";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "mq-tenant";
|
||||
connectionId: string;
|
||||
tenant: string;
|
||||
}
|
||||
| {
|
||||
type: "query-context";
|
||||
connectionId: string;
|
||||
@@ -60,6 +65,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
|
||||
return { type: "etcd-root", connectionId: tab.connectionId };
|
||||
}
|
||||
|
||||
if (tab.mode === "mq" && tab.mqTenant) {
|
||||
return { type: "mq-tenant", connectionId: tab.connectionId, tenant: tab.mqTenant };
|
||||
}
|
||||
|
||||
if (tab.savedSqlId) {
|
||||
return { type: "saved-sql-file", savedSqlId: tab.savedSqlId };
|
||||
}
|
||||
@@ -101,6 +110,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
|
||||
return node.type === "etcd-root" && node.connectionId === target.connectionId;
|
||||
}
|
||||
|
||||
if (target.type === "mq-tenant") {
|
||||
return node.type === "mq-tenant" && node.connectionId === target.connectionId && (node.mqTenant || node.label) === target.tenant;
|
||||
}
|
||||
|
||||
if (target.type === "saved-sql-file") {
|
||||
return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TreeNodeType } from "@/types/database";
|
||||
|
||||
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mongo-collection", "elasticsearch-index", "user-admin", "saved-sql-file", "load-more"]);
|
||||
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mq-tenant", "mongo-collection", "elasticsearch-index", "user-admin", "saved-sql-file", "load-more"]);
|
||||
|
||||
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "elasticsearch-index"]);
|
||||
|
||||
|
||||
@@ -1721,3 +1721,5 @@ export async function exportQueryResultMarkdown(filePath: string, columns: strin
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from "./mq-tauri";
|
||||
|
||||
@@ -7,7 +7,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none";
|
||||
export type SidebarActivation = "single" | "double";
|
||||
|
||||
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
|
||||
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mongo-collection", "elasticsearch-index", "user-admin"]);
|
||||
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "mongo-collection", "elasticsearch-index", "user-admin"]);
|
||||
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
|
||||
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "sequence", "package", "package-body"]);
|
||||
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function mqConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "mq-1",
|
||||
name: "Apache Pulsar",
|
||||
db_type: "mq",
|
||||
host: "127.0.0.1",
|
||||
port: 8080,
|
||||
user: "",
|
||||
password: "",
|
||||
database: "",
|
||||
readonly: false,
|
||||
read_only: false,
|
||||
ssl_mode: "disabled",
|
||||
color: "#888",
|
||||
external_config: {
|
||||
systemKind: "pulsar",
|
||||
adminUrl: "http://127.0.0.1:8080",
|
||||
auth: { kind: "none" },
|
||||
},
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
describe("connectionStore MQ sidebar tree", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("loads Pulsar tenants under a message queue connection", async () => {
|
||||
const mqListTenants = vi.fn().mockResolvedValue([
|
||||
{ name: "public", adminRoles: [], allowedClusters: [] },
|
||||
{ name: "tenant-a", adminRoles: [], allowedClusters: [] },
|
||||
]);
|
||||
|
||||
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/api", () => ({
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
listDatabases: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
mqListTenants,
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = mqConnection();
|
||||
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(mqListTenants).toHaveBeenCalledWith(connection.id);
|
||||
expect(node.children?.map((child) => ({ id: child.id, label: child.label, type: child.type, tenant: (child as TreeNode & { mqTenant?: string }).mqTenant }))).toEqual([
|
||||
{ id: "mq-1:mq-tenant:public", label: "public", type: "mq-tenant", tenant: "public" },
|
||||
{ id: "mq-1:mq-tenant:tenant-a", label: "tenant-a", type: "mq-tenant", tenant: "tenant-a" },
|
||||
]);
|
||||
expect(node.isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("stores the selected tenant when opening an MQ admin tab", async () => {
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
connectionStore.connections = [mqConnection()];
|
||||
|
||||
const firstTabId = queryStore.openMqAdmin("mq-1", { tenant: "public" });
|
||||
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("public");
|
||||
|
||||
const secondTabId = queryStore.openMqAdmin("mq-1", { tenant: "tenant-a" });
|
||||
expect(secondTabId).toBe(firstTabId);
|
||||
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("preserves the selected tenant when duplicating an MQ admin tab", async () => {
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
connectionStore.connections = [mqConnection()];
|
||||
|
||||
const tabId = queryStore.openMqAdmin("mq-1", { tenant: "public" });
|
||||
queryStore.duplicateTab(tabId);
|
||||
|
||||
expect(queryStore.tabs).toHaveLength(2);
|
||||
expect(queryStore.tabs[1]?.mqTenant).toBe("public");
|
||||
});
|
||||
});
|
||||
@@ -731,6 +731,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadMongoDatabases(connectionId);
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
await loadElasticsearchIndices(connectionId);
|
||||
} else if (config.db_type === "mq") {
|
||||
await loadMqTenants(connectionId, { force: true });
|
||||
} else {
|
||||
await loadDatabases(connectionId, { force: true });
|
||||
}
|
||||
@@ -1016,6 +1018,36 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) {
|
||||
const node = findNode(treeNodes.value, connectionId);
|
||||
if (!node) return;
|
||||
|
||||
node.isLoading = true;
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node, options)) return;
|
||||
|
||||
const tenants = await api.mqListTenants(connectionId);
|
||||
const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim()));
|
||||
setChildren(
|
||||
node,
|
||||
tenantNames.map((tenant) => ({
|
||||
id: schemaCacheKey(connectionId, "mq-tenant", tenant),
|
||||
label: tenant,
|
||||
type: "mq-tenant" as const,
|
||||
connectionId,
|
||||
mqTenant: tenant,
|
||||
})),
|
||||
);
|
||||
node.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
throw e;
|
||||
} finally {
|
||||
node.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRedisDbKeyStats(connectionId: string, db: number, stats: { loaded?: number; total?: number; totalDelta?: number }) {
|
||||
const node = findNode(treeNodes.value, `${connectionId}:db${db}`);
|
||||
if (!node || node.type !== "redis-db") return;
|
||||
@@ -1588,6 +1620,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
await loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "mq") {
|
||||
await loadMqTenants(node.connectionId, options);
|
||||
} else {
|
||||
await loadDatabases(node.connectionId, options);
|
||||
}
|
||||
@@ -2697,6 +2731,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
loadDatabases,
|
||||
loadRedisDatabases,
|
||||
loadEtcdRoot,
|
||||
loadMqTenants,
|
||||
updateRedisDbKeyStats,
|
||||
loadMongoDatabases,
|
||||
loadElasticsearchIndices,
|
||||
|
||||
@@ -479,6 +479,33 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
function openMqAdmin(connectionId: string, target?: { tenant?: string }) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "mq" && tab.connectionId === connectionId);
|
||||
if (existing) {
|
||||
if (target?.tenant) existing.mqTenant = target.tenant;
|
||||
activeTabId.value = existing.id;
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const conn = useConnectionStore().getConfig(connectionId);
|
||||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title: `${conn?.name || "Message Queue"} Admin`,
|
||||
connectionId,
|
||||
database: conn?.database || "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "mq",
|
||||
mqTenant: target?.tenant,
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string) {
|
||||
const resolvedTableName = tableName || "";
|
||||
if (resolvedTableName) {
|
||||
@@ -641,6 +668,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
isExplaining: false,
|
||||
explainExecutionId: undefined,
|
||||
mode: original.mode,
|
||||
mqTenant: original.mqTenant,
|
||||
structureTableName: original.structureTableName,
|
||||
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
|
||||
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
|
||||
@@ -1948,6 +1976,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
renameTab,
|
||||
openObjectBrowser,
|
||||
openUserAdmin,
|
||||
openMqAdmin,
|
||||
openTableStructure,
|
||||
linkSavedSql,
|
||||
openSavedSql,
|
||||
|
||||
@@ -52,7 +52,8 @@ export type DatabaseType =
|
||||
| "etcd"
|
||||
| "iris"
|
||||
| "influxdb"
|
||||
| "jdbc";
|
||||
| "jdbc"
|
||||
| "mq";
|
||||
|
||||
export interface SqlSnippet {
|
||||
id: string;
|
||||
@@ -100,6 +101,7 @@ export interface ConnectionConfig {
|
||||
redis_key_separator?: string;
|
||||
etcd_endpoints?: string;
|
||||
gbase_server?: string;
|
||||
external_config?: unknown;
|
||||
one_time?: boolean;
|
||||
read_only?: boolean;
|
||||
}
|
||||
@@ -419,6 +421,7 @@ export type TreeNodeType =
|
||||
| "fkey"
|
||||
| "trigger"
|
||||
| "redis-db"
|
||||
| "mq-tenant"
|
||||
| "etcd-root"
|
||||
| "mongo-db"
|
||||
| "mongo-collection"
|
||||
@@ -447,6 +450,7 @@ export interface TreeNode {
|
||||
pinned?: boolean;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
mqTenant?: string;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
comment?: string | null;
|
||||
@@ -517,7 +521,8 @@ export interface QueryTab {
|
||||
executionId?: string;
|
||||
isExplaining?: boolean;
|
||||
explainExecutionId?: string;
|
||||
mode: "data" | "query" | "redis" | "mongo" | "etcd" | "objects" | "structure" | "users";
|
||||
mode: "data" | "query" | "redis" | "mongo" | "etcd" | "mq" | "objects" | "structure" | "users";
|
||||
mqTenant?: string;
|
||||
structureTableName?: string;
|
||||
objectBrowser?: {
|
||||
schema?: string;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Message queue admin types, matching dbx-core/src/mq/types.rs
|
||||
|
||||
export type MqSystemKind = "pulsar" | "kafka" | "rocketmq";
|
||||
|
||||
export interface MqCapabilities {
|
||||
supportsTenants: boolean;
|
||||
supportsNamespaces: boolean;
|
||||
supportsPartitionedTopics: boolean;
|
||||
supportsSubscriptions: boolean;
|
||||
supportsCreateSubscription: boolean;
|
||||
supportsResetCursor: boolean;
|
||||
supportsSkipMessages: boolean;
|
||||
supportsClearBacklog: boolean;
|
||||
supportsPeekMessages: boolean;
|
||||
supportsExpireMessages: boolean;
|
||||
supportsRateLimits: boolean;
|
||||
supportsBacklogQuota: boolean;
|
||||
supportsRetention: boolean;
|
||||
supportsPermissions: boolean;
|
||||
supportsGeoReplication: boolean;
|
||||
supportsTokenManagement: boolean;
|
||||
supportsRawAdminApi: boolean;
|
||||
}
|
||||
|
||||
export interface MqClusterInfo {
|
||||
systemKind: MqSystemKind;
|
||||
serverVersion?: string;
|
||||
resolvedProfile: string;
|
||||
versionDetection: string;
|
||||
capabilities: MqCapabilities;
|
||||
extra?: unknown;
|
||||
}
|
||||
|
||||
export interface MqAuth {
|
||||
kind: "none" | "token" | "basic" | "apiKey" | "oauth2";
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
header?: string;
|
||||
value?: string;
|
||||
issuerUrl?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
audience?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface MqAdminConfig {
|
||||
systemKind: MqSystemKind;
|
||||
adminUrl: string;
|
||||
auth?: MqAuth;
|
||||
tlsSkipVerify?: boolean;
|
||||
pinnedVersion?: string;
|
||||
tokenSigning?: MqTokenSigningConfig;
|
||||
extra?: unknown;
|
||||
}
|
||||
|
||||
export type MqTokenSigningAlgorithm = "hs256" | "rs256";
|
||||
|
||||
export interface MqTokenSigningConfig {
|
||||
algorithm: MqTokenSigningAlgorithm;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface MqTokenIssueRequest {
|
||||
subject: string;
|
||||
expiresInSeconds?: number;
|
||||
scope?: PolicyScope;
|
||||
actions: AuthAction[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface MqTokenRecord {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
subject: string;
|
||||
algorithm: MqTokenSigningAlgorithm;
|
||||
tokenFingerprint: string;
|
||||
scope?: PolicyScope;
|
||||
actions: AuthAction[];
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface MqIssuedToken {
|
||||
token: string;
|
||||
record: MqTokenRecord;
|
||||
}
|
||||
|
||||
// Tenant
|
||||
export interface TenantInfo {
|
||||
name: string;
|
||||
adminRoles: string[];
|
||||
allowedClusters: string[];
|
||||
}
|
||||
|
||||
export interface TenantConfig {
|
||||
adminRoles: string[];
|
||||
allowedClusters: string[];
|
||||
}
|
||||
|
||||
// Namespace
|
||||
export interface NamespaceRef {
|
||||
tenant: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export interface NamespaceInfo {
|
||||
tenant: string;
|
||||
namespace: string;
|
||||
adminRoles: string[];
|
||||
}
|
||||
|
||||
export interface NamespaceConfig {
|
||||
clusters?: string[];
|
||||
bundles?: number;
|
||||
}
|
||||
|
||||
// Topic
|
||||
export interface TopicRef {
|
||||
tenant: string;
|
||||
namespace: string;
|
||||
topic: string;
|
||||
persistent: boolean;
|
||||
partitioned?: boolean;
|
||||
}
|
||||
|
||||
export interface TopicInfo {
|
||||
name: string;
|
||||
shortName: string;
|
||||
partitioned: boolean;
|
||||
partitions?: number;
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
export interface ListTopicsOpts {
|
||||
includeNonPersistent?: boolean;
|
||||
}
|
||||
|
||||
export interface TopicStats {
|
||||
msgRateIn: number;
|
||||
msgRateOut: number;
|
||||
msgThroughputIn: number;
|
||||
msgThroughputOut: number;
|
||||
storageSize: number;
|
||||
backlogSize: number;
|
||||
msgInCounter: number;
|
||||
msgOutCounter: number;
|
||||
subscriptionCount: number;
|
||||
producerCount: number;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
// Subscription
|
||||
export interface SubscriptionInfo {
|
||||
name: string;
|
||||
subType: string;
|
||||
msgBacklog: number;
|
||||
msgRateOut: number;
|
||||
msgThroughputOut: number;
|
||||
consumers: ConsumerInfo[];
|
||||
}
|
||||
|
||||
export interface ConsumerInfo {
|
||||
consumerName: string;
|
||||
msgRateOut: number;
|
||||
msgThroughputOut: number;
|
||||
availablePermits: number;
|
||||
address: string;
|
||||
clientVersion: string;
|
||||
}
|
||||
|
||||
export interface ProducerInfo {
|
||||
producerId: number;
|
||||
producerName: string;
|
||||
msgRateIn: number;
|
||||
msgThroughputIn: number;
|
||||
address: string;
|
||||
clientVersion: string;
|
||||
}
|
||||
|
||||
export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind: "timestamp"; timestampMs: number } | { kind: "messageId"; ledgerId: number; entryId: number };
|
||||
|
||||
export type SkipCount = { kind: "all" } | { kind: "count"; count: number };
|
||||
|
||||
export interface BacklogStats {
|
||||
msgBacklog: number;
|
||||
backlogSize: number;
|
||||
}
|
||||
|
||||
export interface PeekedMessage {
|
||||
position: number;
|
||||
messageId?: string;
|
||||
key?: string;
|
||||
publishTime?: string;
|
||||
eventTime?: string;
|
||||
properties: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
payloadBase64: string;
|
||||
payloadText?: string;
|
||||
}
|
||||
|
||||
// Policy scope
|
||||
export type PolicyScope = { level: "namespace"; tenant: string; namespace: string } | { level: "topic"; tenant: string; namespace: string; topic: string; persistent: boolean };
|
||||
|
||||
export interface PublishRate {
|
||||
publishThrottlingRateInMsg: number;
|
||||
publishThrottlingRateInByte: number;
|
||||
}
|
||||
|
||||
export interface DispatchRate {
|
||||
dispatchThrottlingRateInMsg: number;
|
||||
dispatchThrottlingRateInByte: number;
|
||||
ratePeriodInSecond: number;
|
||||
}
|
||||
|
||||
export interface SubscribeRate {
|
||||
subscribeThrottlingRatePerConsumer: number;
|
||||
ratePeriodInSecond: number;
|
||||
}
|
||||
|
||||
export interface BacklogQuota {
|
||||
limitSize: number;
|
||||
limitTime: number;
|
||||
policy: string;
|
||||
quotaType: string;
|
||||
}
|
||||
|
||||
export interface RetentionPolicy {
|
||||
retentionTimeInMinutes: number;
|
||||
retentionSizeInMb: number;
|
||||
}
|
||||
|
||||
export type AuthAction = "produce" | "consume" | "functions" | "sources" | "sinks" | "packages";
|
||||
|
||||
export type PermissionMap = Record<string, AuthAction[]>;
|
||||
|
||||
// Raw request
|
||||
export interface MqRawRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface MqRawResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
text?: string;
|
||||
}
|
||||
@@ -5,8 +5,9 @@ edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["duckdb-bundled"]
|
||||
default = ["duckdb-bundled", "mq-admin"]
|
||||
duckdb-bundled = ["duckdb/bundled"]
|
||||
mq-admin = []
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -44,6 +45,7 @@ portpicker = "0.1.1"
|
||||
csv = "1"
|
||||
calamine = "0.30.1"
|
||||
base64 = "0.22"
|
||||
jsonwebtoken = "9"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -1462,6 +1462,35 @@
|
||||
"driverManagement": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "mq",
|
||||
"label": "Message Queue",
|
||||
"runtimeMode": "native",
|
||||
"mcpMode": "unsupported",
|
||||
"singleConnectionPool": false,
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": true,
|
||||
"defaultPort": 8080,
|
||||
"supportLevel": "connect",
|
||||
"capabilities": {
|
||||
"queryExecution": false,
|
||||
"metadataBrowse": false,
|
||||
"objectBrowser": false,
|
||||
"objectSource": false,
|
||||
"schemaSearch": false,
|
||||
"diagram": false,
|
||||
"tableDataEdit": false,
|
||||
"tableStructureEdit": false,
|
||||
"tableImport": false,
|
||||
"dataTransfer": false,
|
||||
"sqlFileExecution": false,
|
||||
"databaseCreate": false,
|
||||
"fieldLineage": false,
|
||||
"sqlExplain": false,
|
||||
"userAdmin": false,
|
||||
"driverManagement": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "iotdb",
|
||||
"label": "Apache IoTDB",
|
||||
|
||||
@@ -10,6 +10,9 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ai::AiConfig;
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
|
||||
use crate::saved_sql::SavedSqlLibrary;
|
||||
use crate::storage::{DesktopSettings, Storage};
|
||||
@@ -23,6 +26,11 @@ const SECRET_KEYS: &[&str] = &[
|
||||
"proxy_password",
|
||||
"redis_sentinel_password",
|
||||
"connection_string",
|
||||
MQ_AUTH_TOKEN_KEY,
|
||||
MQ_AUTH_PASSWORD_KEY,
|
||||
MQ_AUTH_API_KEY_VALUE_KEY,
|
||||
MQ_AUTH_CLIENT_SECRET_KEY,
|
||||
MQ_TOKEN_SIGNING_KEY,
|
||||
];
|
||||
const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels.";
|
||||
const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers.";
|
||||
@@ -362,11 +370,44 @@ async fn build_sensitive_payload(
|
||||
if let Some(connection_string) = &config.connection_string {
|
||||
push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string);
|
||||
}
|
||||
push_mq_external_config_secrets(&mut connection_secrets, config);
|
||||
}
|
||||
|
||||
Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? })
|
||||
}
|
||||
|
||||
fn push_mq_external_config_secrets(secrets: &mut Vec<ConnectionSecretSnapshot>, config: &ConnectionConfig) {
|
||||
let Some(external_config) = config.external_config.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Some(auth) = external_config.get("auth").and_then(serde_json::Value::as_object) {
|
||||
match auth.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("token") => push_json_secret(secrets, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token"),
|
||||
Some("basic") => push_json_secret(secrets, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password"),
|
||||
Some("apiKey") | Some("api_key") | Some("apikey") => {
|
||||
push_json_secret(secrets, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")
|
||||
}
|
||||
Some("oauth2") => push_json_secret(secrets, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(signing) = external_config.get("tokenSigning").and_then(serde_json::Value::as_object) {
|
||||
push_json_secret(secrets, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_json_secret(
|
||||
secrets: &mut Vec<ConnectionSecretSnapshot>,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) {
|
||||
if let Some(secret) = object.get(field).and_then(serde_json::Value::as_str) {
|
||||
push_secret(secrets, connection_id, key, secret);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_secret(secrets: &mut Vec<ConnectionSecretSnapshot>, connection_id: &str, key: &str, secret: &str) {
|
||||
if secret.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -70,7 +70,14 @@ pub enum PoolKind {
|
||||
InfluxDb(db::influxdb_driver::InfluxdbClient),
|
||||
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
|
||||
ExternalTabular(ExternalTabularHandle),
|
||||
ExternalDriver { driver_id: String, config: Arc<ConnectionConfig>, session: Arc<PluginDriverSession> },
|
||||
ExternalDriver {
|
||||
driver_id: String,
|
||||
config: Arc<ConnectionConfig>,
|
||||
session: Arc<PluginDriverSession>,
|
||||
},
|
||||
/// Message queue admin connection (not a data query pool; serves as a
|
||||
/// marker that this connection_id is a valid MQ admin connection).
|
||||
MessageQueue,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -83,6 +90,8 @@ pub struct AppState {
|
||||
pub storage: Storage,
|
||||
pub plugins: PluginRegistry,
|
||||
pub agent_manager: crate::agent_manager::AgentManager,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mq_registry: crate::mq::MqAdminRegistry,
|
||||
}
|
||||
|
||||
pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
|
||||
@@ -263,6 +272,8 @@ impl AppState {
|
||||
agent_dir,
|
||||
app_version,
|
||||
),
|
||||
#[cfg(feature = "mq-admin")]
|
||||
mq_registry: crate::mq::MqAdminRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +754,23 @@ impl AppState {
|
||||
}
|
||||
self.external_driver_pool("jdbc", &jdbc_config).await?
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
DatabaseType::MessageQueue => {
|
||||
// MQ admin connections don't hold a data query pool. We just test
|
||||
// connectivity via the mq_registry and insert a marker so this
|
||||
// connection_id is recognized as valid.
|
||||
let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?;
|
||||
let adapter = self.mq_registry.build_transient_config(mqc).await?;
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::MessageQueue
|
||||
}
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
DatabaseType::MessageQueue => {
|
||||
return Err(
|
||||
"Message queue admin support is not compiled in this build. Rebuild with the 'mq-admin' feature."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
self.insert_connection_pool(pool_key.clone(), pool, &db_config).await;
|
||||
@@ -773,6 +801,21 @@ impl AppState {
|
||||
Ok(("127.0.0.1".to_string(), local_port))
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub async fn mq_admin_config_for_connection(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<crate::mq::config::MqAdminConfig, String> {
|
||||
let mqc = crate::mq::config::MqAdminConfig::from_connection(config)?;
|
||||
if !config.has_effective_transport_layers() {
|
||||
return Ok(mqc);
|
||||
}
|
||||
|
||||
let (host, port) = self.connection_host_port(connection_id, config).await?;
|
||||
Ok(mqc.with_connect_override(&host, port))
|
||||
}
|
||||
|
||||
async fn remove_stale_connection_pool(&self, pool_key: &str) -> bool {
|
||||
let stale = {
|
||||
let connections = self.connections.read().await;
|
||||
@@ -1104,6 +1147,10 @@ impl AppState {
|
||||
removed.push((key, pool));
|
||||
}
|
||||
}
|
||||
drop(conns);
|
||||
// Also drop the MQ admin adapter if this is an MQ connection.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
self.mq_registry.drop_connection(connection_id).await;
|
||||
removed
|
||||
}
|
||||
|
||||
@@ -1183,11 +1230,30 @@ fn connection_remote_endpoint(config: &ConnectionConfig) -> (String, u16) {
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(parse_jdbc_host_port)
|
||||
.unwrap_or_else(|| (config.host.clone(), config.port))
|
||||
} else if config.db_type == DatabaseType::MessageQueue {
|
||||
parse_mq_admin_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
|
||||
} else {
|
||||
(config.host.clone(), config.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mq_admin_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
|
||||
let value = config
|
||||
.external_config
|
||||
.as_ref()?
|
||||
.get("adminUrl")
|
||||
.or_else(|| config.external_config.as_ref()?.get("admin_url"))?
|
||||
.as_str()?
|
||||
.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let url = reqwest::Url::parse(value).ok()?;
|
||||
let host = url.host_str()?.to_string();
|
||||
let port = url.port_or_known_default()?;
|
||||
Some((host, port))
|
||||
}
|
||||
|
||||
fn normalize_client_session_id(client_session_id: Option<&str>) -> Option<String> {
|
||||
client_session_id.map(str::trim).filter(|session| !session.is_empty()).map(|session| session.replace(':', "_"))
|
||||
}
|
||||
@@ -1257,6 +1323,7 @@ pub async fn close_pool_kind(pool: PoolKind) {
|
||||
}
|
||||
PoolKind::ExternalTabular(_) => {}
|
||||
PoolKind::ExternalDriver { .. } => {}
|
||||
PoolKind::MessageQueue => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1513,9 +1580,9 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
agent_connect_timeout, connection_url_for_endpoint, database_connection_config, metadata_connection_config,
|
||||
mysql_metadata_fallback_url, redacted_connection_url_for_endpoint, uses_bare_mysql_pool, uses_tcp_probe,
|
||||
validate_h2_database_path, AppState, PoolKind,
|
||||
agent_connect_timeout, connection_remote_endpoint, connection_url_for_endpoint, database_connection_config,
|
||||
metadata_connection_config, mysql_metadata_fallback_url, redacted_connection_url_for_endpoint,
|
||||
uses_bare_mysql_pool, uses_tcp_probe, validate_h2_database_path, AppState, PoolKind,
|
||||
};
|
||||
use crate::agent_connection::{
|
||||
agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver,
|
||||
@@ -2420,6 +2487,56 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mq_remote_endpoint_comes_from_admin_url_when_host_fields_are_empty() {
|
||||
let mut config = mysql_config(None);
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.host = String::new();
|
||||
config.port = 0;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "https://broker.internal:8443/pulsar-admin?tenant=public",
|
||||
"auth": { "kind": "none" }
|
||||
}));
|
||||
|
||||
assert_eq!(connection_remote_endpoint(&config), ("broker.internal".to_string(), 8443));
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn mq_admin_config_preserves_admin_url_and_uses_forwarded_connect_override() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
let mut config = mysql_config(None);
|
||||
config.id = "proxied-mq".to_string();
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.host = String::new();
|
||||
config.port = 0;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "https://broker.internal:8443/pulsar-admin?tenant=public",
|
||||
"auth": { "kind": "none" }
|
||||
}));
|
||||
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
id: "proxy".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 65000,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
})];
|
||||
|
||||
let mqc = state.mq_admin_config_for_connection("proxied-mq", &config).await.unwrap();
|
||||
|
||||
assert_eq!(mqc.admin_url, "https://broker.internal:8443/pulsar-admin?tenant=public");
|
||||
let connect_override = mqc.connect_override.expect("MQ transport should set a connect override");
|
||||
assert_eq!(connect_override.host, "127.0.0.1");
|
||||
assert_ne!(connect_override.port, 8443);
|
||||
state.proxy_tunnels.stop_tunnel("proxied-mq:transport:0").await;
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a reachable GaussDB instance via environment variables"]
|
||||
async fn live_gaussdb_native_connection_succeeds() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,6 +11,13 @@ pub const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers.";
|
||||
pub const PROXY_PASSWORD_KEY: &str = "proxy_password";
|
||||
pub const REDIS_SENTINEL_PASSWORD_KEY: &str = "redis_sentinel_password";
|
||||
pub const CONNECTION_STRING_KEY: &str = "connection_string";
|
||||
pub const MQ_AUTH_SECRET_PREFIX: &str = "mq.auth.";
|
||||
pub const MQ_AUTH_TOKEN_KEY: &str = "mq.auth.token";
|
||||
pub const MQ_AUTH_PASSWORD_KEY: &str = "mq.auth.password";
|
||||
pub const MQ_AUTH_API_KEY_VALUE_KEY: &str = "mq.auth.api_key_value";
|
||||
pub const MQ_AUTH_CLIENT_SECRET_KEY: &str = "mq.auth.client_secret";
|
||||
pub const MQ_TOKEN_SIGNING_SECRET_PREFIX: &str = "mq.token_signing.";
|
||||
pub const MQ_TOKEN_SIGNING_KEY: &str = "mq.token_signing.key";
|
||||
|
||||
pub trait ConnectionSecretStore {
|
||||
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>;
|
||||
@@ -31,7 +38,23 @@ impl FileSecretStore {
|
||||
}
|
||||
|
||||
fn read_store(&self) -> HashMap<String, String> {
|
||||
std::fs::read_to_string(&self.path).ok().and_then(|json| serde_json::from_str(&json).ok()).unwrap_or_default()
|
||||
match std::fs::read_to_string(&self.path) {
|
||||
Ok(json) => match serde_json::from_str(&json) {
|
||||
Ok(map) => map,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse secret store at {:?}: {}. Returning empty store. This may indicate file corruption.",
|
||||
self.path, e
|
||||
);
|
||||
HashMap::default()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::default(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read secret store at {:?}: {}. Returning empty store.", self.path, e);
|
||||
HashMap::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_store(&self, map: &HashMap<String, String>) -> Result<(), String> {
|
||||
@@ -79,6 +102,8 @@ pub fn save_connections_to_file(
|
||||
}
|
||||
persist_secret(store, &config.id, REDIS_SENTINEL_PASSWORD_KEY, &config.redis_sentinel_password)?;
|
||||
persist_optional_secret(store, &config.id, CONNECTION_STRING_KEY, config.connection_string.as_deref())?;
|
||||
persist_mq_auth_secrets(store, config)?;
|
||||
persist_mq_token_signing_secret(store, config)?;
|
||||
|
||||
// New configs persist transport-layer secrets only. Remove legacy transport secret slots after the
|
||||
// migrated layer values have been written so old configs do not keep two sources of truth.
|
||||
@@ -133,6 +158,8 @@ pub fn load_connections_from_file(
|
||||
}
|
||||
}
|
||||
}
|
||||
hydrate_mq_auth_secrets(store, config, &mut needs_rewrite)?;
|
||||
hydrate_mq_token_signing_secret(store, config, &mut needs_rewrite)?;
|
||||
}
|
||||
|
||||
if needs_rewrite {
|
||||
@@ -294,6 +321,8 @@ fn delete_removed_connection_secrets(
|
||||
delete_secret_prefix(store, &config.id, SSH_TUNNEL_SECRET_PREFIX)?;
|
||||
delete_secret_prefix(store, &config.id, TRANSPORT_LAYER_SECRET_PREFIX)?;
|
||||
store.delete_secret(&config.id, CONNECTION_STRING_KEY)?;
|
||||
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -331,6 +360,193 @@ fn delete_secret_prefix(
|
||||
store.delete_secret_prefix(connection_id, key_prefix)
|
||||
}
|
||||
|
||||
fn persist_mq_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(auth) = mq_auth_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match mq_auth_kind(auth).as_deref() {
|
||||
Some("none") => delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?,
|
||||
Some("token") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token")?,
|
||||
Some("basic") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password")?,
|
||||
Some("apiKey") | Some("api_key") | Some("apikey") => {
|
||||
replace_mq_auth_secret(store, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")?
|
||||
}
|
||||
Some("oauth2") => replace_mq_auth_secret(store, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?,
|
||||
_ => delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_mq_auth_secret(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
|
||||
let existing = if current.is_none() { store.get_secret(connection_id, key)? } else { None };
|
||||
delete_secret_prefix(store, connection_id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
match current {
|
||||
Some(secret) => store.set_secret(connection_id, key, secret),
|
||||
None => match existing {
|
||||
Some(secret) => store.set_secret(connection_id, key, &secret),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_mq_token_signing_secret(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(signing) = mq_token_signing_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
persist_json_secret_if_present(store, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key")
|
||||
}
|
||||
|
||||
fn hydrate_mq_auth_secrets(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
config: &mut ConnectionConfig,
|
||||
needs_rewrite: &mut bool,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match mq_auth_kind(auth).as_deref() {
|
||||
Some("token") => hydrate_json_secret(store, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token", needs_rewrite)?,
|
||||
Some("basic") => hydrate_json_secret(store, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password", needs_rewrite)?,
|
||||
Some("apiKey") | Some("api_key") | Some("apikey") => {
|
||||
hydrate_json_secret(store, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value", needs_rewrite)?
|
||||
}
|
||||
Some("oauth2") => {
|
||||
hydrate_json_secret(store, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret", needs_rewrite)?
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hydrate_mq_token_signing_secret(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
config: &mut ConnectionConfig,
|
||||
needs_rewrite: &mut bool,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
hydrate_json_secret(store, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key", needs_rewrite)
|
||||
}
|
||||
|
||||
fn persist_json_secret_if_present(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
match auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
|
||||
Some(secret) => store.set_secret(connection_id, key, secret),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate_json_secret(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &mut serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
needs_rewrite: &mut bool,
|
||||
) -> Result<(), String> {
|
||||
match auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
|
||||
Some(secret) => {
|
||||
store.set_secret(connection_id, key, secret)?;
|
||||
*needs_rewrite = true;
|
||||
}
|
||||
None => {
|
||||
if let Some(secret) = store.get_secret(connection_id, key)? {
|
||||
auth.insert(field.to_string(), serde_json::Value::String(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scrub_mq_auth_secrets(config: &mut ConnectionConfig) {
|
||||
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
match mq_auth_kind(auth).as_deref() {
|
||||
Some("token") => scrub_json_secret(auth, "token"),
|
||||
Some("basic") => scrub_json_secret(auth, "password"),
|
||||
Some("apiKey") | Some("api_key") | Some("apikey") => scrub_json_secret(auth, "value"),
|
||||
Some("oauth2") => scrub_json_secret(auth, "clientSecret"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) {
|
||||
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
scrub_json_secret(signing, "key");
|
||||
}
|
||||
|
||||
fn scrub_json_secret(auth: &mut serde_json::Map<String, serde_json::Value>, field: &str) {
|
||||
if auth.contains_key(field) {
|
||||
auth.insert(field.to_string(), serde_json::Value::String(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn mq_auth_kind(auth: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
|
||||
auth.get("kind").and_then(serde_json::Value::as_str).map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn mq_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("auth")?.as_object()
|
||||
}
|
||||
|
||||
fn mq_auth_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("auth")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn mq_token_signing_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("tokenSigning")?.as_object()
|
||||
}
|
||||
|
||||
fn mq_token_signing_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("tokenSigning")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String {
|
||||
if hop.id.trim().is_empty() {
|
||||
index.to_string()
|
||||
@@ -398,6 +614,8 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
|
||||
}
|
||||
config.redis_sentinel_password.clear();
|
||||
config.connection_string = None;
|
||||
scrub_mq_auth_secrets(&mut config);
|
||||
scrub_mq_token_signing_secret(&mut config);
|
||||
config
|
||||
})
|
||||
.collect()
|
||||
@@ -411,7 +629,8 @@ pub fn secret_account(connection_id: &str, key: &str) -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
load_connections_from_file, save_connections_to_file, ConnectionSecretStore, CONNECTION_STRING_KEY,
|
||||
MAIN_PASSWORD_KEY, REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY,
|
||||
MAIN_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, REDIS_SENTINEL_PASSWORD_KEY,
|
||||
SSH_PASSWORD_KEY,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig};
|
||||
use std::cell::RefCell;
|
||||
@@ -669,4 +888,147 @@ mod tests {
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
assert_eq!(loaded[0].connection_string.as_deref(), Some("mongodb://user:secret@localhost/app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_moves_mq_auth_secrets_to_secret_store_and_restores_them() {
|
||||
let path = temp_connections_file("mq-auth");
|
||||
let store = MemorySecretStore::default();
|
||||
let mut config = connection("pulsar", "", "");
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": {
|
||||
"kind": "token",
|
||||
"token": "mq-token-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
save_connections_to_file(&path, &[config], &store).unwrap();
|
||||
|
||||
assert_eq!(store.get_existing("pulsar", "mq.auth.token").as_deref(), Some("mq-token-secret"));
|
||||
let persisted_json = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!persisted_json.contains("mq-token-secret"));
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
let auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).expect("restored MQ auth");
|
||||
assert_eq!(auth.get("token").and_then(serde_json::Value::as_str), Some("mq-token-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_moves_mq_basic_and_oauth_secrets_to_secret_store_and_restores_them() {
|
||||
let path = temp_connections_file("mq-auth-multiple");
|
||||
let store = MemorySecretStore::default();
|
||||
let mut basic = connection("basic", "", "");
|
||||
basic.db_type = DatabaseType::MessageQueue;
|
||||
basic.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": {
|
||||
"kind": "basic",
|
||||
"username": "admin",
|
||||
"password": "basic-secret"
|
||||
}
|
||||
}));
|
||||
let mut oauth = connection("oauth", "", "");
|
||||
oauth.db_type = DatabaseType::MessageQueue;
|
||||
oauth.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": {
|
||||
"kind": "oauth2",
|
||||
"issuerUrl": "https://issuer/token",
|
||||
"clientId": "client",
|
||||
"clientSecret": "oauth-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
save_connections_to_file(&path, &[basic, oauth], &store).unwrap();
|
||||
|
||||
assert_eq!(store.get_existing("basic", "mq.auth.password").as_deref(), Some("basic-secret"));
|
||||
assert_eq!(store.get_existing("oauth", "mq.auth.client_secret").as_deref(), Some("oauth-secret"));
|
||||
let persisted_json = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!persisted_json.contains("basic-secret"));
|
||||
assert!(!persisted_json.contains("oauth-secret"));
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
let basic_auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
|
||||
let oauth_auth = loaded[1].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
|
||||
assert_eq!(basic_auth.get("password").and_then(serde_json::Value::as_str), Some("basic-secret"));
|
||||
assert_eq!(oauth_auth.get("clientSecret").and_then(serde_json::Value::as_str), Some("oauth-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_preserves_existing_mq_secret_when_config_is_sanitized() {
|
||||
let path = temp_connections_file("mq-auth-preserve");
|
||||
let store = MemorySecretStore::default();
|
||||
store.set_existing("pulsar", "mq.auth.token", "existing-token");
|
||||
let mut config = connection("pulsar", "", "");
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": {
|
||||
"kind": "token",
|
||||
"token": ""
|
||||
}
|
||||
}));
|
||||
|
||||
save_connections_to_file(&path, &[config], &store).unwrap();
|
||||
|
||||
assert_eq!(store.get_existing("pulsar", "mq.auth.token").as_deref(), Some("existing-token"));
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
let auth = loaded[0].external_config.as_ref().and_then(|value| value.get("auth")).unwrap();
|
||||
assert_eq!(auth.get("token").and_then(serde_json::Value::as_str), Some("existing-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_deletes_stale_mq_auth_secrets_when_kind_changes() {
|
||||
let path = temp_connections_file("mq-auth-kind-change");
|
||||
let store = MemorySecretStore::default();
|
||||
store.set_existing("pulsar", MQ_AUTH_TOKEN_KEY, "old-token");
|
||||
let mut config = connection("pulsar", "", "");
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": {
|
||||
"kind": "basic",
|
||||
"username": "admin",
|
||||
"password": "basic-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
save_connections_to_file(&path, &[config], &store).unwrap();
|
||||
|
||||
assert_eq!(store.get_existing("pulsar", MQ_AUTH_TOKEN_KEY), None);
|
||||
assert_eq!(store.get_existing("pulsar", MQ_AUTH_PASSWORD_KEY).as_deref(), Some("basic-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_moves_mq_token_signing_key_to_secret_store_and_restores_it() {
|
||||
let path = temp_connections_file("mq-token-signing");
|
||||
let store = MemorySecretStore::default();
|
||||
let mut config = connection("pulsar", "", "");
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": { "kind": "none" },
|
||||
"tokenSigning": {
|
||||
"algorithm": "hs256",
|
||||
"key": "broker-signing-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
save_connections_to_file(&path, &[config], &store).unwrap();
|
||||
|
||||
assert_eq!(store.get_existing("pulsar", MQ_TOKEN_SIGNING_KEY).as_deref(), Some("broker-signing-secret"));
|
||||
let persisted_json = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!persisted_json.contains("broker-signing-secret"));
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
let signing = loaded[0].external_config.as_ref().and_then(|value| value.get("tokenSigning")).unwrap();
|
||||
assert_eq!(signing.get("key").and_then(serde_json::Value::as_str), Some("broker-signing-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod history;
|
||||
pub mod jdbc;
|
||||
pub mod models;
|
||||
pub mod mongo_ops;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq;
|
||||
pub mod object_source_sql;
|
||||
pub mod path_utils;
|
||||
pub mod plugins;
|
||||
|
||||
@@ -2,7 +2,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -301,6 +301,10 @@ pub enum DatabaseType {
|
||||
#[serde(rename = "questdb")]
|
||||
Questdb,
|
||||
Jdbc,
|
||||
/// Message queue admin connection (Pulsar / Kafka / RocketMQ). The specific
|
||||
/// system is determined by `external_config.systemKind`.
|
||||
#[serde(rename = "mq")]
|
||||
MessageQueue,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -768,6 +772,7 @@ impl ConnectionConfig {
|
||||
format!("{scheme}://{host}:{port}")
|
||||
}
|
||||
DatabaseType::Jdbc => "jdbc:<redacted>".to_string(),
|
||||
DatabaseType::MessageQueue => self.message_queue_admin_url(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,9 +973,21 @@ impl ConnectionConfig {
|
||||
DatabaseType::Jdbc => {
|
||||
self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string()
|
||||
}
|
||||
DatabaseType::MessageQueue => self.message_queue_admin_url(),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_queue_admin_url(&self) -> String {
|
||||
self.external_config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("adminUrl").or_else(|| value.get("admin_url")))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("mq://")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalized_url_params(&self) -> String {
|
||||
let value = self.url_params.as_deref().unwrap_or("").trim();
|
||||
if self.needs_bare_mysql() {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Message Queue Admin Module
|
||||
|
||||
Apache Pulsar 管理 API 的 Rust 实现。
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
mq/
|
||||
├── mod.rs - MqAdminRegistry 注册表
|
||||
├── types.rs - 类型定义
|
||||
├── auth.rs - 认证机制 (OAuth2 缓存)
|
||||
├── config.rs - 配置解析
|
||||
├── port.rs - MessageQueueAdmin trait
|
||||
├── service.rs - 服务层函数
|
||||
└── adapters/
|
||||
├── pulsar.rs - Pulsar 实现
|
||||
└── pulsar_version.rs - 版本探测
|
||||
```
|
||||
|
||||
## 核心 Trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MessageQueueAdmin: Send + Sync {
|
||||
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>;
|
||||
async fn list_namespaces(&self, tenant: &str) -> Result<Vec<NamespaceInfo>, String>;
|
||||
async fn list_topics(&self, ns: &NamespaceRef, opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String>;
|
||||
// ... 45 more methods
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
// AppState 会在存在 SSH/Proxy transport layer 时,把 Admin URL 改写到本地转发端点。
|
||||
let config = state.mq_admin_config_for_connection(&connection.id, &connection).await?;
|
||||
|
||||
let adapter = state.mq_registry.get_or_build_config(&connection.id, config).await?;
|
||||
let tenants = adapter.list_tenants().await?;
|
||||
```
|
||||
|
||||
通常业务代码优先复用 `service.rs` 中的 `mq_*_core` 函数;只有需要直接组合底层能力时才访问 `MqAdminRegistry`。
|
||||
|
||||
## Feature Flag
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["mq-admin"]
|
||||
mq-admin = []
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档请参考:`docs/mq-index.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Concrete message queue admin adapters.
|
||||
|
||||
pub mod pulsar;
|
||||
pub mod pulsar_version;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
//! Pulsar version detection and the version "profile" that isolates all
|
||||
//! version-specific behaviour (endpoint paths + response parsing + capability
|
||||
//! flags) behind one object. Adapter methods express *what* to do; the profile
|
||||
//! decides *how*, per the resolved version.
|
||||
//!
|
||||
//! First-party scope is **Pulsar 3.1.x** (REST-contract compatible with the
|
||||
//! 3.0.x LTS line). Newer versions are handled additively: add a `PulsarVersion`
|
||||
//! variant, add the differing path/parse branches here, and the adapter, service
|
||||
//! layer, commands, routes, and frontend stay untouched. See
|
||||
//! `docs/message-queue-admin-design.zh-CN.md` chapter 10.
|
||||
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::types::{MqCapabilities, SubscriptionInfo, TopicStats};
|
||||
|
||||
/// A recognised Pulsar major/minor line. Unknown versions fall back to the
|
||||
/// nearest known profile in conservative mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PulsarVersion {
|
||||
/// Pulsar 3.0.x / 3.1.x — the first-party baseline.
|
||||
V3_1,
|
||||
}
|
||||
|
||||
impl PulsarVersion {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PulsarVersion::V3_1 => "3.1.x",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the version was determined. Surfaced to the UI so users understand
|
||||
/// whether the adapter is running on a confirmed version.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VersionDetection {
|
||||
/// Probed from `brokers/version`.
|
||||
Probed,
|
||||
/// Pinned by the user in connection config.
|
||||
Pinned,
|
||||
/// Could not determine; using the default baseline profile.
|
||||
Fallback,
|
||||
}
|
||||
|
||||
impl VersionDetection {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
VersionDetection::Probed => "probed",
|
||||
VersionDetection::Pinned => "pinned",
|
||||
VersionDetection::Fallback => "fallback",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved version picture: which version, how we found it, and the raw string.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectedVersion {
|
||||
pub version: PulsarVersion,
|
||||
pub detection: VersionDetection,
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
/// The version profile: a single object that answers every version-dependent
|
||||
/// question. Adapter methods route all path construction and response parsing
|
||||
/// through here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PulsarApiProfile {
|
||||
version: PulsarVersion,
|
||||
detection: VersionDetection,
|
||||
raw_version: Option<String>,
|
||||
}
|
||||
|
||||
impl PulsarApiProfile {
|
||||
/// Resolve a profile from a detected version.
|
||||
pub fn resolve(detected: DetectedVersion) -> Self {
|
||||
Self { version: detected.version, detection: detected.detection, raw_version: detected.raw }
|
||||
}
|
||||
|
||||
/// The default baseline profile (3.1.x), used when detection fails.
|
||||
pub fn default_baseline() -> Self {
|
||||
Self { version: PulsarVersion::V3_1, detection: VersionDetection::Fallback, raw_version: None }
|
||||
}
|
||||
|
||||
pub fn version(&self) -> PulsarVersion {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn detection(&self) -> VersionDetection {
|
||||
self.detection
|
||||
}
|
||||
|
||||
pub fn raw_version(&self) -> Option<&str> {
|
||||
self.raw_version.as_deref()
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
self.version.label()
|
||||
}
|
||||
|
||||
/// Parse a raw broker version string (e.g. `3.1.2`, `3.0.7`, or
|
||||
/// `Pulsar version 3.1.2 ...`) into a known [`PulsarVersion`].
|
||||
pub fn parse_version(raw: &str) -> Option<PulsarVersion> {
|
||||
let trimmed = raw.trim();
|
||||
// Find the first dotted numeric token.
|
||||
let token = trimmed.split_whitespace().find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()));
|
||||
let token = token.unwrap_or(trimmed);
|
||||
let mut nums = token.split('.');
|
||||
let major: u32 = nums.next()?.trim().parse().ok()?;
|
||||
let _minor: u32 = nums.next().and_then(|m| m.trim().parse().ok()).unwrap_or(0);
|
||||
// 3.x (and the 3.0/3.1 LTS family) map onto the V3_1 baseline.
|
||||
match major {
|
||||
3 => Some(PulsarVersion::V3_1),
|
||||
// Newer majors are not yet first-party; caller decides fallback.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability flags for this version. Older versions missing endpoints would
|
||||
/// turn the corresponding flag off here so the UI hides them.
|
||||
pub fn capabilities(&self) -> MqCapabilities {
|
||||
match self.version {
|
||||
PulsarVersion::V3_1 => MqCapabilities {
|
||||
supports_tenants: true,
|
||||
supports_namespaces: true,
|
||||
supports_partitioned_topics: true,
|
||||
supports_subscriptions: true,
|
||||
supports_create_subscription: true,
|
||||
supports_reset_cursor: true,
|
||||
supports_skip_messages: true,
|
||||
supports_clear_backlog: true,
|
||||
supports_peek_messages: true,
|
||||
supports_expire_messages: true,
|
||||
supports_rate_limits: true,
|
||||
supports_backlog_quota: true,
|
||||
supports_retention: true,
|
||||
supports_permissions: true,
|
||||
supports_geo_replication: true,
|
||||
// Token signing is NOT a broker REST capability — see chapter 11.
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Path construction (all relative to the admin base) ----
|
||||
|
||||
pub fn broker_version_path(&self) -> String {
|
||||
"/admin/v2/brokers/version".to_string()
|
||||
}
|
||||
|
||||
pub fn clusters_path(&self) -> String {
|
||||
"/admin/v2/clusters".to_string()
|
||||
}
|
||||
|
||||
pub fn tenants_path(&self) -> String {
|
||||
"/admin/v2/tenants".to_string()
|
||||
}
|
||||
|
||||
pub fn tenant_path(&self, tenant: &str) -> String {
|
||||
format!("/admin/v2/tenants/{}", encode_segment(tenant))
|
||||
}
|
||||
|
||||
pub fn namespaces_path(&self, tenant: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}", encode_segment(tenant))
|
||||
}
|
||||
|
||||
pub fn namespace_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_policies_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
// GET on the namespace itself returns its policies.
|
||||
self.namespace_path(tenant, namespace)
|
||||
}
|
||||
|
||||
pub fn topics_path(&self, tenant: &str, namespace: &str, persistent: bool) -> String {
|
||||
let domain = if persistent { "persistent" } else { "non-persistent" };
|
||||
format!("/admin/v2/{domain}/{}/{}", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn partitioned_topics_path(&self, tenant: &str, namespace: &str, persistent: bool) -> String {
|
||||
let domain = if persistent { "persistent" } else { "non-persistent" };
|
||||
format!("/admin/v2/{domain}/{}/{}/partitioned", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
/// Base path for a single topic: `/admin/v2/{domain}/{tenant}/{ns}/{topic}`.
|
||||
pub fn topic_base_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_partitions_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/partitions", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_stats_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/stats", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_partitioned_stats_path(&self, domain: &str, topic_path: &str, per_partition: bool) -> String {
|
||||
let path = format!("/admin/v2/{domain}/{}/partitioned-stats", encode_topic_path(topic_path));
|
||||
if per_partition {
|
||||
format!("{path}?perPartition=true")
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn topic_internal_stats_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/internalStats", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn subscriptions_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/subscriptions", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn subscription_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/subscription/{}", encode_topic_path(topic_path), encode_segment(sub))
|
||||
}
|
||||
|
||||
pub fn subscription_reset_cursor_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/subscription/{}/resetcursor", encode_topic_path(topic_path), encode_segment(sub))
|
||||
}
|
||||
|
||||
pub fn subscription_skip_path(&self, domain: &str, topic_path: &str, sub: &str, count: u32) -> String {
|
||||
format!(
|
||||
"/admin/v2/{domain}/{}/subscription/{}/skip/{count}",
|
||||
encode_topic_path(topic_path),
|
||||
encode_segment(sub)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn subscription_skip_all_path(&self, domain: &str, topic_path: &str, sub: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/subscription/{}/skip_all", encode_topic_path(topic_path), encode_segment(sub))
|
||||
}
|
||||
|
||||
pub fn subscription_peek_path(&self, domain: &str, topic_path: &str, sub: &str, position: u32) -> String {
|
||||
format!(
|
||||
"/admin/v2/{domain}/{}/subscription/{}/position/{position}",
|
||||
encode_topic_path(topic_path),
|
||||
encode_segment(sub)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn subscription_expire_path(&self, domain: &str, topic_path: &str, sub: &str, expire_seconds: i64) -> String {
|
||||
format!(
|
||||
"/admin/v2/{domain}/{}/subscription/{}/expireMessages/{expire_seconds}",
|
||||
encode_topic_path(topic_path),
|
||||
encode_segment(sub)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn topic_permissions_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/permissions", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_permission_role_path(&self, domain: &str, topic_path: &str, role: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/permissions/{}", encode_topic_path(topic_path), encode_segment(role))
|
||||
}
|
||||
|
||||
pub fn namespace_permissions_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/permissions", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_permission_role_path(&self, tenant: &str, namespace: &str, role: &str) -> String {
|
||||
format!(
|
||||
"/admin/v2/namespaces/{}/{}/permissions/{}",
|
||||
encode_segment(tenant),
|
||||
encode_segment(namespace),
|
||||
encode_segment(role)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn namespace_publish_rate_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/publishRate", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_dispatch_rate_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/dispatchRate", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_subscribe_rate_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/subscribeRate", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_backlog_quota_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/backlogQuota", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn namespace_retention_path(&self, tenant: &str, namespace: &str) -> String {
|
||||
format!("/admin/v2/namespaces/{}/{}/retention", encode_segment(tenant), encode_segment(namespace))
|
||||
}
|
||||
|
||||
pub fn topic_publish_rate_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/publishRate", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_dispatch_rate_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/dispatchRate", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_backlog_quota_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/backlogQuota", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
pub fn topic_retention_path(&self, domain: &str, topic_path: &str) -> String {
|
||||
format!("/admin/v2/{domain}/{}/retention", encode_topic_path(topic_path))
|
||||
}
|
||||
|
||||
// ---- Response parsing (version-tolerant) ----
|
||||
|
||||
/// Parse the aggregated topic stats payload. Field names are read
|
||||
/// defensively so minor cross-version renames don't break parsing.
|
||||
pub fn parse_topic_stats(&self, raw: &serde_json::Value) -> TopicStats {
|
||||
let f64_of = |key: &str| raw.get(key).and_then(serde_json::Value::as_f64).unwrap_or(0.0);
|
||||
let i64_of = |key: &str| raw.get(key).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
|
||||
// Backlog appears as `backlogSize` (bytes) in newer stats; some payloads
|
||||
// expose `msgBacklog` per subscription only. Read both tolerantly.
|
||||
let backlog_size = raw
|
||||
.get("backlogSize")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.or_else(|| raw.get("backlog").and_then(serde_json::Value::as_i64))
|
||||
.unwrap_or(0);
|
||||
|
||||
let subscription_count =
|
||||
raw.get("subscriptions").and_then(serde_json::Value::as_object).map(|m| m.len() as u32).unwrap_or(0);
|
||||
|
||||
let producer_count = raw
|
||||
.get("publishers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.len() as u32)
|
||||
.or_else(|| raw.get("producerCount").and_then(serde_json::Value::as_u64).map(|v| v as u32))
|
||||
.unwrap_or(0);
|
||||
|
||||
TopicStats {
|
||||
msg_rate_in: f64_of("msgRateIn"),
|
||||
msg_rate_out: f64_of("msgRateOut"),
|
||||
msg_throughput_in: f64_of("msgThroughputIn"),
|
||||
msg_throughput_out: f64_of("msgThroughputOut"),
|
||||
storage_size: i64_of("storageSize"),
|
||||
backlog_size,
|
||||
msg_in_counter: i64_of("msgInCounter"),
|
||||
msg_out_counter: i64_of("msgOutCounter"),
|
||||
subscription_count,
|
||||
producer_count,
|
||||
raw: raw.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `subscriptions` map from a topic stats payload into a typed list.
|
||||
pub fn parse_subscriptions(&self, stats_raw: &serde_json::Value) -> Vec<SubscriptionInfo> {
|
||||
let mut by_name = std::collections::BTreeMap::new();
|
||||
let has_root_subscriptions = append_subscriptions_from_stats(stats_raw, &mut by_name, false);
|
||||
|
||||
if let Some(partitions) = stats_raw.get("partitions").and_then(serde_json::Value::as_object) {
|
||||
for partition in partitions.values() {
|
||||
append_subscriptions_from_stats(partition, &mut by_name, has_root_subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
by_name.into_values().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn append_subscriptions_from_stats(
|
||||
stats_raw: &serde_json::Value,
|
||||
by_name: &mut std::collections::BTreeMap<String, SubscriptionInfo>,
|
||||
keep_existing_metrics: bool,
|
||||
) -> bool {
|
||||
let Some(subs) = stats_raw.get("subscriptions").and_then(serde_json::Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
for (name, body) in subs {
|
||||
merge_subscription(by_name, subscription_from_stats(name, body), keep_existing_metrics);
|
||||
}
|
||||
!subs.is_empty()
|
||||
}
|
||||
|
||||
fn merge_subscription(
|
||||
by_name: &mut std::collections::BTreeMap<String, SubscriptionInfo>,
|
||||
mut incoming: SubscriptionInfo,
|
||||
keep_existing_metrics: bool,
|
||||
) {
|
||||
let Some(existing) = by_name.get_mut(&incoming.name) else {
|
||||
by_name.insert(incoming.name.clone(), incoming);
|
||||
return;
|
||||
};
|
||||
|
||||
if existing.sub_type.is_empty() && !incoming.sub_type.is_empty() {
|
||||
existing.sub_type = incoming.sub_type;
|
||||
}
|
||||
if keep_existing_metrics {
|
||||
existing.msg_backlog = existing.msg_backlog.max(incoming.msg_backlog);
|
||||
existing.msg_rate_out = existing.msg_rate_out.max(incoming.msg_rate_out);
|
||||
existing.msg_throughput_out = existing.msg_throughput_out.max(incoming.msg_throughput_out);
|
||||
} else {
|
||||
existing.msg_backlog = existing.msg_backlog.saturating_add(incoming.msg_backlog);
|
||||
existing.msg_rate_out += incoming.msg_rate_out;
|
||||
existing.msg_throughput_out += incoming.msg_throughput_out;
|
||||
}
|
||||
existing.consumers.append(&mut incoming.consumers);
|
||||
}
|
||||
|
||||
fn subscription_from_stats(name: &str, body: &serde_json::Value) -> SubscriptionInfo {
|
||||
let consumers = body
|
||||
.get("consumers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|arr| arr.iter().map(parse_consumer).collect())
|
||||
.unwrap_or_default();
|
||||
SubscriptionInfo {
|
||||
name: name.to_string(),
|
||||
sub_type: body.get("type").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
|
||||
msg_backlog: body.get("msgBacklog").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
msg_rate_out: body.get("msgRateOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
|
||||
msg_throughput_out: body.get("msgThroughputOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
|
||||
consumers,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_consumer(v: &serde_json::Value) -> super::super::types::ConsumerInfo {
|
||||
super::super::types::ConsumerInfo {
|
||||
consumer_name: v.get("consumerName").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
|
||||
msg_rate_out: v.get("msgRateOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
|
||||
msg_throughput_out: v.get("msgThroughputOut").and_then(serde_json::Value::as_f64).unwrap_or(0.0),
|
||||
available_permits: v.get("availablePermits").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
address: v.get("address").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
|
||||
client_version: v.get("clientVersion").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_segment(segment: &str) -> String {
|
||||
utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn encode_topic_path(topic_path: &str) -> String {
|
||||
let mut parts = topic_path.splitn(3, '/');
|
||||
let tenant = parts.next();
|
||||
let namespace = parts.next();
|
||||
let topic = parts.next();
|
||||
match (tenant, namespace, topic) {
|
||||
(Some(tenant), Some(namespace), Some(topic)) => {
|
||||
format!("{}/{}/{}", encode_segment(tenant), encode_segment(namespace), encode_segment(topic))
|
||||
}
|
||||
_ => topic_path.split('/').map(encode_segment).collect::<Vec<_>>().join("/"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_plain_version_strings() {
|
||||
assert_eq!(PulsarApiProfile::parse_version("3.1.2"), Some(PulsarVersion::V3_1));
|
||||
assert_eq!(PulsarApiProfile::parse_version("3.0.7"), Some(PulsarVersion::V3_1));
|
||||
assert_eq!(PulsarApiProfile::parse_version(" 3.1.0 "), Some(PulsarVersion::V3_1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_verbose_version_strings() {
|
||||
assert_eq!(PulsarApiProfile::parse_version("Pulsar version 3.1.2 (rev abc)"), Some(PulsarVersion::V3_1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_major_returns_none() {
|
||||
assert_eq!(PulsarApiProfile::parse_version("4.0.1"), None);
|
||||
assert_eq!(PulsarApiProfile::parse_version("not-a-version"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_profile_is_fallback_3_1() {
|
||||
let p = PulsarApiProfile::default_baseline();
|
||||
assert_eq!(p.version(), PulsarVersion::V3_1);
|
||||
assert_eq!(p.detection(), VersionDetection::Fallback);
|
||||
assert!(p.capabilities().supports_tenants);
|
||||
assert!(!p.capabilities().supports_token_management);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_topic_stats_fields() {
|
||||
let raw = serde_json::json!({
|
||||
"msgRateIn": 12.5,
|
||||
"msgRateOut": 7.0,
|
||||
"storageSize": 4096,
|
||||
"backlogSize": 128,
|
||||
"publishers": [{"producerName": "p1"}],
|
||||
"subscriptions": {
|
||||
"sub-a": {"msgBacklog": 10, "msgRateOut": 1.0, "type": "Shared", "consumers": []},
|
||||
"sub-b": {"msgBacklog": 0, "type": "Exclusive", "consumers": []}
|
||||
}
|
||||
});
|
||||
let profile = PulsarApiProfile::default_baseline();
|
||||
let stats = profile.parse_topic_stats(&raw);
|
||||
assert_eq!(stats.msg_rate_in, 12.5);
|
||||
assert_eq!(stats.storage_size, 4096);
|
||||
assert_eq!(stats.backlog_size, 128);
|
||||
assert_eq!(stats.subscription_count, 2);
|
||||
assert_eq!(stats.producer_count, 1);
|
||||
|
||||
let subs = profile.parse_subscriptions(&raw);
|
||||
assert_eq!(subs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_partitioned_stats_subscriptions() {
|
||||
let raw = serde_json::json!({
|
||||
"partitions": {
|
||||
"persistent://public/default/orders-partition-0": {
|
||||
"subscriptions": {
|
||||
"sub-a": {
|
||||
"msgBacklog": 3,
|
||||
"msgRateOut": 1.5,
|
||||
"msgThroughputOut": 128.0,
|
||||
"type": "Shared",
|
||||
"consumers": [{ "consumerName": "c0", "address": "/127.0.0.1:1000" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent://public/default/orders-partition-1": {
|
||||
"subscriptions": {
|
||||
"sub-a": {
|
||||
"msgBacklog": 5,
|
||||
"msgRateOut": 2.0,
|
||||
"msgThroughputOut": 256.0,
|
||||
"type": "Shared",
|
||||
"consumers": [{ "consumerName": "c1", "address": "/127.0.0.1:1001" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let profile = PulsarApiProfile::default_baseline();
|
||||
let subs = profile.parse_subscriptions(&raw);
|
||||
assert_eq!(subs.len(), 1);
|
||||
assert_eq!(subs[0].name, "sub-a");
|
||||
assert_eq!(subs[0].sub_type, "Shared");
|
||||
assert_eq!(subs[0].msg_backlog, 8);
|
||||
assert_eq!(subs[0].msg_rate_out, 3.5);
|
||||
assert_eq!(subs[0].msg_throughput_out, 384.0);
|
||||
assert_eq!(subs[0].consumers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_each_admin_path_segment() {
|
||||
let profile = PulsarApiProfile::default_baseline();
|
||||
assert_eq!(profile.tenant_path("tenant with/slash?x"), "/admin/v2/tenants/tenant%20with%2Fslash%3Fx");
|
||||
assert_eq!(profile.namespace_path("public", "default space"), "/admin/v2/namespaces/public/default%20space");
|
||||
assert_eq!(
|
||||
profile.topic_base_path("persistent", "public/default/orders/a?b"),
|
||||
"/admin/v2/persistent/public/default/orders%2Fa%3Fb"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.subscription_path("persistent", "public/default/orders/a?b", "sub/role #1"),
|
||||
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/subscription/sub%2Frole%20%231"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.topic_permission_role_path("persistent", "public/default/orders", "role/a?b"),
|
||||
"/admin/v2/persistent/public/default/orders/permissions/role%2Fa%3Fb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partitioned_stats_path_encodes_topic_and_requests_partition_detail() {
|
||||
let profile = PulsarApiProfile::default_baseline();
|
||||
assert_eq!(
|
||||
profile.topic_partitioned_stats_path("persistent", "b2b/ec-product-service/goods_status_change", true),
|
||||
"/admin/v2/persistent/b2b/ec%2Dproduct%2Dservice/goods%5Fstatus%5Fchange/partitioned-stats?perPartition=true"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.topic_partitioned_stats_path("persistent", "public/default/orders/a?b", true),
|
||||
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/partitioned-stats?perPartition=true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_path_encodes_topic_and_subscription_segments() {
|
||||
let profile = PulsarApiProfile::default_baseline();
|
||||
assert_eq!(
|
||||
profile.subscription_peek_path("persistent", "public/default/orders/a?b", "sub/role #1", 3),
|
||||
"/admin/v2/persistent/public/default/orders%2Fa%3Fb/subscription/sub%2Frole%20%231/position/3"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Authentication for message queue admin connections. Injects credentials
|
||||
//! into outgoing `reqwest` requests, with token caching for OAuth2
|
||||
//! client-credentials so we don't exchange a token on every call.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::util::truncate;
|
||||
|
||||
/// Authentication method for an MQ admin connection.
|
||||
///
|
||||
/// Sensitive fields (token / password / secret) are stored through the
|
||||
/// project's encrypted connection-secrets / keychain path and are never logged.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum MqAuth {
|
||||
None,
|
||||
/// Pulsar JWT bearer token.
|
||||
Token {
|
||||
token: String,
|
||||
},
|
||||
/// HTTP Basic auth.
|
||||
Basic {
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
/// Arbitrary API key header, e.g. `Authorization: <value>` or a custom header.
|
||||
ApiKey {
|
||||
header: String,
|
||||
value: String,
|
||||
},
|
||||
/// OAuth2 client-credentials flow (Pulsar's `oauth2` auth plugin).
|
||||
OAuth2 {
|
||||
issuer_url: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
audience: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
scope: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for MqAuth {
|
||||
fn default() -> Self {
|
||||
MqAuth::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches an OAuth2 access token with its expiry so repeated requests reuse it.
|
||||
/// Also tracks in-flight token requests to prevent concurrent fetches.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TokenCache {
|
||||
inner: Arc<Mutex<TokenCacheState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TokenCacheState {
|
||||
cached: Option<CachedToken>,
|
||||
/// Tracks whether a token fetch is currently in progress. Subsequent requests
|
||||
/// will wait on this shared future instead of issuing duplicate OAuth2 requests.
|
||||
#[allow(clippy::type_complexity)]
|
||||
in_flight: Option<Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedToken {
|
||||
token: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// Refresh the cached token slightly before it actually expires.
|
||||
const TOKEN_EXPIRY_SKEW: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuth2TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
impl MqAuth {
|
||||
/// Apply this auth method to a request builder, fetching/refreshing an
|
||||
/// OAuth2 token through `http`/`cache` if necessary.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
req: reqwest::RequestBuilder,
|
||||
http: &reqwest::Client,
|
||||
cache: &TokenCache,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
match self {
|
||||
MqAuth::None => Ok(req),
|
||||
MqAuth::Token { token } => Ok(req.bearer_auth(token)),
|
||||
MqAuth::Basic { username, password } => Ok(req.basic_auth(username, Some(password))),
|
||||
MqAuth::ApiKey { header, value } => Ok(req.header(header.as_str(), value.as_str())),
|
||||
MqAuth::OAuth2 { .. } => {
|
||||
let token = self.fetch_oauth2_token(http, cache).await?;
|
||||
Ok(req.bearer_auth(token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_oauth2_token(&self, http: &reqwest::Client, cache: &TokenCache) -> Result<String, String> {
|
||||
let MqAuth::OAuth2 { issuer_url, client_id, client_secret, audience, scope } = self else {
|
||||
return Err("not an OAuth2 auth method".to_string());
|
||||
};
|
||||
|
||||
// Fast path: reuse a still-valid cached token.
|
||||
let notify = {
|
||||
let mut guard = cache.inner.lock().await;
|
||||
if let Some(cached) = &guard.cached {
|
||||
if cached.expires_at > Instant::now() {
|
||||
return Ok(cached.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if another task is already fetching a token
|
||||
if let Some(existing_notify) = &guard.in_flight {
|
||||
let notify = existing_notify.clone();
|
||||
drop(guard);
|
||||
// Wait for the in-flight request to complete
|
||||
notify.notified().await;
|
||||
// Re-check the cache after waiting
|
||||
let guard = cache.inner.lock().await;
|
||||
if let Some(cached) = &guard.cached {
|
||||
if cached.expires_at > Instant::now() {
|
||||
return Ok(cached.token.clone());
|
||||
}
|
||||
}
|
||||
// Token still expired after waiting, we'll fetch ourselves
|
||||
return Err("Token fetch by another task failed or token still expired".to_string());
|
||||
}
|
||||
|
||||
// Mark that we're now fetching
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
guard.in_flight = Some(notify.clone());
|
||||
notify
|
||||
};
|
||||
|
||||
// Fetch the token (without holding the lock)
|
||||
let result = self.do_oauth2_token_fetch(http, issuer_url, client_id, client_secret, audience, scope).await;
|
||||
|
||||
// Update cache and clear in-flight flag
|
||||
{
|
||||
let mut guard = cache.inner.lock().await;
|
||||
guard.in_flight = None;
|
||||
|
||||
match result {
|
||||
Ok((token, ttl)) => {
|
||||
let expires_at = Instant::now() + Duration::from_secs(ttl).saturating_sub(TOKEN_EXPIRY_SKEW);
|
||||
guard.cached = Some(CachedToken { token: token.clone(), expires_at });
|
||||
notify.notify_waiters();
|
||||
Ok(token)
|
||||
}
|
||||
Err(e) => {
|
||||
notify.notify_waiters();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_oauth2_token_fetch(
|
||||
&self,
|
||||
http: &reqwest::Client,
|
||||
issuer_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
audience: &Option<String>,
|
||||
scope: &Option<String>,
|
||||
) -> Result<(String, u64), String> {
|
||||
let mut form = vec![
|
||||
("grant_type", "client_credentials".to_string()),
|
||||
("client_id", client_id.to_string()),
|
||||
("client_secret", client_secret.to_string()),
|
||||
];
|
||||
if let Some(audience) = audience {
|
||||
form.push(("audience", audience.clone()));
|
||||
}
|
||||
if let Some(scope) = scope {
|
||||
form.push(("scope", scope.clone()));
|
||||
}
|
||||
|
||||
let resp =
|
||||
http.post(issuer_url).form(&form).send().await.map_err(|e| format!("OAuth2 token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let detail = oauth2_error_detail(&body).unwrap_or_else(|| truncate(&body, 300));
|
||||
return Err(format!("OAuth2 token endpoint returned {status}: {detail}"));
|
||||
}
|
||||
|
||||
let parsed: OAuth2TokenResponse =
|
||||
resp.json().await.map_err(|e| format!("Failed to parse OAuth2 token response: {e}"))?;
|
||||
|
||||
let ttl = parsed.expires_in.unwrap_or(3600);
|
||||
Ok((parsed.access_token, ttl))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a human-readable OAuth2 error detail from a JSON error response.
|
||||
/// Returns `None` when the body is not a valid OAuth2 error payload.
|
||||
fn oauth2_error_detail(body: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let error = value.get("error")?.as_str()?;
|
||||
let description = value.get("error_description").and_then(serde_json::Value::as_str);
|
||||
Some(match description {
|
||||
Some(desc) => format!("{error}: {desc}"),
|
||||
None => error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auth_default_is_none() {
|
||||
assert!(matches!(MqAuth::default(), MqAuth::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_auth_serde_roundtrip() {
|
||||
let auth = MqAuth::Token { token: "abc".to_string() };
|
||||
let json = serde_json::to_string(&auth).expect("should serialize MqAuth");
|
||||
assert!(json.contains("\"kind\":\"token\""));
|
||||
let back: MqAuth = serde_json::from_str(&json).expect("should deserialize MqAuth");
|
||||
assert!(matches!(back, MqAuth::Token { token } if token == "abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_optional_fields_skipped_when_absent() {
|
||||
let auth = MqAuth::OAuth2 {
|
||||
issuer_url: "https://issuer/token".to_string(),
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
audience: None,
|
||||
scope: None,
|
||||
};
|
||||
let json = serde_json::to_string(&auth).expect("should serialize OAuth2 auth");
|
||||
assert!(!json.contains("audience"));
|
||||
assert!(!json.contains("scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_error_detail_parses_standard_error() {
|
||||
let body = r#"{"error":"invalid_client","error_description":"Client authentication failed"}"#;
|
||||
assert_eq!(oauth2_error_detail(body).as_deref(), Some("invalid_client: Client authentication failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_error_detail_returns_none_for_non_json() {
|
||||
assert!(oauth2_error_detail("not json").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Parses the message queue admin configuration out of a `ConnectionConfig`.
|
||||
//!
|
||||
//! MQ admin connections reuse the generic `external_config` extension slot on
|
||||
//! `ConnectionConfig` rather than adding top-level fields, keeping the 50+
|
||||
//! database-type connection model untouched.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::types::{MqSystemKind, MqTokenSigningConfig};
|
||||
|
||||
/// Runtime TCP endpoint override for MQ admin requests.
|
||||
///
|
||||
/// The public admin URL remains unchanged so TLS hostname verification, SNI and
|
||||
/// the HTTP Host header continue to target the broker name. The HTTP client uses
|
||||
/// this endpoint only for the underlying TCP connection, e.g. after an SSH/proxy
|
||||
/// tunnel has mapped the broker to a local port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqConnectOverride {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Configuration for an MQ admin connection, decoded from
|
||||
/// `ConnectionConfig.external_config`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqAdminConfig {
|
||||
pub system_kind: MqSystemKind,
|
||||
/// Admin REST base URL, e.g. `http://broker:8080`.
|
||||
pub admin_url: String,
|
||||
#[serde(default)]
|
||||
pub auth: MqAuth,
|
||||
/// Skip TLS certificate verification (self-signed clusters only).
|
||||
#[serde(default)]
|
||||
pub tls_skip_verify: bool,
|
||||
/// Manually pin a server version (e.g. `3.1`), skipping auto-detection for
|
||||
/// environments where the version endpoint is blocked by a gateway.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pinned_version: Option<String>,
|
||||
/// Optional local JWT signing configuration. This is used only by dbx to
|
||||
/// issue Pulsar client tokens; the signing key itself is stored through the
|
||||
/// connection secret path, not in plain connection JSON.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub token_signing: Option<MqTokenSigningConfig>,
|
||||
/// Runtime-only TCP endpoint override used by transport layers.
|
||||
#[serde(skip)]
|
||||
pub connect_override: Option<MqConnectOverride>,
|
||||
/// System-specific extension fields (e.g. Kafka bootstrap servers).
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
impl MqAdminConfig {
|
||||
/// Decode the MQ admin config from a connection's `external_config` slot.
|
||||
pub fn from_connection(cfg: &ConnectionConfig) -> Result<Self, String> {
|
||||
let raw = cfg
|
||||
.external_config
|
||||
.as_ref()
|
||||
.ok_or("This connection has no message queue admin configuration (external_config is empty)")?;
|
||||
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();
|
||||
if parsed.admin_url.is_empty() {
|
||||
return Err("Message queue admin URL is empty".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn token_signing_configured(&self) -> bool {
|
||||
self.token_signing.as_ref().is_some_and(MqTokenSigningConfig::is_configured)
|
||||
}
|
||||
|
||||
pub fn with_admin_endpoint(mut self, host: &str, port: u16) -> Result<Self, String> {
|
||||
self.admin_url = admin_url_with_endpoint(&self.admin_url, host, port)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_connect_override(mut self, host: &str, port: u16) -> Self {
|
||||
self.connect_override = Some(MqConnectOverride { host: host.to_string(), port });
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn admin_url_with_endpoint(admin_url: &str, host: &str, port: u16) -> Result<String, String> {
|
||||
let mut url = reqwest::Url::parse(admin_url).map_err(|e| format!("MQ Admin URL is invalid: {e}"))?;
|
||||
url.set_host(Some(host)).map_err(|_| format!("MQ Admin URL host is invalid: {host}"))?;
|
||||
url.set_port(Some(port)).map_err(|_| format!("MQ Admin URL port is invalid: {port}"))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
|
||||
let mut cfg = ConnectionConfig {
|
||||
id: "c1".to_string(),
|
||||
name: "mq".to_string(),
|
||||
db_type: crate::models::connection::DatabaseType::MessageQueue,
|
||||
driver_profile: None,
|
||||
driver_label: None,
|
||||
url_params: None,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
database: None,
|
||||
visible_databases: None,
|
||||
attached_databases: Vec::new(),
|
||||
color: None,
|
||||
transport_layers: Vec::new(),
|
||||
connect_timeout_secs: 5,
|
||||
query_timeout_secs: 30,
|
||||
idle_timeout_secs: 60,
|
||||
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
redis_key_separator: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
external_config: Some(value),
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
};
|
||||
cfg.redis_key_separator = ":".to_string();
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_pulsar_config() {
|
||||
let cfg = connection_with_external(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://localhost:8080",
|
||||
"auth": { "kind": "none" }
|
||||
}));
|
||||
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse valid Pulsar config");
|
||||
assert_eq!(mqc.system_kind, MqSystemKind::Pulsar);
|
||||
assert_eq!(mqc.admin_url, "http://localhost:8080");
|
||||
assert!(matches!(mqc.auth, MqAuth::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_external_config_missing() {
|
||||
let mut cfg = connection_with_external(serde_json::Value::Null);
|
||||
cfg.external_config = None;
|
||||
assert!(MqAdminConfig::from_connection(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_empty_admin_url() {
|
||||
let cfg = connection_with_external(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": " "
|
||||
}));
|
||||
assert!(MqAdminConfig::from_connection(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_url_with_endpoint_preserves_scheme_path_and_query() {
|
||||
let rewritten =
|
||||
admin_url_with_endpoint("https://broker.internal:8443/pulsar-admin?tenant=public", "127.0.0.1", 49152)
|
||||
.expect("should rewrite admin URL with endpoint");
|
||||
|
||||
assert_eq!(rewritten, "https://127.0.0.1:49152/pulsar-admin?tenant=public");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Message queue admin console support.
|
||||
//!
|
||||
//! Provides a pluggable, port-adapter abstraction for managing message queue
|
||||
//! systems (Apache Pulsar today; Kafka / RocketMQ are reserved). The module is
|
||||
//! gated behind the `mq-admin` Cargo feature so builds that don't need it pay
|
||||
//! nothing.
|
||||
//!
|
||||
//! Architecture mirrors the existing `agent_kv` pattern: business logic lives in
|
||||
//! `service::*_core` functions shared by the desktop command layer and the web
|
||||
//! route layer; this module owns the trait, the typed model, and the registry
|
||||
//! that caches one adapter per connection.
|
||||
|
||||
pub mod adapters;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod port;
|
||||
pub mod service;
|
||||
pub mod token;
|
||||
pub mod types;
|
||||
pub(crate) mod util;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::mq::adapters::pulsar::PulsarAdmin;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::port::MessageQueueAdmin;
|
||||
use crate::mq::types::MqSystemKind as MqSystemKindInternal;
|
||||
|
||||
pub use crate::mq::auth::MqAuth;
|
||||
pub use crate::mq::config::MqAdminConfig as MqConfig;
|
||||
pub use crate::mq::types::*;
|
||||
|
||||
/// Caches one live admin adapter per connection id. Adapters are built lazily on
|
||||
/// first use and dropped when the connection is closed.
|
||||
///
|
||||
/// Each connection has its own build-lock so concurrent first-use requests for
|
||||
/// the same connection block until the first builder finishes, rather than both
|
||||
/// racing to construct an adapter.
|
||||
#[derive(Default)]
|
||||
pub struct MqAdminRegistry {
|
||||
instances: RwLock<HashMap<String, Arc<dyn MessageQueueAdmin>>>,
|
||||
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
|
||||
}
|
||||
|
||||
impl MqAdminRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// Return the cached adapter for this connection, building it from the
|
||||
/// connection's `external_config` if not already present.
|
||||
pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.get_or_build_config(&cfg.id, mqc).await
|
||||
}
|
||||
|
||||
pub async fn get_or_build_config(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
mqc: MqAdminConfig,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
// Fast path: return the cached adapter.
|
||||
if let Some(adapter) = self.instances.read().await.get(connection_id) {
|
||||
return Ok(adapter.clone());
|
||||
}
|
||||
|
||||
// Slow path: acquire a per-connection build lock so only one task
|
||||
// constructs the adapter at a time.
|
||||
let lock = {
|
||||
let mut locks = self.build_locks.write().await;
|
||||
locks.entry(connection_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
|
||||
};
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
// Another task may have built it while we were waiting for the lock.
|
||||
if let Some(adapter) = self.instances.read().await.get(connection_id) {
|
||||
return Ok(adapter.clone());
|
||||
}
|
||||
|
||||
let adapter = build_adapter(mqc).await?;
|
||||
self.instances.write().await.insert(connection_id.to_string(), adapter.clone());
|
||||
Ok(adapter)
|
||||
}
|
||||
|
||||
/// Drop the cached adapter for a connection (called on disconnect).
|
||||
pub async fn drop_connection(&self, connection_id: &str) {
|
||||
self.instances.write().await.remove(connection_id);
|
||||
self.build_locks.write().await.remove(connection_id);
|
||||
}
|
||||
|
||||
/// Build a fresh adapter without caching it — used for connection tests
|
||||
/// where we don't want to retain state.
|
||||
pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.build_transient_config(mqc).await
|
||||
}
|
||||
|
||||
pub async fn build_transient_config(&self, mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_adapter(mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
match mqc.system_kind {
|
||||
MqSystemKindInternal::Pulsar => {
|
||||
let adapter = PulsarAdmin::new(mqc).await?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
MqSystemKindInternal::Kafka => Err("Kafka admin is not yet implemented".to_string()),
|
||||
MqSystemKindInternal::RocketMq => Err("RocketMQ admin is not yet implemented".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The message queue admin port — the abstraction all MQ systems implement.
|
||||
//!
|
||||
//! Commands and routes program against this trait only; concrete systems
|
||||
//! (Pulsar today, Kafka / RocketMQ later) live in `adapters/`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::mq::types::*;
|
||||
|
||||
/// Unified management interface for message queue systems.
|
||||
#[async_trait]
|
||||
pub trait MessageQueueAdmin: Send + Sync {
|
||||
/// Capability flags so the frontend can show/hide functionality.
|
||||
fn capabilities(&self) -> MqCapabilities;
|
||||
|
||||
fn system_kind(&self) -> MqSystemKind;
|
||||
|
||||
/// Connectivity test; returns cluster/version info.
|
||||
async fn test_connection(&self) -> Result<MqClusterInfo, String>;
|
||||
|
||||
// ---- Tenants ----
|
||||
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>;
|
||||
async fn get_tenant(&self, name: &str) -> Result<TenantInfo, String>;
|
||||
async fn create_tenant(&self, name: &str, cfg: TenantConfig) -> Result<(), String>;
|
||||
async fn update_tenant(&self, name: &str, cfg: TenantConfig) -> Result<(), String>;
|
||||
async fn delete_tenant(&self, name: &str, force: bool) -> Result<(), String>;
|
||||
|
||||
// ---- Namespaces ----
|
||||
async fn list_namespaces(&self, tenant: &str) -> Result<Vec<NamespaceInfo>, String>;
|
||||
async fn create_namespace(&self, ns: &NamespaceRef, cfg: NamespaceConfig) -> Result<(), String>;
|
||||
async fn delete_namespace(&self, ns: &NamespaceRef, force: bool) -> Result<(), String>;
|
||||
async fn get_namespace_policies(&self, ns: &NamespaceRef) -> Result<serde_json::Value, String>;
|
||||
|
||||
// ---- Topics ----
|
||||
async fn list_topics(&self, ns: &NamespaceRef, opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String>;
|
||||
async fn create_topic(&self, topic: &TopicRef, partitions: Option<u32>) -> Result<(), String>;
|
||||
async fn delete_topic(&self, topic: &TopicRef, force: bool) -> Result<(), String>;
|
||||
async fn update_partitions(&self, topic: &TopicRef, partitions: u32) -> Result<(), String>;
|
||||
async fn get_topic_stats(&self, topic: &TopicRef) -> Result<TopicStats, String>;
|
||||
async fn get_topic_internal_stats(&self, topic: &TopicRef) -> Result<serde_json::Value, String>;
|
||||
|
||||
// ---- Subscriptions ----
|
||||
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String>;
|
||||
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
|
||||
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
|
||||
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
|
||||
async fn reset_cursor(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
|
||||
async fn clear_backlog(&self, topic: &TopicRef, sub: &str) -> Result<(), String>;
|
||||
async fn peek_messages(&self, topic: &TopicRef, sub: &str, count: u32) -> Result<Vec<PeekedMessage>, String>;
|
||||
async fn expire_messages(&self, topic: &TopicRef, sub: &str, expire_seconds: i64) -> Result<(), String>;
|
||||
|
||||
// ---- Producers / consumers (runtime, read from stats) ----
|
||||
async fn list_producers(&self, topic: &TopicRef) -> Result<Vec<ProducerInfo>, String>;
|
||||
async fn list_consumers(&self, topic: &TopicRef, sub: &str) -> Result<Vec<ConsumerInfo>, String>;
|
||||
/// Unload a topic (Pulsar has no per-connection kill; unload is the closest).
|
||||
async fn unload_topic(&self, topic: &TopicRef) -> Result<(), String>;
|
||||
|
||||
// ---- Rate limits / quotas / retention ----
|
||||
async fn set_publish_rate(&self, scope: &PolicyScope, rate: PublishRate) -> Result<(), String>;
|
||||
async fn set_dispatch_rate(&self, scope: &PolicyScope, rate: DispatchRate) -> Result<(), String>;
|
||||
async fn set_subscribe_rate(&self, scope: &PolicyScope, rate: SubscribeRate) -> Result<(), String>;
|
||||
async fn set_backlog_quota(&self, scope: &PolicyScope, quota: BacklogQuota) -> Result<(), String>;
|
||||
async fn set_retention(&self, scope: &PolicyScope, retention: RetentionPolicy) -> Result<(), String>;
|
||||
async fn get_effective_policies(&self, scope: &PolicyScope) -> Result<serde_json::Value, String>;
|
||||
|
||||
// ---- Permissions (who can produce / consume) ----
|
||||
async fn grant_permission(&self, scope: &PolicyScope, role: &str, actions: Vec<AuthAction>) -> Result<(), String>;
|
||||
async fn revoke_permission(&self, scope: &PolicyScope, role: &str) -> Result<(), String>;
|
||||
async fn list_permissions(&self, scope: &PolicyScope) -> Result<PermissionMap, String>;
|
||||
|
||||
// ---- Monitoring ----
|
||||
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String>;
|
||||
|
||||
/// Escape hatch: proxy an arbitrary admin REST call. Covers any endpoint the
|
||||
/// typed methods do not.
|
||||
async fn raw_request(&self, req: MqRawRequest) -> Result<MqRawResponse, String>;
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! Service-layer functions for message queue admin operations. These `*_core`
|
||||
//! functions are shared by both the desktop command layer
|
||||
//! (`src-tauri/src/commands/mq_cmd.rs`) and the web route layer
|
||||
//! (`crates/dbx-web/src/routes/mq.rs`), keeping the business logic unified.
|
||||
//!
|
||||
//! Mirrors the pattern used by `agent_kv::*_core`.
|
||||
|
||||
use crate::connection::AppState;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::token::sign_pulsar_token;
|
||||
use crate::mq::types::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_PEEK_MESSAGES: u32 = 100;
|
||||
|
||||
/// Test connectivity to the message queue admin endpoint. Builds a transient
|
||||
/// adapter (not cached) to avoid polluting the registry on failed attempts.
|
||||
pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result<MqClusterInfo, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
adapter.test_connection().await
|
||||
}
|
||||
|
||||
// ---- Tenants ----
|
||||
|
||||
pub async fn mq_list_tenants_core(state: &AppState, conn_id: &str) -> Result<Vec<TenantInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_tenants().await
|
||||
}
|
||||
|
||||
pub async fn mq_get_tenant_core(state: &AppState, conn_id: &str, name: &str) -> Result<TenantInfo, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_tenant(name).await
|
||||
}
|
||||
|
||||
pub async fn mq_create_tenant_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
name: &str,
|
||||
cfg: TenantConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Create tenant").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.create_tenant(name, cfg).await
|
||||
}
|
||||
|
||||
pub async fn mq_update_tenant_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
name: &str,
|
||||
cfg: TenantConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Update tenant").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.update_tenant(name, cfg).await
|
||||
}
|
||||
|
||||
pub async fn mq_delete_tenant_core(state: &AppState, conn_id: &str, name: &str, force: bool) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Delete tenant").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.delete_tenant(name, force).await
|
||||
}
|
||||
|
||||
// ---- Namespaces ----
|
||||
|
||||
pub async fn mq_list_namespaces_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
tenant: &str,
|
||||
) -> Result<Vec<NamespaceInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_namespaces(tenant).await
|
||||
}
|
||||
|
||||
pub async fn mq_create_namespace_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
ns: NamespaceRef,
|
||||
cfg: NamespaceConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Create namespace").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.create_namespace(&ns, cfg).await
|
||||
}
|
||||
|
||||
pub async fn mq_delete_namespace_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
ns: NamespaceRef,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Delete namespace").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.delete_namespace(&ns, force).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_namespace_policies_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
ns: NamespaceRef,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_namespace_policies(&ns).await
|
||||
}
|
||||
|
||||
// ---- Topics ----
|
||||
|
||||
pub async fn mq_list_topics_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
ns: NamespaceRef,
|
||||
opts: ListTopicsOpts,
|
||||
) -> Result<Vec<TopicInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_topics(&ns, opts).await
|
||||
}
|
||||
|
||||
pub async fn mq_create_topic_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
partitions: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Create topic").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.create_topic(&topic, partitions).await
|
||||
}
|
||||
|
||||
pub async fn mq_delete_topic_core(state: &AppState, conn_id: &str, topic: TopicRef, force: bool) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Delete topic").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.delete_topic(&topic, force).await
|
||||
}
|
||||
|
||||
pub async fn mq_update_partitions_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
partitions: u32,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Update partitions").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.update_partitions(&topic, partitions).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_topic_stats_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<TopicStats, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_topic_stats(&topic).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_topic_internal_stats_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_topic_internal_stats(&topic).await
|
||||
}
|
||||
|
||||
// ---- Subscriptions ----
|
||||
|
||||
pub async fn mq_list_subscriptions_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_subscriptions(&topic).await
|
||||
}
|
||||
|
||||
pub async fn mq_create_subscription_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
pos: ResetPosition,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Create subscription").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.create_subscription(&topic, &sub, pos).await
|
||||
}
|
||||
|
||||
pub async fn mq_delete_subscription_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Delete subscription").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.delete_subscription(&topic, &sub, force).await
|
||||
}
|
||||
|
||||
pub async fn mq_skip_messages_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
count: SkipCount,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Skip messages").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.skip_messages(&topic, &sub, count).await
|
||||
}
|
||||
|
||||
pub async fn mq_reset_cursor_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
pos: ResetPosition,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Reset cursor").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.reset_cursor(&topic, &sub, pos).await
|
||||
}
|
||||
|
||||
pub async fn mq_clear_backlog_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Clear backlog").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.clear_backlog(&topic, &sub).await
|
||||
}
|
||||
|
||||
pub async fn mq_peek_messages_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
count: u32,
|
||||
) -> Result<Vec<PeekedMessage>, String> {
|
||||
if count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if count > MAX_PEEK_MESSAGES {
|
||||
return Err(format!("Peek message count must be between 1 and {MAX_PEEK_MESSAGES}"));
|
||||
}
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.peek_messages(&topic, &sub, count).await
|
||||
}
|
||||
|
||||
pub async fn mq_expire_messages_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
expire_seconds: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Expire messages").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.expire_messages(&topic, &sub, expire_seconds).await
|
||||
}
|
||||
|
||||
// ---- Producers / consumers ----
|
||||
|
||||
pub async fn mq_list_producers_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
) -> Result<Vec<ProducerInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_producers(&topic).await
|
||||
}
|
||||
|
||||
pub async fn mq_list_consumers_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: String,
|
||||
) -> Result<Vec<ConsumerInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_consumers(&topic, &sub).await
|
||||
}
|
||||
|
||||
pub async fn mq_unload_topic_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Unload topic").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.unload_topic(&topic).await
|
||||
}
|
||||
|
||||
// ---- Rate limits / quotas / retention ----
|
||||
|
||||
pub async fn mq_set_publish_rate_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
rate: PublishRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Set publish rate").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.set_publish_rate(&scope, rate).await
|
||||
}
|
||||
|
||||
pub async fn mq_set_dispatch_rate_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
rate: DispatchRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Set dispatch rate").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.set_dispatch_rate(&scope, rate).await
|
||||
}
|
||||
|
||||
pub async fn mq_set_subscribe_rate_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
rate: SubscribeRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Set subscribe rate").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.set_subscribe_rate(&scope, rate).await
|
||||
}
|
||||
|
||||
pub async fn mq_set_backlog_quota_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
quota: BacklogQuota,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Set backlog quota").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.set_backlog_quota(&scope, quota).await
|
||||
}
|
||||
|
||||
pub async fn mq_set_retention_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
retention: RetentionPolicy,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Set retention").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.set_retention(&scope, retention).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_effective_policies_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_effective_policies(&scope).await
|
||||
}
|
||||
|
||||
// ---- Permissions ----
|
||||
|
||||
pub async fn mq_grant_permission_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
role: String,
|
||||
actions: Vec<AuthAction>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Grant permission").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.grant_permission(&scope, &role, actions).await
|
||||
}
|
||||
|
||||
pub async fn mq_revoke_permission_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
role: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(state, conn_id, "Revoke permission").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.revoke_permission(&scope, &role).await
|
||||
}
|
||||
|
||||
pub async fn mq_list_permissions_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
scope: PolicyScope,
|
||||
) -> Result<PermissionMap, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.list_permissions(&scope).await
|
||||
}
|
||||
|
||||
// ---- Client tokens ----
|
||||
|
||||
pub async fn mq_issue_token_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
req: MqTokenIssueRequest,
|
||||
) -> Result<MqIssuedToken, String> {
|
||||
ensure_connection_writable(state, conn_id, "Issue MQ token").await?;
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = MqAdminConfig::from_connection(&cfg)?;
|
||||
let signing_config = mqc
|
||||
.token_signing
|
||||
.as_ref()
|
||||
.filter(|config| config.is_configured())
|
||||
.ok_or("Token signing is not configured for this MQ connection. Edit the connection and configure Broker Token signing with an HS256 secret or RS256 private key.")?;
|
||||
|
||||
let now = Utc::now();
|
||||
let signed = sign_pulsar_token(signing_config, &req, now.timestamp())?;
|
||||
let expires_at =
|
||||
signed.expires_at_unix.and_then(|value| Utc.timestamp_opt(value, 0).single()).map(|value| value.to_rfc3339());
|
||||
let record = MqTokenRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
connection_id: conn_id.to_string(),
|
||||
subject: req.subject.trim().to_string(),
|
||||
algorithm: signing_config.algorithm,
|
||||
token_fingerprint: signed.fingerprint,
|
||||
scope: req.scope.clone(),
|
||||
actions: req.actions.clone(),
|
||||
expires_at,
|
||||
created_at: now.to_rfc3339(),
|
||||
note: req.note.as_deref().unwrap_or_default().trim().to_string(),
|
||||
};
|
||||
|
||||
state.storage.save_mq_token_record(&record).await?;
|
||||
Ok(MqIssuedToken { token: signed.token, record })
|
||||
}
|
||||
|
||||
pub async fn mq_list_token_records_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
subject: Option<String>,
|
||||
) -> Result<Vec<MqTokenRecord>, String> {
|
||||
if !state.configs.read().await.contains_key(conn_id) {
|
||||
return Err("Connection not found".to_string());
|
||||
}
|
||||
let subject = subject.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
state.storage.load_mq_token_records(conn_id, subject).await
|
||||
}
|
||||
|
||||
// ---- Monitoring ----
|
||||
|
||||
pub async fn mq_get_backlog_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
sub: Option<String>,
|
||||
) -> Result<BacklogStats, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_backlog(&topic, sub.as_deref()).await
|
||||
}
|
||||
|
||||
// ---- Raw request (escape hatch) ----
|
||||
|
||||
pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequest) -> Result<MqRawResponse, String> {
|
||||
if req.is_mutating() {
|
||||
ensure_connection_writable(state, conn_id, "MQ admin write").await?;
|
||||
}
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.raw_request(req).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_adapter(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
) -> Result<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
state.mq_registry.get_or_build_config(conn_id, mqc).await
|
||||
}
|
||||
|
||||
async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation: &str) -> Result<(), String> {
|
||||
let configs = state.configs.read().await;
|
||||
if let Some(config) = configs.get(conn_id) {
|
||||
if config.read_only {
|
||||
return Err(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
||||
config.name, operation
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use crate::storage::Storage;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn mq_connection(read_only: bool) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: "readonly-mq".to_string(),
|
||||
name: "Read only MQ".to_string(),
|
||||
db_type: DatabaseType::MessageQueue,
|
||||
driver_profile: Some("pulsar".to_string()),
|
||||
driver_label: Some("Apache Pulsar".to_string()),
|
||||
url_params: None,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
database: None,
|
||||
visible_databases: None,
|
||||
attached_databases: Vec::new(),
|
||||
color: None,
|
||||
transport_layers: Vec::new(),
|
||||
connect_timeout_secs: 5,
|
||||
query_timeout_secs: 30,
|
||||
idle_timeout_secs: 60,
|
||||
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
redis_key_separator: ":".to_string(),
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
external_config: Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://127.0.0.1:8080",
|
||||
"auth": { "kind": "none" }
|
||||
})),
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only,
|
||||
}
|
||||
}
|
||||
|
||||
fn mq_connection_with_token_signing() -> ConnectionConfig {
|
||||
let mut config = mq_connection(false);
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://127.0.0.1:8080",
|
||||
"auth": { "kind": "none" },
|
||||
"tokenSigning": {
|
||||
"algorithm": "hs256",
|
||||
"key": "broker-signing-secret"
|
||||
}
|
||||
}));
|
||||
config
|
||||
}
|
||||
|
||||
async fn test_state_with(config: ConnectionConfig) -> (AppState, std::path::PathBuf) {
|
||||
let stamp =
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).expect("system time should be after UNIX epoch").as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dbx-mq-service-test-{stamp}"));
|
||||
std::fs::create_dir_all(&dir).expect("failed to create test directory");
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.expect("failed to open test storage");
|
||||
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
||||
state.configs.write().await.insert(config.id.clone(), config);
|
||||
(state, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
|
||||
let (state, dir) = test_state_with(mq_connection(true)).await;
|
||||
let err = mq_create_topic_core(
|
||||
&state,
|
||||
"readonly-mq",
|
||||
TopicRef {
|
||||
tenant: "public".to_string(),
|
||||
namespace: "default".to_string(),
|
||||
topic: "orders".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("read-only write 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;
|
||||
let err = mq_raw_request_core(
|
||||
&state,
|
||||
"readonly-mq",
|
||||
MqRawRequest {
|
||||
method: "POST".to_string(),
|
||||
path: "/admin/v2/tenants".to_string(),
|
||||
query: None,
|
||||
body: Some(serde_json::json!({ "adminRoles": [] })),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("read-only raw write 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;
|
||||
let err = mq_peek_messages_core(
|
||||
&state,
|
||||
"readonly-mq",
|
||||
TopicRef {
|
||||
tenant: "public".to_string(),
|
||||
namespace: "default".to_string(),
|
||||
topic: "orders".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
},
|
||||
"sub-a".to_string(),
|
||||
101,
|
||||
)
|
||||
.await
|
||||
.expect_err("peek count above the service limit should fail");
|
||||
|
||||
assert!(err.contains("between 1 and 100"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_token_saves_record_without_plaintext() {
|
||||
let (state, dir) = test_state_with(mq_connection_with_token_signing()).await;
|
||||
|
||||
let issued = mq_issue_token_core(
|
||||
&state,
|
||||
"readonly-mq",
|
||||
MqTokenIssueRequest {
|
||||
subject: "rt-erp-server".to_string(),
|
||||
expires_in_seconds: Some(3600),
|
||||
scope: None,
|
||||
actions: vec![AuthAction::Consume],
|
||||
note: Some("integration test".to_string()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("token issuance should succeed");
|
||||
|
||||
assert!(!issued.token.is_empty());
|
||||
assert_eq!(issued.record.subject, "rt-erp-server");
|
||||
let records = mq_list_token_records_core(&state, "readonly-mq", Some("rt-erp-server".to_string()))
|
||||
.await
|
||||
.expect("listing token records should succeed");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0], issued.record);
|
||||
let record_json = serde_json::to_value(&records[0]).expect("record should serialize to JSON");
|
||||
assert!(record_json.get("token").is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::mq::types::{MqTokenIssueRequest, MqTokenSigningAlgorithm, MqTokenSigningConfig};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedPulsarToken {
|
||||
pub token: String,
|
||||
pub fingerprint: String,
|
||||
pub expires_at_unix: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PulsarTokenClaims<'a> {
|
||||
sub: &'a str,
|
||||
iat: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
exp: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn sign_pulsar_token(
|
||||
config: &MqTokenSigningConfig,
|
||||
req: &MqTokenIssueRequest,
|
||||
now_unix: i64,
|
||||
) -> Result<SignedPulsarToken, String> {
|
||||
let subject = req.subject.trim();
|
||||
if subject.is_empty() {
|
||||
return Err("Token subject is required".to_string());
|
||||
}
|
||||
|
||||
// Validate subject format
|
||||
if subject.len() > 256 {
|
||||
return Err("Token subject exceeds maximum length of 256 characters".to_string());
|
||||
}
|
||||
|
||||
// Pulsar typically accepts alphanumeric characters plus common separators
|
||||
// Allow: letters, digits, hyphen, underscore, dot, @
|
||||
if !subject.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '@')) {
|
||||
return Err(format!(
|
||||
"Token subject contains invalid characters. Only alphanumeric, '-', '_', '.', and '@' are allowed. Got: {}",
|
||||
subject
|
||||
));
|
||||
}
|
||||
|
||||
let key = config.key.trim();
|
||||
if key.is_empty() {
|
||||
return Err("Token signing key is required".to_string());
|
||||
}
|
||||
|
||||
let expires_at_unix = match req.expires_in_seconds {
|
||||
Some(seconds) if seconds <= 0 => return Err("Token expiry must be greater than zero seconds".to_string()),
|
||||
Some(seconds) => Some(now_unix.saturating_add(seconds)),
|
||||
None => None,
|
||||
};
|
||||
let claims = PulsarTokenClaims { sub: subject, iat: now_unix, exp: expires_at_unix };
|
||||
let algorithm = jwt_algorithm(config.algorithm);
|
||||
let header = Header::new(algorithm);
|
||||
let encoding_key = encoding_key(config.algorithm, key)?;
|
||||
let token = encode(&header, &claims, &encoding_key).map_err(|err| format!("Failed to sign Pulsar token: {err}"))?;
|
||||
let fingerprint = token_fingerprint(&token);
|
||||
|
||||
Ok(SignedPulsarToken { token, fingerprint, expires_at_unix })
|
||||
}
|
||||
|
||||
pub fn token_fingerprint(token: &str) -> String {
|
||||
let digest = Sha256::digest(token.as_bytes());
|
||||
format!("sha256:{}", hex_lower(&digest))
|
||||
}
|
||||
|
||||
fn jwt_algorithm(algorithm: MqTokenSigningAlgorithm) -> Algorithm {
|
||||
match algorithm {
|
||||
MqTokenSigningAlgorithm::Hs256 => Algorithm::HS256,
|
||||
MqTokenSigningAlgorithm::Rs256 => Algorithm::RS256,
|
||||
}
|
||||
}
|
||||
|
||||
fn encoding_key(algorithm: MqTokenSigningAlgorithm, key: &str) -> Result<EncodingKey, String> {
|
||||
match algorithm {
|
||||
MqTokenSigningAlgorithm::Hs256 => Ok(EncodingKey::from_secret(key.as_bytes())),
|
||||
MqTokenSigningAlgorithm::Rs256 => {
|
||||
let normalized = normalize_rsa_key_material(key)?;
|
||||
EncodingKey::from_rsa_pem(normalized.as_bytes()).map_err(|err| format!("Invalid RS256 private key: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rsa_key_material(key: &str) -> Result<String, String> {
|
||||
// If already in PEM format, return as-is
|
||||
if key.contains("-----BEGIN") {
|
||||
return Ok(key.to_string());
|
||||
}
|
||||
|
||||
// Try to decode as base64
|
||||
match BASE64.decode(key.as_bytes()) {
|
||||
Ok(bytes) => String::from_utf8(bytes).map_err(|e| {
|
||||
format!(
|
||||
"RSA key appears to be base64-encoded but decodes to invalid UTF-8: {}. \
|
||||
Please provide the key in PEM format (with -----BEGIN/END headers) or as valid base64.",
|
||||
e
|
||||
)
|
||||
}),
|
||||
Err(e) => Err(format!(
|
||||
"RSA key is not in PEM format and failed to decode as base64: {}. \
|
||||
Please provide the key in PEM format (with -----BEGIN RSA PRIVATE KEY----- headers) \
|
||||
or as valid base64-encoded data.",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push(HEX[(byte >> 4) as usize] as char);
|
||||
out.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
//! Shared resource, statistics, and configuration types for the message queue
|
||||
//! admin console. All types are `serde`-serializable in `camelCase` so they map
|
||||
//! 1:1 to the frontend TypeScript definitions in `apps/desktop/src/types/mq.ts`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Which message queue system an admin connection targets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MqSystemKind {
|
||||
Pulsar,
|
||||
Kafka,
|
||||
#[serde(rename = "rocketmq")]
|
||||
RocketMq,
|
||||
}
|
||||
|
||||
impl MqSystemKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MqSystemKind::Pulsar => "pulsar",
|
||||
MqSystemKind::Kafka => "kafka",
|
||||
MqSystemKind::RocketMq => "rocketmq",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability flags. The frontend reads these to show/hide functionality, and
|
||||
/// the adapter computes them from the detected server version so unsupported
|
||||
/// features are hidden rather than failing at call time.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqCapabilities {
|
||||
pub supports_tenants: bool,
|
||||
pub supports_namespaces: bool,
|
||||
pub supports_partitioned_topics: bool,
|
||||
pub supports_subscriptions: bool,
|
||||
pub supports_create_subscription: bool,
|
||||
pub supports_reset_cursor: bool,
|
||||
pub supports_skip_messages: bool,
|
||||
pub supports_clear_backlog: bool,
|
||||
pub supports_peek_messages: bool,
|
||||
pub supports_expire_messages: bool,
|
||||
pub supports_rate_limits: bool,
|
||||
pub supports_backlog_quota: bool,
|
||||
pub supports_retention: bool,
|
||||
pub supports_permissions: bool,
|
||||
pub supports_geo_replication: bool,
|
||||
pub supports_token_management: bool,
|
||||
pub supports_raw_admin_api: bool,
|
||||
}
|
||||
|
||||
impl Default for MqCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
supports_tenants: false,
|
||||
supports_namespaces: false,
|
||||
supports_partitioned_topics: false,
|
||||
supports_subscriptions: false,
|
||||
supports_create_subscription: false,
|
||||
supports_reset_cursor: false,
|
||||
supports_skip_messages: false,
|
||||
supports_clear_backlog: false,
|
||||
supports_peek_messages: false,
|
||||
supports_expire_messages: false,
|
||||
supports_rate_limits: false,
|
||||
supports_backlog_quota: false,
|
||||
supports_retention: false,
|
||||
supports_permissions: false,
|
||||
supports_geo_replication: false,
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a connectivity test, including the detected server version and how
|
||||
/// it was determined (probe vs. fallback) so the UI can warn appropriately.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqClusterInfo {
|
||||
pub system_kind: MqSystemKind,
|
||||
/// Raw version string reported by the broker (e.g. `3.1.2`), if available.
|
||||
pub server_version: Option<String>,
|
||||
/// Version profile the adapter resolved to (e.g. `3.1.x`).
|
||||
pub resolved_profile: String,
|
||||
/// How the version was determined: `probed` | `pinned` | `fallback`.
|
||||
pub version_detection: String,
|
||||
pub capabilities: MqCapabilities,
|
||||
/// Optional free-form extra fields (cluster list, broker count, ...).
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MqTokenSigningAlgorithm {
|
||||
Hs256,
|
||||
Rs256,
|
||||
}
|
||||
|
||||
impl MqTokenSigningAlgorithm {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hs256 => "hs256",
|
||||
Self::Rs256 => "rs256",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqTokenSigningConfig {
|
||||
pub algorithm: MqTokenSigningAlgorithm,
|
||||
#[serde(default)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl MqTokenSigningConfig {
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.key.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqTokenIssueRequest {
|
||||
pub subject: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_in_seconds: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<PolicyScope>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<AuthAction>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqTokenRecord {
|
||||
pub id: String,
|
||||
pub connection_id: String,
|
||||
pub subject: String,
|
||||
pub algorithm: MqTokenSigningAlgorithm,
|
||||
pub token_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<PolicyScope>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<AuthAction>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqIssuedToken {
|
||||
pub token: String,
|
||||
pub record: MqTokenRecord,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tenant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TenantInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub admin_roles: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_clusters: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TenantConfig {
|
||||
#[serde(default)]
|
||||
pub admin_roles: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_clusters: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A fully-qualified namespace reference (`tenant/namespace`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamespaceRef {
|
||||
pub tenant: String,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
impl NamespaceRef {
|
||||
/// `tenant/namespace`
|
||||
pub fn path(&self) -> String {
|
||||
format!("{}/{}", self.tenant, self.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamespaceInfo {
|
||||
pub tenant: String,
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub admin_roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamespaceConfig {
|
||||
/// Replication clusters to bootstrap the namespace with. Pulsar requires at
|
||||
/// least one when the cluster runs with geo-replication.
|
||||
#[serde(default)]
|
||||
pub clusters: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bundles: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A fully-qualified topic reference.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TopicRef {
|
||||
pub tenant: String,
|
||||
pub namespace: String,
|
||||
pub topic: String,
|
||||
/// Whether the topic is persistent (`persistent://`) or not
|
||||
/// (`non-persistent://`). Defaults to persistent.
|
||||
#[serde(default = "default_true")]
|
||||
pub persistent: bool,
|
||||
/// Optional UI hint used to prefer partitioned stats when the topic came
|
||||
/// from the partitioned topic list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub partitioned: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl TopicRef {
|
||||
/// The URL domain segment: `persistent` or `non-persistent`.
|
||||
pub fn domain(&self) -> &'static str {
|
||||
if self.persistent {
|
||||
"persistent"
|
||||
} else {
|
||||
"non-persistent"
|
||||
}
|
||||
}
|
||||
|
||||
/// `{tenant}/{namespace}/{topic}` — the path used by most Pulsar endpoints.
|
||||
pub fn path(&self) -> String {
|
||||
format!("{}/{}/{}", self.tenant, self.namespace, self.topic)
|
||||
}
|
||||
|
||||
/// Full topic name, e.g. `persistent://public/default/orders`.
|
||||
pub fn full_name(&self) -> String {
|
||||
format!("{}://{}", self.domain(), self.path())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TopicInfo {
|
||||
/// Full topic name, e.g. `persistent://public/default/orders`.
|
||||
pub name: String,
|
||||
/// Short topic name without the namespace prefix.
|
||||
pub short_name: String,
|
||||
pub partitioned: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub partitions: Option<u32>,
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListTopicsOpts {
|
||||
/// Include non-persistent topics in the listing.
|
||||
#[serde(default)]
|
||||
pub include_non_persistent: bool,
|
||||
}
|
||||
|
||||
/// Aggregated, UI-friendly topic statistics. Parsed from the version-specific
|
||||
/// raw stats payload by the adapter's version profile.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TopicStats {
|
||||
pub msg_rate_in: f64,
|
||||
pub msg_rate_out: f64,
|
||||
pub msg_throughput_in: f64,
|
||||
pub msg_throughput_out: f64,
|
||||
pub storage_size: i64,
|
||||
pub backlog_size: i64,
|
||||
pub msg_in_counter: i64,
|
||||
pub msg_out_counter: i64,
|
||||
pub subscription_count: u32,
|
||||
pub producer_count: u32,
|
||||
/// Original raw stats JSON, for the detail view / advanced inspection.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscription / consumers / producers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub sub_type: String,
|
||||
pub msg_backlog: i64,
|
||||
pub msg_rate_out: f64,
|
||||
pub msg_throughput_out: f64,
|
||||
#[serde(default)]
|
||||
pub consumers: Vec<ConsumerInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsumerInfo {
|
||||
pub consumer_name: String,
|
||||
pub msg_rate_out: f64,
|
||||
pub msg_throughput_out: f64,
|
||||
pub available_permits: i64,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub client_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProducerInfo {
|
||||
pub producer_id: i64,
|
||||
pub producer_name: String,
|
||||
pub msg_rate_in: f64,
|
||||
pub msg_throughput_in: f64,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub client_version: String,
|
||||
}
|
||||
|
||||
/// Where to position a cursor when creating a subscription or resetting it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ResetPosition {
|
||||
/// Earliest available message.
|
||||
Earliest,
|
||||
/// Latest message (skip the existing backlog).
|
||||
Latest,
|
||||
/// A specific point in time (milliseconds since epoch).
|
||||
Timestamp { timestamp_ms: i64 },
|
||||
/// A specific message id.
|
||||
MessageId { ledger_id: i64, entry_id: i64 },
|
||||
}
|
||||
|
||||
/// How many messages to skip on a subscription.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SkipCount {
|
||||
All,
|
||||
Count { count: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacklogStats {
|
||||
pub msg_backlog: i64,
|
||||
pub backlog_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeekedMessage {
|
||||
/// 1-based subscription position passed to the Pulsar Admin API.
|
||||
pub position: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publish_time: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub event_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
/// Message payload encoded as base64 so binary messages are preserved.
|
||||
pub payload_base64: String,
|
||||
/// UTF-8 preview when the payload can be decoded losslessly.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload_text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy scope (rate limits / quotas / permissions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether a policy applies at the namespace or topic level. Lets the rate
|
||||
/// limit / quota / permission methods avoid duplicating namespace vs. topic
|
||||
/// variants.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "level", rename_all = "camelCase")]
|
||||
pub enum PolicyScope {
|
||||
Namespace { tenant: String, namespace: String },
|
||||
Topic { tenant: String, namespace: String, topic: String, persistent: bool },
|
||||
}
|
||||
|
||||
impl PolicyScope {
|
||||
pub fn as_namespace_ref(&self) -> Option<NamespaceRef> {
|
||||
match self {
|
||||
PolicyScope::Namespace { tenant, namespace } => {
|
||||
Some(NamespaceRef { tenant: tenant.clone(), namespace: namespace.clone() })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_topic_ref(&self) -> Option<TopicRef> {
|
||||
match self {
|
||||
PolicyScope::Topic { tenant, namespace, topic, persistent } => Some(TopicRef {
|
||||
tenant: tenant.clone(),
|
||||
namespace: namespace.clone(),
|
||||
topic: topic.clone(),
|
||||
persistent: *persistent,
|
||||
partitioned: None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_topic(&self) -> bool {
|
||||
matches!(self, PolicyScope::Topic { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishRate {
|
||||
pub publish_throttling_rate_in_msg: i32,
|
||||
pub publish_throttling_rate_in_byte: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchRate {
|
||||
pub dispatch_throttling_rate_in_msg: i32,
|
||||
pub dispatch_throttling_rate_in_byte: i64,
|
||||
#[serde(default = "default_rate_period")]
|
||||
pub rate_period_in_second: i32,
|
||||
}
|
||||
|
||||
fn default_rate_period() -> i32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscribeRate {
|
||||
pub subscribe_throttling_rate_per_consumer: i32,
|
||||
#[serde(default = "default_rate_period")]
|
||||
pub rate_period_in_second: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacklogQuota {
|
||||
/// Limit in bytes. `-1` means no size limit.
|
||||
pub limit_size: i64,
|
||||
/// Limit in seconds. `-1` means no time limit.
|
||||
#[serde(default)]
|
||||
pub limit_time: i32,
|
||||
/// `producer_request_hold` | `producer_exception` | `consumer_backlog_eviction`.
|
||||
pub policy: String,
|
||||
/// `destination_storage` (size) or `message_age` (time).
|
||||
#[serde(default = "default_backlog_quota_type")]
|
||||
pub quota_type: String,
|
||||
}
|
||||
|
||||
fn default_backlog_quota_type() -> String {
|
||||
"destination_storage".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RetentionPolicy {
|
||||
/// Retention time in minutes. `-1` = infinite.
|
||||
pub retention_time_in_minutes: i32,
|
||||
/// Retention size in MB. `-1` = infinite.
|
||||
pub retention_size_in_mb: i32,
|
||||
}
|
||||
|
||||
/// Authorization actions that can be granted to a role on a namespace/topic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthAction {
|
||||
Produce,
|
||||
Consume,
|
||||
Functions,
|
||||
Sources,
|
||||
Sinks,
|
||||
#[serde(rename = "packages")]
|
||||
Packages,
|
||||
}
|
||||
|
||||
pub type PermissionMap = HashMap<String, Vec<AuthAction>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw request (escape hatch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A raw admin REST request, proxied through the adapter to cover any endpoint
|
||||
/// the typed methods do not. The path is appended to the connection's
|
||||
/// `admin_url` base — arbitrary hosts are not allowed (SSRF guard).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqRawRequest {
|
||||
/// HTTP method: GET / PUT / POST / DELETE.
|
||||
pub method: String,
|
||||
/// Path relative to the admin base, e.g. `/admin/v2/tenants`.
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub query: Option<HashMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl MqRawRequest {
|
||||
/// Whether this request mutates server state and therefore must pass the
|
||||
/// read-only protection check.
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
!matches!(self.method.to_ascii_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MqRawResponse {
|
||||
pub status: u16,
|
||||
pub body: serde_json::Value,
|
||||
/// Set when the response body was not valid JSON; carries the raw text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Shared utilities for the message queue admin module.
|
||||
|
||||
/// Truncate a string to `max` bytes, respecting UTF-8 char boundaries, and
|
||||
/// append an ellipsis when truncation occurs.
|
||||
pub(crate) fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_short_string_unchanged() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_long_string_adds_ellipsis() {
|
||||
let result = truncate("abcdefghij", 5);
|
||||
assert_eq!(result, "abcde…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handles_multibyte_utf8_boundary() {
|
||||
let body = format!("a{}", "错".repeat(100));
|
||||
let truncated = truncate(&body, 10);
|
||||
assert!(truncated.ends_with('…'));
|
||||
assert!(truncated.len() <= 10 + "…".len());
|
||||
}
|
||||
}
|
||||
@@ -924,6 +924,7 @@ pub async fn do_execute(
|
||||
}
|
||||
PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()),
|
||||
PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()),
|
||||
PoolKind::MessageQueue => Err("Use Message Queue-specific commands".to_string()),
|
||||
PoolKind::InfluxDb(client) => {
|
||||
let client = client.clone();
|
||||
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
|
||||
@@ -1585,6 +1586,7 @@ pub async fn execute_statements_in_transaction(
|
||||
| PoolKind::Turso(_)
|
||||
| PoolKind::SqlServer(_)
|
||||
| PoolKind::Agent(_) => TxPath::Explicit,
|
||||
PoolKind::MessageQueue => TxPath::None,
|
||||
#[cfg(feature = "duckdb-bundled")]
|
||||
PoolKind::DuckDb(_)
|
||||
| PoolKind::Redis(_)
|
||||
|
||||
@@ -6,9 +6,13 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::{AiChatMessage, AiConfig, AiConversation};
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
|
||||
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX,
|
||||
};
|
||||
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
|
||||
use crate::history::{HistoryEntry, MAX_HISTORY};
|
||||
use crate::models::connection::{ConnectionConfig, TransportLayerConfig};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
|
||||
use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
|
||||
|
||||
const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels.";
|
||||
@@ -136,6 +140,22 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS mq_token_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
token_fingerprint TEXT NOT NULL,
|
||||
scope_json TEXT,
|
||||
actions_json TEXT NOT NULL DEFAULT '[]',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mq_token_records_connection_subject
|
||||
ON mq_token_records (connection_id, subject, created_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mq_token_records_fingerprint
|
||||
ON mq_token_records (token_fingerprint)",
|
||||
"CREATE TABLE IF NOT EXISTS saved_sql_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
@@ -302,6 +322,32 @@ fn scrub_transport_layer_secrets(config: &mut ConnectionConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_mq_auth_secrets(config: &mut ConnectionConfig) {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return;
|
||||
}
|
||||
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
match mq_auth_kind(auth) {
|
||||
Some("token") => scrub_json_secret(auth, "token"),
|
||||
Some("basic") => scrub_json_secret(auth, "password"),
|
||||
Some(kind) if is_api_key_auth_kind(kind) => scrub_json_secret(auth, "value"),
|
||||
Some("oauth2") => scrub_json_secret(auth, "clientSecret"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return;
|
||||
}
|
||||
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
scrub_json_secret(signing, "key");
|
||||
}
|
||||
|
||||
fn delete_secret_prefix_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
connection_id: &str,
|
||||
@@ -736,6 +782,8 @@ impl Storage {
|
||||
scrub_transport_layer_secrets(&mut sanitized);
|
||||
sanitized.redis_sentinel_password = String::new();
|
||||
sanitized.connection_string = None;
|
||||
scrub_mq_auth_secrets(&mut sanitized);
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
|
||||
@@ -770,6 +818,8 @@ impl Storage {
|
||||
scrub_transport_layer_secrets(&mut sanitized);
|
||||
sanitized.redis_sentinel_password = String::new();
|
||||
sanitized.connection_string = None;
|
||||
scrub_mq_auth_secrets(&mut sanitized);
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
|
||||
@@ -817,6 +867,8 @@ impl Storage {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
persist_mq_auth_secrets_in_tx(&tx, &config)?;
|
||||
persist_mq_token_signing_secret_in_tx(&tx, &config)?;
|
||||
}
|
||||
|
||||
if configs.is_empty() {
|
||||
@@ -895,10 +947,73 @@ impl Storage {
|
||||
}
|
||||
config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default();
|
||||
config.connection_string = self.get_secret(&id, "connection_string").await?;
|
||||
let needs_mq_auth_rewrite = self.hydrate_mq_auth_secrets(&id, &mut config).await?;
|
||||
let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?;
|
||||
let needs_mq_secret_rewrite = needs_mq_auth_rewrite || needs_mq_token_signing_rewrite;
|
||||
if needs_mq_secret_rewrite {
|
||||
let mut sanitized = config.clone().canonicalized();
|
||||
scrub_mq_auth_secrets(&mut sanitized);
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
let sanitized_json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
let update_id = id.clone();
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE connections SET config_json = ?1 WHERE id = ?2",
|
||||
params![sanitized_json, update_id],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
configs.push(config.canonicalized());
|
||||
}
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
async fn hydrate_mq_auth_secrets(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &mut ConnectionConfig,
|
||||
) -> Result<bool, String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let needs_rewrite = match mq_auth_kind(auth) {
|
||||
Some("token") => hydrate_mq_json_secret(self, connection_id, MQ_AUTH_TOKEN_KEY, auth, "token").await?,
|
||||
Some("basic") => {
|
||||
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_PASSWORD_KEY, auth, "password").await?
|
||||
}
|
||||
Some(kind) if is_api_key_auth_kind(kind) => {
|
||||
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value").await?
|
||||
}
|
||||
Some("oauth2") => {
|
||||
hydrate_mq_json_secret(self, connection_id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret").await?
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Ok(needs_rewrite)
|
||||
}
|
||||
|
||||
async fn hydrate_mq_token_signing_secret(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &mut ConnectionConfig,
|
||||
) -> Result<bool, String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
hydrate_mq_json_secret(self, connection_id, MQ_TOKEN_SIGNING_KEY, signing, "key").await
|
||||
}
|
||||
}
|
||||
|
||||
// Saved SQL
|
||||
@@ -1166,6 +1281,76 @@ impl Storage {
|
||||
}
|
||||
}
|
||||
|
||||
// MQ token records
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
impl Storage {
|
||||
pub async fn save_mq_token_record(&self, record: &crate::mq::MqTokenRecord) -> Result<(), String> {
|
||||
let record = record.clone();
|
||||
self.with_conn(move |conn| {
|
||||
let scope_json = record
|
||||
.scope
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let actions_json = serde_json::to_string(&record.actions).map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mq_token_records \
|
||||
(id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
record.id,
|
||||
record.connection_id,
|
||||
record.subject,
|
||||
record.algorithm.as_str(),
|
||||
record.token_fingerprint,
|
||||
scope_json,
|
||||
actions_json,
|
||||
record.expires_at,
|
||||
record.created_at,
|
||||
record.note
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_mq_token_records(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
subject: Option<&str>,
|
||||
) -> Result<Vec<crate::mq::MqTokenRecord>, String> {
|
||||
let connection_id = connection_id.to_string();
|
||||
let subject = subject.map(str::to_string);
|
||||
self.with_conn(move |conn| {
|
||||
let sql = if subject.is_some() {
|
||||
"SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \
|
||||
FROM mq_token_records WHERE connection_id = ?1 AND subject = ?2 ORDER BY created_at DESC"
|
||||
} else {
|
||||
"SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \
|
||||
FROM mq_token_records WHERE connection_id = ?1 ORDER BY created_at DESC"
|
||||
};
|
||||
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
|
||||
let rows = if let Some(subject) = subject {
|
||||
stmt.query_map(params![connection_id, subject], mq_token_record_from_row)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
stmt.query_map(params![connection_id], mq_token_record_from_row)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(rows)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// Layout
|
||||
|
||||
impl Storage {
|
||||
@@ -1480,6 +1665,163 @@ fn persist_secret_in_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_mq_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(auth) = mq_auth_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match mq_auth_kind(auth) {
|
||||
Some("none") => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?,
|
||||
Some("token") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token")?,
|
||||
Some("basic") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password")?,
|
||||
Some(kind) if is_api_key_auth_kind(kind) => {
|
||||
replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")?
|
||||
}
|
||||
Some("oauth2") => {
|
||||
replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?
|
||||
}
|
||||
_ => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_mq_auth_secret_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
|
||||
let existing = if current.is_none() { get_secret_in_tx(tx, connection_id, key)? } else { None };
|
||||
delete_secret_prefix_in_tx(tx, connection_id, MQ_AUTH_SECRET_PREFIX)?;
|
||||
match current {
|
||||
Some(secret) => persist_secret_in_tx(tx, connection_id, key, secret),
|
||||
None => match existing {
|
||||
Some(secret) => persist_secret_in_tx(tx, connection_id, key, &secret),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn get_secret_in_tx(tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str) -> Result<Option<String>, String> {
|
||||
tx.query_row(
|
||||
"SELECT secret FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
|
||||
params![connection_id, key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn persist_mq_token_signing_secret_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::MessageQueue {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(signing) = mq_token_signing_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
persist_json_secret_if_present_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key")
|
||||
}
|
||||
|
||||
fn persist_json_secret_if_present_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
|
||||
persist_secret_in_tx(tx, connection_id, key, secret)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_mq_json_secret(
|
||||
storage: &Storage,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &mut serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) {
|
||||
storage.set_secret(connection_id, key, secret).await?;
|
||||
Ok(true)
|
||||
} else if let Some(secret) = storage.get_secret(connection_id, key).await? {
|
||||
auth.insert(field.to_string(), serde_json::Value::String(secret));
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_json_secret(auth: &mut serde_json::Map<String, serde_json::Value>, field: &str) {
|
||||
if auth.contains_key(field) {
|
||||
auth.insert(field.to_string(), serde_json::Value::String(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn mq_auth_kind(auth: &serde_json::Map<String, serde_json::Value>) -> Option<&str> {
|
||||
auth.get("kind").and_then(serde_json::Value::as_str)
|
||||
}
|
||||
|
||||
fn mq_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("auth")?.as_object()
|
||||
}
|
||||
|
||||
fn mq_auth_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("auth")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn mq_token_signing_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("tokenSigning")?.as_object()
|
||||
}
|
||||
|
||||
fn mq_token_signing_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("tokenSigning")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn is_api_key_auth_kind(kind: &str) -> bool {
|
||||
matches!(kind, "apiKey" | "api_key" | "apikey")
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
fn mq_token_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<crate::mq::MqTokenRecord> {
|
||||
let algorithm: String = row.get(3)?;
|
||||
let scope_json: Option<String> = row.get(5)?;
|
||||
let actions_json: String = row.get(6)?;
|
||||
Ok(crate::mq::MqTokenRecord {
|
||||
id: row.get(0)?,
|
||||
connection_id: row.get(1)?,
|
||||
subject: row.get(2)?,
|
||||
algorithm: serde_json::from_value(serde_json::Value::String(algorithm)).map_err(map_from_sql_err)?,
|
||||
token_fingerprint: row.get(4)?,
|
||||
scope: scope_json.as_deref().map(serde_json::from_str).transpose().map_err(map_from_sql_err)?,
|
||||
actions: serde_json::from_str(&actions_json).map_err(map_from_sql_err)?,
|
||||
expires_at: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
note: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
|
||||
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err))
|
||||
}
|
||||
@@ -1487,6 +1829,8 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DesktopIconTheme, DesktopSettings, Storage};
|
||||
use crate::connection_secrets::{MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_db_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -1494,6 +1838,203 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}.db", std::process::id()))
|
||||
}
|
||||
|
||||
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: id.to_string(),
|
||||
name: "Pulsar".to_string(),
|
||||
db_type: DatabaseType::MessageQueue,
|
||||
driver_profile: Some("pulsar".to_string()),
|
||||
driver_label: Some("Apache Pulsar".to_string()),
|
||||
url_params: None,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
database: None,
|
||||
visible_databases: None,
|
||||
attached_databases: Vec::new(),
|
||||
color: None,
|
||||
transport_layers: Vec::new(),
|
||||
connect_timeout_secs: 30,
|
||||
query_timeout_secs: 300,
|
||||
idle_timeout_secs: 600,
|
||||
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
redis_key_separator: ":".to_string(),
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
external_config: Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://127.0.0.1:8080",
|
||||
"auth": {
|
||||
"kind": "token",
|
||||
"token": token
|
||||
}
|
||||
})),
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn raw_connection_json(storage: &Storage, id: &str) -> String {
|
||||
let id = id.to_string();
|
||||
storage
|
||||
.with_conn(move |conn| {
|
||||
conn.query_row("SELECT config_json FROM connections WHERE id = ?1", [id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn insert_raw_connection(storage: &Storage, config: &ConnectionConfig) {
|
||||
let id = config.id.clone();
|
||||
let json = serde_json::to_string(config).unwrap();
|
||||
storage
|
||||
.with_conn(move |conn| {
|
||||
conn.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", rusqlite::params![id, json])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn mq_token(config: &ConnectionConfig) -> Option<&str> {
|
||||
config.external_config.as_ref()?.get("auth")?.get("token")?.as_str()
|
||||
}
|
||||
|
||||
fn mq_token_signing_key(config: &ConnectionConfig) -> Option<&str> {
|
||||
config.external_config.as_ref()?.get("tokenSigning")?.get("key")?.as_str()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connections_moves_mq_auth_token_to_secret_table_and_restores_it() {
|
||||
let path = temp_db_path("mq-token-secrets");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
storage.save_connections(&[mq_connection("pulsar", "mq-token-secret")]).await.unwrap();
|
||||
|
||||
let raw_json = raw_connection_json(&storage, "pulsar").await;
|
||||
assert!(!raw_json.contains("mq-token-secret"));
|
||||
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
|
||||
assert_eq!(mq_token(&persisted), Some(""));
|
||||
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("mq-token-secret"));
|
||||
|
||||
let loaded = storage.load_connections().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(mq_token(&loaded[0]), Some("mq-token-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_save_scrubs_mq_auth_token_and_preserves_existing_secret() {
|
||||
let path = temp_db_path("mq-token-metadata");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
let original = mq_connection("pulsar", "existing-token");
|
||||
storage.save_connections(&[original.clone()]).await.unwrap();
|
||||
|
||||
let mut metadata = original;
|
||||
metadata.name = "Pulsar renamed".to_string();
|
||||
if let Some(auth) = metadata.external_config.as_mut().and_then(|value| value.get_mut("auth")) {
|
||||
auth["token"] = serde_json::Value::String("new-token-that-should-not-persist".to_string());
|
||||
}
|
||||
|
||||
storage.save_connection_metadata_preserving_secrets(&[metadata]).await.unwrap();
|
||||
|
||||
let raw_json = raw_connection_json(&storage, "pulsar").await;
|
||||
assert!(!raw_json.contains("existing-token"));
|
||||
assert!(!raw_json.contains("new-token-that-should-not-persist"));
|
||||
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("existing-token"));
|
||||
|
||||
let loaded = storage.load_connections().await.unwrap();
|
||||
assert_eq!(loaded[0].name, "Pulsar renamed");
|
||||
assert_eq!(mq_token(&loaded[0]), Some("existing-token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_connections_migrates_legacy_mq_auth_token_out_of_config_json() {
|
||||
let path = temp_db_path("mq-token-legacy-migration");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
insert_raw_connection(&storage, &mq_connection("pulsar", "legacy-token")).await;
|
||||
|
||||
let loaded = storage.load_connections().await.unwrap();
|
||||
|
||||
assert_eq!(mq_token(&loaded[0]), Some("legacy-token"));
|
||||
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("legacy-token"));
|
||||
let raw_json = raw_connection_json(&storage, "pulsar").await;
|
||||
assert!(!raw_json.contains("legacy-token"));
|
||||
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
|
||||
assert_eq!(mq_token(&persisted), Some(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connections_deletes_stale_mq_auth_secrets_when_kind_changes() {
|
||||
let path = temp_db_path("mq-auth-kind-change");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
storage.save_connections(&[mq_connection("pulsar", "old-token")]).await.unwrap();
|
||||
let mut config = mq_connection("pulsar", "");
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://127.0.0.1:8080",
|
||||
"auth": {
|
||||
"kind": "basic",
|
||||
"username": "admin",
|
||||
"password": "basic-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
storage.save_connections(&[config]).await.unwrap();
|
||||
|
||||
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap(), None);
|
||||
assert_eq!(storage.get_secret("pulsar", MQ_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), Some("basic-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connections_moves_mq_token_signing_key_to_secret_table_and_restores_it() {
|
||||
let path = temp_db_path("mq-token-signing-secret");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
let mut config = mq_connection("pulsar", "");
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "http://127.0.0.1:8080",
|
||||
"auth": { "kind": "none" },
|
||||
"tokenSigning": {
|
||||
"algorithm": "hs256",
|
||||
"key": "broker-signing-secret"
|
||||
}
|
||||
}));
|
||||
|
||||
storage.save_connections(&[config]).await.unwrap();
|
||||
|
||||
let raw_json = raw_connection_json(&storage, "pulsar").await;
|
||||
assert!(!raw_json.contains("broker-signing-secret"));
|
||||
let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap();
|
||||
assert_eq!(mq_token_signing_key(&persisted), Some(""));
|
||||
assert_eq!(
|
||||
storage.get_secret("pulsar", MQ_TOKEN_SIGNING_KEY).await.unwrap().as_deref(),
|
||||
Some("broker-signing-secret")
|
||||
);
|
||||
|
||||
let loaded = storage.load_connections().await.unwrap();
|
||||
assert_eq!(mq_token_signing_key(&loaded[0]), Some("broker-signing-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_settings_default_to_background_enabled() {
|
||||
let path = temp_db_path("desktop-settings-default");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#![cfg(feature = "mq-admin")]
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use dbx_core::mq::token::{sign_pulsar_token, token_fingerprint};
|
||||
use dbx_core::mq::{MqTokenIssueRequest, MqTokenRecord, MqTokenSigningAlgorithm, MqTokenSigningConfig};
|
||||
use dbx_core::storage::Storage;
|
||||
|
||||
#[test]
|
||||
fn hs256_token_contains_pulsar_subject_and_expiry() {
|
||||
let config = MqTokenSigningConfig { algorithm: MqTokenSigningAlgorithm::Hs256, key: "broker-secret".to_string() };
|
||||
let req = MqTokenIssueRequest {
|
||||
subject: "order-consumer".to_string(),
|
||||
expires_in_seconds: Some(3600),
|
||||
scope: None,
|
||||
actions: vec![],
|
||||
note: None,
|
||||
};
|
||||
|
||||
let issued = sign_pulsar_token(&config, &req, 1_700_000_000).unwrap();
|
||||
let payload = issued.token.split('.').nth(1).unwrap();
|
||||
let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(payload.get("sub").and_then(serde_json::Value::as_str), Some("order-consumer"));
|
||||
assert_eq!(payload.get("iat").and_then(serde_json::Value::as_i64), Some(1_700_000_000));
|
||||
assert_eq!(payload.get("exp").and_then(serde_json::Value::as_i64), Some(1_700_003_600));
|
||||
assert_eq!(issued.fingerprint, token_fingerprint(&issued.token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_signing_requires_configured_key() {
|
||||
let config = MqTokenSigningConfig { algorithm: MqTokenSigningAlgorithm::Hs256, key: " ".to_string() };
|
||||
let req = MqTokenIssueRequest {
|
||||
subject: "order-consumer".to_string(),
|
||||
expires_in_seconds: None,
|
||||
scope: None,
|
||||
actions: vec![],
|
||||
note: None,
|
||||
};
|
||||
|
||||
let err = sign_pulsar_token(&config, &req, 1_700_000_000).unwrap_err();
|
||||
assert!(err.contains("signing key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_records_are_saved_without_token_plaintext() {
|
||||
let storage = Storage::open(&temp_db_path("mq-token-records")).await.unwrap();
|
||||
let record = MqTokenRecord {
|
||||
id: "rec-1".to_string(),
|
||||
connection_id: "conn-1".to_string(),
|
||||
subject: "order-consumer".to_string(),
|
||||
algorithm: MqTokenSigningAlgorithm::Hs256,
|
||||
token_fingerprint: "sha256:abc".to_string(),
|
||||
scope: None,
|
||||
actions: vec![],
|
||||
expires_at: Some("2026-06-14T12:00:00Z".to_string()),
|
||||
created_at: "2026-06-14T11:00:00Z".to_string(),
|
||||
note: "issued for test".to_string(),
|
||||
};
|
||||
|
||||
storage.save_mq_token_record(&record).await.unwrap();
|
||||
let records = storage.load_mq_token_records("conn-1", Some("order-consumer")).await.unwrap();
|
||||
|
||||
assert_eq!(records, vec![record]);
|
||||
let json = serde_json::to_value(&records[0]).unwrap();
|
||||
assert!(json.get("token").is_none());
|
||||
}
|
||||
|
||||
fn temp_db_path(name: &str) -> std::path::PathBuf {
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
std::env::temp_dir().join(format!("dbx-{name}-{}-{stamp}.db", std::process::id()))
|
||||
}
|
||||
@@ -8,6 +8,10 @@ license = "Apache-2.0"
|
||||
name = "dbx-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["mq-admin"]
|
||||
mq-admin = ["dbx-core/mq-admin"]
|
||||
|
||||
[dependencies]
|
||||
dbx-core = { path = "../dbx-core" }
|
||||
redis = { version = "0.32", features = ["tokio-comp"] }
|
||||
|
||||
@@ -32,6 +32,56 @@ fn web_body_limit_bytes() -> usize {
|
||||
mb.saturating_mul(1024 * 1024)
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
||||
router
|
||||
.route("/mq/test-connection", post(routes::mq::test_connection))
|
||||
.route("/mq/tenants/list", post(routes::mq::list_tenants))
|
||||
.route("/mq/tenants/get", post(routes::mq::get_tenant))
|
||||
.route("/mq/tenants/create", post(routes::mq::create_tenant))
|
||||
.route("/mq/tenants/update", post(routes::mq::update_tenant))
|
||||
.route("/mq/tenants/delete", post(routes::mq::delete_tenant))
|
||||
.route("/mq/namespaces/list", post(routes::mq::list_namespaces))
|
||||
.route("/mq/namespaces/create", post(routes::mq::create_namespace))
|
||||
.route("/mq/namespaces/delete", post(routes::mq::delete_namespace))
|
||||
.route("/mq/namespaces/policies", post(routes::mq::get_namespace_policies))
|
||||
.route("/mq/topics/list", post(routes::mq::list_topics))
|
||||
.route("/mq/topics/create", post(routes::mq::create_topic))
|
||||
.route("/mq/topics/delete", post(routes::mq::delete_topic))
|
||||
.route("/mq/topics/update-partitions", post(routes::mq::update_partitions))
|
||||
.route("/mq/topics/stats", post(routes::mq::get_topic_stats))
|
||||
.route("/mq/topics/internal-stats", post(routes::mq::get_topic_internal_stats))
|
||||
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
|
||||
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
|
||||
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
|
||||
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
|
||||
.route("/mq/subscriptions/reset-cursor", post(routes::mq::reset_cursor))
|
||||
.route("/mq/subscriptions/clear-backlog", post(routes::mq::clear_backlog))
|
||||
.route("/mq/subscriptions/peek-messages", post(routes::mq::peek_messages))
|
||||
.route("/mq/subscriptions/expire-messages", post(routes::mq::expire_messages))
|
||||
.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/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/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/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/raw", post(routes::mq::raw_request))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
||||
router
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -341,7 +391,9 @@ async fn main() {
|
||||
"/app-settings/pinned-tree-node-ids",
|
||||
get(routes::app_settings::load_pinned_tree_node_ids).post(routes::app_settings::save_pinned_tree_node_ids),
|
||||
)
|
||||
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config))
|
||||
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config));
|
||||
|
||||
let api = add_mq_routes(api)
|
||||
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
|
||||
.with_state(web_state.clone());
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
@@ -50,6 +51,7 @@ pub async fn test_connection(
|
||||
// Clean up any pool keys created for the temporary connection, including
|
||||
// database-scoped keys like "__test_uuid:database".
|
||||
app.remove_connection_pools(&temp_id).await;
|
||||
app.reset_connection_transport_for_config(&temp_id, &config).await;
|
||||
app.configs.write().await.remove(&temp_id);
|
||||
|
||||
match result {
|
||||
@@ -66,6 +68,8 @@ pub async fn connect_db(
|
||||
let app = &state.app;
|
||||
let connection_id = config.id.clone();
|
||||
|
||||
app.remove_connection_pools(&connection_id).await;
|
||||
app.reset_connection_transport_for_config(&connection_id, &config).await;
|
||||
app.configs.write().await.insert(connection_id.clone(), config.clone());
|
||||
|
||||
app.get_or_create_pool(&connection_id, None).await.map_err(AppError)?;
|
||||
@@ -120,26 +124,86 @@ pub async fn save_connections(
|
||||
Json(body): Json<SaveConnectionsRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.save_connections(&body.configs).await.map_err(AppError)?;
|
||||
cache_connection_configs(&state, &body.configs).await;
|
||||
let sync = sync_connection_configs(&state, &body.configs).await;
|
||||
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
|
||||
drop_mq_adapters_for_connection_ids(&state, &sync.mq_adapter_ids_to_drop).await;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_connections(State(state): State<Arc<WebState>>) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
|
||||
let configs = state.app.storage.load_connections().await.map_err(AppError)?;
|
||||
cache_connection_configs(&state, &configs).await;
|
||||
let sync = sync_connection_configs(&state, &configs).await;
|
||||
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
|
||||
drop_mq_adapters_for_connection_ids(&state, &sync.mq_adapter_ids_to_drop).await;
|
||||
Ok(Json(configs))
|
||||
}
|
||||
|
||||
async fn cache_connection_configs(state: &WebState, configs: &[ConnectionConfig]) {
|
||||
struct ConnectionConfigSync {
|
||||
mq_adapter_ids_to_drop: Vec<String>,
|
||||
connection_pool_ids_to_drop: Vec<String>,
|
||||
}
|
||||
|
||||
async fn sync_connection_configs(state: &WebState, configs: &[ConnectionConfig]) -> ConnectionConfigSync {
|
||||
let saved_ids: HashSet<&str> = configs.iter().map(|config| config.id.as_str()).collect();
|
||||
let mut mq_adapter_ids_to_drop = HashSet::new();
|
||||
let mut connection_pool_ids_to_drop = HashSet::new();
|
||||
let mut runtime_configs = state.app.configs.write().await;
|
||||
runtime_configs.retain(|id, existing| {
|
||||
if saved_ids.contains(id.as_str()) || is_transient_runtime_config_id(id) {
|
||||
true
|
||||
} else {
|
||||
connection_pool_ids_to_drop.insert(id.clone());
|
||||
if existing.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(id.clone());
|
||||
}
|
||||
false
|
||||
}
|
||||
});
|
||||
for config in configs {
|
||||
runtime_configs.insert(config.id.clone(), config.clone());
|
||||
if config.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
if let Some(previous) = runtime_configs.insert(config.id.clone(), config.clone()) {
|
||||
if previous.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
if &previous != config {
|
||||
connection_pool_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionConfigSync {
|
||||
mq_adapter_ids_to_drop: mq_adapter_ids_to_drop.into_iter().collect(),
|
||||
connection_pool_ids_to_drop: connection_pool_ids_to_drop.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_transient_runtime_config_id(id: &str) -> bool {
|
||||
id.starts_with("__test_") || id.starts_with("__visible_draft_")
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
async fn drop_mq_adapters_for_connection_ids(state: &WebState, connection_ids: &[String]) {
|
||||
for connection_id in connection_ids {
|
||||
state.app.mq_registry.drop_connection(connection_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
async fn drop_mq_adapters_for_connection_ids(_state: &WebState, _connection_ids: &[String]) {}
|
||||
|
||||
async fn remove_connection_pools_for_connection_ids(state: &WebState, connection_ids: &[String]) {
|
||||
for connection_id in connection_ids {
|
||||
state.app.remove_connection_pools(connection_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{disconnect_db, save_connections, DisconnectRequest, SaveConnectionsRequest};
|
||||
use super::{
|
||||
connect_db, disconnect_db, load_connections, save_connections, ConnectRequest, DisconnectRequest,
|
||||
SaveConnectionsRequest,
|
||||
};
|
||||
use crate::state::{LoginRateLimit, WebState};
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
@@ -148,6 +212,8 @@ mod tests {
|
||||
use dbx_core::storage::Storage;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
|
||||
@@ -196,6 +262,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn mq_config(id: &str, admin_url: &str) -> ConnectionConfig {
|
||||
let mut config = sqlite_config(id, "");
|
||||
config.name = "Pulsar".to_string();
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": admin_url,
|
||||
"auth": { "kind": "none" },
|
||||
"pinnedVersion": "3.1"
|
||||
}));
|
||||
config
|
||||
}
|
||||
|
||||
async fn test_web_state() -> (Arc<WebState>, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-web-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -215,6 +294,34 @@ mod tests {
|
||||
(state, dir)
|
||||
}
|
||||
|
||||
async fn spawn_pulsar_clusters_server() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0_u8; 1024];
|
||||
let Ok(n) = stream.read(&mut buf).await else {
|
||||
return;
|
||||
};
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let status =
|
||||
if request.starts_with("GET /admin/v2/clusters ") { "200 OK" } else { "404 Not Found" };
|
||||
let body = if status.starts_with("200") { r#"["ec-pulsar"]"# } else { r#"{"reason":"missing"}"# };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connections_updates_runtime_config_cache() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
@@ -232,6 +339,133 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connections_drops_cached_mq_adapter_for_updated_config() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap();
|
||||
|
||||
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![updated.clone()] }))
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cached_admin_url = state
|
||||
.app
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get("mq-conn")
|
||||
.and_then(|config| config.external_config.as_ref())
|
||||
.and_then(|external| external.get("adminUrl"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
|
||||
|
||||
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
assert!(!state.app.connections.read().await.contains_key(&initial.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn connect_db_rebuilds_mq_adapter_for_updated_config_with_same_id() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap();
|
||||
|
||||
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
|
||||
let result = connect_db(State(state.clone()), Json(ConnectRequest { config: updated.clone() })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_connections_drops_cached_pool_for_updated_config() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||
state.app.storage.save_connections(&[updated.clone()]).await.unwrap();
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
let result = load_connections(State(state.clone())).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
let cached_admin_url = configs
|
||||
.get("mq-conn")
|
||||
.and_then(|config| config.external_config.as_ref())
|
||||
.and_then(|external| external.get("adminUrl"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
assert_eq!(cached_admin_url, Some("http://127.0.0.1:8081"));
|
||||
drop(configs);
|
||||
assert!(!state.app.connections.read().await.contains_key(&initial.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connections_removes_deleted_runtime_config_and_mq_adapter() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let kept = sqlite_config("kept", &dir.join("kept.db").to_string_lossy());
|
||||
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
|
||||
{
|
||||
let mut configs = state.app.configs.write().await;
|
||||
configs.insert(kept.id.clone(), kept.clone());
|
||||
configs.insert(removed.id.clone(), removed.clone());
|
||||
}
|
||||
let stale = state.app.mq_registry.get_or_build(&removed).await.unwrap();
|
||||
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
assert!(configs.contains_key("kept"));
|
||||
assert!(!configs.contains_key("removed-mq"));
|
||||
drop(configs);
|
||||
|
||||
let rebuilt = state.app.mq_registry.get_or_build(&removed).await.unwrap();
|
||||
assert!(!Arc::ptr_eq(&stale, &rebuilt));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connections_removes_deleted_connection_pools() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let kept = sqlite_config("kept", &dir.join("kept.db").to_string_lossy());
|
||||
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
|
||||
{
|
||||
let mut configs = state.app.configs.write().await;
|
||||
configs.insert(kept.id.clone(), kept.clone());
|
||||
configs.insert(removed.id.clone(), removed.clone());
|
||||
}
|
||||
state.app.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert!(!state.app.connections.read().await.contains_key(&removed.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_db_keeps_connections_with_similar_prefixes() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
@@ -285,6 +519,26 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn disconnect_db_drops_cached_mq_adapter() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let config = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
state.app.configs.write().await.insert(config.id.clone(), config.clone());
|
||||
state.app.connections.write().await.insert(config.id.clone(), PoolKind::MessageQueue);
|
||||
let first = state.app.mq_registry.get_or_build(&config).await.unwrap();
|
||||
|
||||
let result =
|
||||
disconnect_db(State(state.clone()), Json(DisconnectRequest { connection_id: config.id.clone() })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert!(!state.app.connections.read().await.contains_key(&config.id));
|
||||
let second = state.app.mq_registry.get_or_build(&config).await.unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_db_removes_visible_database_draft_config() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
|
||||
@@ -9,6 +9,8 @@ pub mod history;
|
||||
pub mod jdbc;
|
||||
pub mod layout;
|
||||
pub mod mongo;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq;
|
||||
pub mod plugins;
|
||||
pub mod query;
|
||||
pub mod redis;
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! Web (Axum) routes for message queue admin operations.
|
||||
//!
|
||||
//! Mirrors the desktop command layer, sharing the same `dbx_core::mq::service::*_core`
|
||||
//! functions, with read-only protection for mutating operations.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
// Request wrappers for endpoints that need more than just connection_id.
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConnReq {
|
||||
connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TenantReq {
|
||||
connection_id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreateTenantReq {
|
||||
connection_id: String,
|
||||
name: String,
|
||||
config: dbx_core::mq::TenantConfig,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateTenantReq {
|
||||
connection_id: String,
|
||||
name: String,
|
||||
config: dbx_core::mq::TenantConfig,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteTenantReq {
|
||||
connection_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ListNamespacesReq {
|
||||
connection_id: String,
|
||||
tenant: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreateNamespaceReq {
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
config: dbx_core::mq::NamespaceConfig,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteNamespaceReq {
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NamespacePoliciesReq {
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ListTopicsReq {
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
opts: dbx_core::mq::ListTopicsOpts,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreateTopicReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
partitions: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteTopicReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdatePartitionsReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
partitions: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TopicReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreateSubscriptionReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
pos: dbx_core::mq::ResetPosition,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteSubscriptionReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkipMessagesReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
count: dbx_core::mq::SkipCount,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ResetCursorReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
pos: dbx_core::mq::ResetPosition,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SubscriptionReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PeekMessagesReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ExpireMessagesReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
expire_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SetPublishRateReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::PublishRate,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SetDispatchRateReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::DispatchRate,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SetSubscribeRateReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::SubscribeRate,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SetBacklogQuotaReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
quota: dbx_core::mq::BacklogQuota,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SetRetentionReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
retention: dbx_core::mq::RetentionPolicy,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PolicyScopeReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GrantPermissionReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
role: String,
|
||||
actions: Vec<dbx_core::mq::AuthAction>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RevokePermissionReq {
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
role: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct IssueTokenReq {
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::MqTokenIssueRequest,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ListTokenRecordsReq {
|
||||
connection_id: String,
|
||||
subject: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BacklogReq {
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RawRequestReq {
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::MqRawRequest,
|
||||
}
|
||||
|
||||
// ---- Helper: writable check ----
|
||||
|
||||
async fn ensure_writable(app: &dbx_core::connection::AppState, conn_id: &str, action: &str) -> Result<(), AppError> {
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(app, conn_id).await {
|
||||
return Err(AppError(format!("Read-only connection '{name}'. {action} is blocked.")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
pub async fn test_connection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ConnReq>,
|
||||
) -> Result<Json<dbx_core::mq::MqClusterInfo>, AppError> {
|
||||
let result =
|
||||
dbx_core::mq::service::mq_test_connection_core(&state.app, &req.connection_id).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ConnReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::TenantInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_tenants_core(&state.app, &req.connection_id).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_tenant(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<TenantReq>,
|
||||
) -> Result<Json<dbx_core::mq::TenantInfo>, AppError> {
|
||||
let result =
|
||||
dbx_core::mq::service::mq_get_tenant_core(&state.app, &req.connection_id, &req.name).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create_tenant(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CreateTenantReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Create tenant").await?;
|
||||
dbx_core::mq::service::mq_create_tenant_core(&state.app, &req.connection_id, &req.name, req.config)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn update_tenant(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<UpdateTenantReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Update tenant").await?;
|
||||
dbx_core::mq::service::mq_update_tenant_core(&state.app, &req.connection_id, &req.name, req.config)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_tenant(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<DeleteTenantReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete tenant").await?;
|
||||
dbx_core::mq::service::mq_delete_tenant_core(&state.app, &req.connection_id, &req.name, req.force)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_namespaces(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ListNamespacesReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::NamespaceInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_namespaces_core(&state.app, &req.connection_id, &req.tenant)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create_namespace(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CreateNamespaceReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Create namespace").await?;
|
||||
dbx_core::mq::service::mq_create_namespace_core(&state.app, &req.connection_id, req.ns, req.config)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_namespace(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<DeleteNamespaceReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete namespace").await?;
|
||||
dbx_core::mq::service::mq_delete_namespace_core(&state.app, &req.connection_id, req.ns, req.force)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn get_namespace_policies(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<NamespacePoliciesReq>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_get_namespace_policies_core(&state.app, &req.connection_id, req.ns)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_topics(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ListTopicsReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::TopicInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_topics_core(&state.app, &req.connection_id, req.ns, req.opts)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create_topic(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CreateTopicReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Create topic").await?;
|
||||
dbx_core::mq::service::mq_create_topic_core(&state.app, &req.connection_id, req.topic, req.partitions)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_topic(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<DeleteTopicReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete topic").await?;
|
||||
dbx_core::mq::service::mq_delete_topic_core(&state.app, &req.connection_id, req.topic, req.force)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn update_partitions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<UpdatePartitionsReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Update partitions").await?;
|
||||
dbx_core::mq::service::mq_update_partitions_core(&state.app, &req.connection_id, req.topic, req.partitions)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn get_topic_stats(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<TopicReq>,
|
||||
) -> Result<Json<dbx_core::mq::TopicStats>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_get_topic_stats_core(&state.app, &req.connection_id, req.topic)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_topic_internal_stats(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<TopicReq>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_get_topic_internal_stats_core(&state.app, &req.connection_id, req.topic)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_subscriptions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<TopicReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::SubscriptionInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_subscriptions_core(&state.app, &req.connection_id, req.topic)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create_subscription(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CreateSubscriptionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Create subscription").await?;
|
||||
dbx_core::mq::service::mq_create_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_subscription(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<DeleteSubscriptionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete subscription").await?;
|
||||
dbx_core::mq::service::mq_delete_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.force)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn skip_messages(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SkipMessagesReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Skip messages").await?;
|
||||
dbx_core::mq::service::mq_skip_messages_core(&state.app, &req.connection_id, req.topic, req.sub, req.count)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn reset_cursor(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ResetCursorReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Reset cursor").await?;
|
||||
dbx_core::mq::service::mq_reset_cursor_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn clear_backlog(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SubscriptionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Clear backlog").await?;
|
||||
dbx_core::mq::service::mq_clear_backlog_core(&state.app, &req.connection_id, req.topic, req.sub)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn peek_messages(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<PeekMessagesReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::PeekedMessage>>, AppError> {
|
||||
let result =
|
||||
dbx_core::mq::service::mq_peek_messages_core(&state.app, &req.connection_id, req.topic, req.sub, req.count)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn expire_messages(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExpireMessagesReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Expire messages").await?;
|
||||
dbx_core::mq::service::mq_expire_messages_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.topic,
|
||||
req.sub,
|
||||
req.expire_seconds,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_producers(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<TopicReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::ProducerInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_producers_core(&state.app, &req.connection_id, req.topic)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_consumers(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SubscriptionReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::ConsumerInfo>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_consumers_core(&state.app, &req.connection_id, req.topic, req.sub)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn unload_topic(State(state): State<Arc<WebState>>, Json(req): Json<TopicReq>) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Unload topic").await?;
|
||||
dbx_core::mq::service::mq_unload_topic_core(&state.app, &req.connection_id, req.topic).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_publish_rate(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SetPublishRateReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Set publish rate").await?;
|
||||
dbx_core::mq::service::mq_set_publish_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_dispatch_rate(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SetDispatchRateReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Set dispatch rate").await?;
|
||||
dbx_core::mq::service::mq_set_dispatch_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_subscribe_rate(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SetSubscribeRateReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Set subscribe rate").await?;
|
||||
dbx_core::mq::service::mq_set_subscribe_rate_core(&state.app, &req.connection_id, req.scope, req.rate)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_backlog_quota(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SetBacklogQuotaReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Set backlog quota").await?;
|
||||
dbx_core::mq::service::mq_set_backlog_quota_core(&state.app, &req.connection_id, req.scope, req.quota)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_retention(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SetRetentionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Set retention").await?;
|
||||
dbx_core::mq::service::mq_set_retention_core(&state.app, &req.connection_id, req.scope, req.retention)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn get_effective_policies(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<PolicyScopeReq>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_get_effective_policies_core(&state.app, &req.connection_id, req.scope)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn grant_permission(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<GrantPermissionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Grant permission").await?;
|
||||
dbx_core::mq::service::mq_grant_permission_core(&state.app, &req.connection_id, req.scope, req.role, req.actions)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn revoke_permission(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RevokePermissionReq>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Revoke permission").await?;
|
||||
dbx_core::mq::service::mq_revoke_permission_core(&state.app, &req.connection_id, req.scope, req.role)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_permissions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<PolicyScopeReq>,
|
||||
) -> Result<Json<dbx_core::mq::PermissionMap>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_permissions_core(&state.app, &req.connection_id, req.scope)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn issue_token(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<IssueTokenReq>,
|
||||
) -> Result<Json<dbx_core::mq::MqIssuedToken>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Issue MQ token").await?;
|
||||
let result =
|
||||
dbx_core::mq::service::mq_issue_token_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_token_records(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ListTokenRecordsReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::MqTokenRecord>>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_list_token_records_core(&state.app, &req.connection_id, req.subject)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_backlog(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<BacklogReq>,
|
||||
) -> Result<Json<dbx_core::mq::BacklogStats>, AppError> {
|
||||
let result = dbx_core::mq::service::mq_get_backlog_core(&state.app, &req.connection_id, req.topic, req.sub)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn raw_request(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RawRequestReq>,
|
||||
) -> Result<Json<dbx_core::mq::MqRawResponse>, AppError> {
|
||||
if req.req.is_mutating() {
|
||||
ensure_writable(&state.app, &req.connection_id, "MQ admin write").await?;
|
||||
}
|
||||
let result =
|
||||
dbx_core::mq::service::mq_raw_request_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
# 消息队列管理功能 - 快速开始
|
||||
|
||||
## 概述
|
||||
|
||||
该功能为 DBX 增加了消息队列(Message Queue)管理能力,首批支持 **Apache Pulsar** 的管理操作。架构保留 Kafka、RocketMQ 扩展点,但在适配器完成前 UI 只允许创建 Pulsar 连接。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 已实现(Pulsar 首期核心)
|
||||
- ✅ **租户管理**:创建、删除、查询、更新租户信息
|
||||
- ✅ **命名空间管理**:创建、删除、查询命名空间,配置策略
|
||||
- ✅ **主题管理**:创建普通/分区主题,删除、查询统计信息
|
||||
- ✅ **订阅管理**:创建、删除订阅,重置游标,清理积压,跳过消息
|
||||
- ✅ **生产者/消费者监控**:查看活跃的生产者和消费者
|
||||
- ✅ **策略配置**:发布速率、分发速率、订阅速率、积压配额、保留策略
|
||||
- ✅ **权限管理**:授予/撤销角色权限
|
||||
- ✅ **监控统计**:消息速率、吞吐量、积压大小
|
||||
- ✅ **Raw Admin API**:直接发送自定义管理请求(逃生通道)
|
||||
|
||||
### 前端 UI(Pulsar 首期核心可用)
|
||||
- ✅ 连接配置、控制台入口、租户/命名空间/主题/订阅/监控/生产者消费者/策略/权限/Raw API 面板
|
||||
- ⚠️ 后续扩展:完整 i18n、Kafka/RocketMQ 适配器、高级 typed policy(最大生产者/消费者数、TTL、deduplication)
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端 (Vue) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ MqAdminConsole │ │ TenantsPanel │ │ TopicsPanel │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ ▼ mq-api.ts (统一 API 层) │
|
||||
│ ┌──────────┴───────────┐ │
|
||||
│ │ mq-tauri.ts │ mq-http.ts │ │
|
||||
└──────┴─────────────┴────────────────────────────────────────┘
|
||||
▼ Tauri Invoke ▼ HTTP Fetch
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Rust 后端 (Tauri / Axum) │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ commands/mq_cmd │ ◀─▶ │ routes/mq │ │
|
||||
│ └────────────────┘ └──────────────────┘ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ dbx-core/mq/service.rs (*_core) │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ MqAdminRegistry (缓存 adapter 实例) │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ MessageQueueAdmin trait (port.rs) │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ PulsarAdapter (adapters/pulsar.rs) │ │
|
||||
│ │ • 版本探测 (pulsar_version.rs) │ │
|
||||
│ │ • OAuth2 token 缓存 (auth.rs) │ │
|
||||
│ │ • SSRF 防护 │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▼ HTTPS
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Apache Pulsar Admin REST API │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速配置
|
||||
|
||||
### 1. 创建 Pulsar 连接
|
||||
|
||||
在 DBX 中添加新连接,配置如下:
|
||||
|
||||
**基本信息**:
|
||||
- **数据库类型**:选择 `mq` (Message Queue)
|
||||
- **连接名称**:例如 "Pulsar Production"
|
||||
- **Host/Port**:填写任意值(MQ 连接不使用这些字段)
|
||||
|
||||
**扩展配置** (external_config JSON):
|
||||
```json
|
||||
{
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "https://pulsar.example.com:8443",
|
||||
"auth": {
|
||||
"kind": "token",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
},
|
||||
"tlsSkipVerify": false
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 认证方式
|
||||
|
||||
#### Token 认证(最常用)
|
||||
```json
|
||||
{
|
||||
"kind": "token",
|
||||
"token": "your-jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
#### Basic 认证
|
||||
```json
|
||||
{
|
||||
"kind": "basic",
|
||||
"username": "admin",
|
||||
"password": "admin-secret"
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth2 认证(自动缓存 token)
|
||||
```json
|
||||
{
|
||||
"kind": "oauth2",
|
||||
"issuerUrl": "https://auth.example.com/oauth/token",
|
||||
"clientId": "pulsar-admin",
|
||||
"clientSecret": "client-secret",
|
||||
"audience": "https://pulsar.example.com",
|
||||
"scope": "pulsar:admin"
|
||||
}
|
||||
```
|
||||
|
||||
#### API Key 认证
|
||||
```json
|
||||
{
|
||||
"kind": "apiKey",
|
||||
"header": "X-API-Key",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
```
|
||||
|
||||
#### 无认证
|
||||
```json
|
||||
{
|
||||
"kind": "none"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 高级选项
|
||||
|
||||
```json
|
||||
{
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": "https://pulsar.example.com:8443",
|
||||
"auth": { "kind": "token", "token": "..." },
|
||||
"tlsSkipVerify": true, // 跳过 TLS 证书验证(开发环境)
|
||||
"pinnedVersion": "3.1.0" // 强制使用特定 API profile
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 租户管理
|
||||
|
||||
```typescript
|
||||
import { mqListTenants, mqCreateTenant } from '@/lib/mq-api'
|
||||
|
||||
// 列出所有租户
|
||||
const tenants = await mqListTenants(connectionId)
|
||||
|
||||
// 创建租户
|
||||
await mqCreateTenant(connectionId, 'my-tenant', {
|
||||
adminRoles: ['admin', 'ops'],
|
||||
allowedClusters: ['prod-cluster']
|
||||
})
|
||||
```
|
||||
|
||||
### 命名空间管理
|
||||
|
||||
```typescript
|
||||
import { mqListNamespaces, mqCreateNamespace } from '@/lib/mq-api'
|
||||
|
||||
// 列出租户下的命名空间
|
||||
const namespaces = await mqListNamespaces(connectionId, 'my-tenant')
|
||||
|
||||
// 创建命名空间
|
||||
await mqCreateNamespace(connectionId, {
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace'
|
||||
}, {})
|
||||
```
|
||||
|
||||
### 主题管理
|
||||
|
||||
```typescript
|
||||
import { mqListTopics, mqCreateTopic, mqGetTopicStats } from '@/lib/mq-api'
|
||||
|
||||
// 列出主题
|
||||
const topics = await mqListTopics(connectionId, {
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace'
|
||||
}, {
|
||||
includeNonPersistent: false
|
||||
})
|
||||
|
||||
// 创建分区主题
|
||||
await mqCreateTopic(connectionId, {
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace',
|
||||
topic: 'my-topic',
|
||||
persistent: true
|
||||
}, 4) // 4 个分区
|
||||
|
||||
// 查询主题统计
|
||||
const stats = await mqGetTopicStats(connectionId, {
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace',
|
||||
topic: 'my-topic',
|
||||
persistent: true
|
||||
})
|
||||
console.log(`消息速率: ${stats.msgRateIn}/s`)
|
||||
console.log(`积压大小: ${stats.backlogSize} bytes`)
|
||||
```
|
||||
|
||||
### 订阅管理
|
||||
|
||||
```typescript
|
||||
import {
|
||||
mqListSubscriptions,
|
||||
mqCreateSubscription,
|
||||
mqResetCursor,
|
||||
mqClearBacklog
|
||||
} from '@/lib/mq-api'
|
||||
|
||||
const topicRef = {
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace',
|
||||
topic: 'my-topic',
|
||||
persistent: true
|
||||
}
|
||||
|
||||
// 列出订阅
|
||||
const subs = await mqListSubscriptions(connectionId, topicRef)
|
||||
|
||||
// 创建订阅(从最早消息开始)
|
||||
await mqCreateSubscription(connectionId, topicRef, 'my-subscription', {
|
||||
kind: 'earliest'
|
||||
})
|
||||
|
||||
// 重置游标到最新
|
||||
await mqResetCursor(connectionId, topicRef, 'my-subscription', {
|
||||
kind: 'latest'
|
||||
})
|
||||
|
||||
// 清空积压
|
||||
await mqClearBacklog(connectionId, topicRef, 'my-subscription')
|
||||
```
|
||||
|
||||
### 策略配置
|
||||
|
||||
```typescript
|
||||
import { mqSetPublishRate, mqSetRetention } from '@/lib/mq-api'
|
||||
|
||||
const scope = {
|
||||
level: 'namespace' as const,
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace'
|
||||
}
|
||||
|
||||
// 设置发布速率限制
|
||||
await mqSetPublishRate(connectionId, scope, {
|
||||
publishThrottlingRateInMsg: 1000, // 1000 msg/s
|
||||
publishThrottlingRateInByte: 1048576 // 1 MB/s
|
||||
})
|
||||
|
||||
// 设置保留策略
|
||||
await mqSetRetention(connectionId, scope, {
|
||||
retentionTimeInMinutes: 7 * 24 * 60, // 7 天
|
||||
retentionSizeInMb: 10240 // 10 GB
|
||||
})
|
||||
```
|
||||
|
||||
### 权限管理
|
||||
|
||||
```typescript
|
||||
import { mqGrantPermission, mqListPermissions } from '@/lib/mq-api'
|
||||
|
||||
const scope = {
|
||||
level: 'namespace' as const,
|
||||
tenant: 'my-tenant',
|
||||
namespace: 'my-namespace'
|
||||
}
|
||||
|
||||
// 授予权限
|
||||
await mqGrantPermission(connectionId, scope, 'my-app-role', ['produce', 'consume'])
|
||||
|
||||
// 查询权限
|
||||
const permissions = await mqListPermissions(connectionId, scope)
|
||||
console.log(permissions) // { 'my-app-role': ['produce', 'consume'] }
|
||||
```
|
||||
|
||||
## 桌面端命令注册
|
||||
|
||||
所有后端功能已通过 Tauri commands 暴露给前端:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/lib.rs
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// ... 其他命令
|
||||
commands::mq_cmd::mq_test_connection,
|
||||
commands::mq_cmd::mq_list_tenants,
|
||||
commands::mq_cmd::mq_create_tenant,
|
||||
// ... 共 48 个 mq 命令
|
||||
])
|
||||
```
|
||||
|
||||
## Web 端路由注册
|
||||
|
||||
所有后端功能已通过 Axum routes 暴露为 REST API:
|
||||
|
||||
```rust
|
||||
// crates/dbx-web/src/main.rs
|
||||
.route("/mq/test-connection", post(routes::mq::test_connection))
|
||||
.route("/mq/tenants/list", post(routes::mq::list_tenants))
|
||||
.route("/mq/tenants/create", post(routes::mq::create_tenant))
|
||||
// ... 共 48 个路由
|
||||
```
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 1. 只读保护
|
||||
所有变更操作会检查连接的 `read_only` 标志:
|
||||
```rust
|
||||
ensure_connection_writable(&state, &connection_id, "Create tenant").await?;
|
||||
```
|
||||
|
||||
### 2. SSRF 防护
|
||||
Raw request 路径必须是相对当前 Admin base 的绝对路径:必须以 `/` 开头,不能包含 scheme/host,也不能包含 `..` 路径段:
|
||||
```rust
|
||||
if path.contains("://") || path.starts_with("//") || !path.starts_with('/') || path.split('/').any(|seg| seg == "..") {
|
||||
return Err("Raw request path is not safe".to_string());
|
||||
}
|
||||
```
|
||||
|
||||
### 3. OAuth2 Token 缓存
|
||||
避免每次请求都交换 token(60 秒缓存,5 秒提前刷新):
|
||||
```rust
|
||||
pub struct OAuth2TokenCache {
|
||||
token: RwLock<Option<CachedToken>>,
|
||||
config: OAuth2Config,
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 版本兼容性
|
||||
自动探测 Pulsar 版本,优雅降级不支持的 API:
|
||||
```rust
|
||||
async fn detect_pulsar_version(client: &reqwest::Client, base_url: &str) -> PulsarProfile {
|
||||
// 探测 /admin/v2/brokers/version
|
||||
// 返回 3.1.x baseline profile
|
||||
}
|
||||
```
|
||||
|
||||
## 编译与运行
|
||||
|
||||
### 启用 MQ 功能
|
||||
```bash
|
||||
# 默认已启用(default = ["duckdb-bundled", "mq-admin"])
|
||||
cargo build --release
|
||||
|
||||
# 或显式指定
|
||||
cargo build --release --features mq-admin
|
||||
```
|
||||
|
||||
### 禁用 MQ 功能
|
||||
```bash
|
||||
cargo build --release --no-default-features --features duckdb-bundled
|
||||
```
|
||||
|
||||
### 检查编译
|
||||
```bash
|
||||
cargo check --package dbx-core --features mq-admin
|
||||
cargo check --package dbx --features mq-admin
|
||||
cargo check --package dbx-web --features mq-admin
|
||||
```
|
||||
|
||||
## 后续扩展
|
||||
|
||||
### 添加新的 MQ 系统(如 Kafka)
|
||||
|
||||
当前版本仅在架构和类型层预留 Kafka/RocketMQ;前端连接对话框不会提供这些选项。新增适配器并完成验证后,再开放 UI 选择。
|
||||
|
||||
1. 实现 `MessageQueueAdmin` trait:
|
||||
```rust
|
||||
// crates/dbx-core/src/mq/adapters/kafka.rs
|
||||
pub struct KafkaAdapter {
|
||||
config: Arc<ConnectionConfig>,
|
||||
client: KafkaAdminClient,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageQueueAdmin for KafkaAdapter {
|
||||
async fn test_connection(&self) -> Result<MqClusterInfo, String> {
|
||||
// Kafka 特定的连接测试
|
||||
}
|
||||
|
||||
async fn list_topics(&self, ns: &NamespaceRef, opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String> {
|
||||
// Kafka 使用不同的 topic 模型,适配到统一接口
|
||||
}
|
||||
|
||||
// ... 实现其他方法
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `MqAdminRegistry::build_adapter` 中注册:
|
||||
```rust
|
||||
match mq_config.system_kind {
|
||||
MqSystemKind::Pulsar => { /* ... */ }
|
||||
MqSystemKind::Kafka => {
|
||||
Ok(Arc::new(KafkaAdapter::new(config.clone()).await?))
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. 适配器、能力位和端到端验证完成后,再在连接对话框开放对应系统选择。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 连接测试失败 "SSL certificate problem"
|
||||
A: 开发环境可设置 `tlsSkipVerify: true`,生产环境应配置正确的 CA 证书。
|
||||
|
||||
### Q: 权限不足 "Not authorized"
|
||||
A: 检查 token 是否有 `admin` 或相应的角色权限。
|
||||
|
||||
### Q: 某些 API 返回 404
|
||||
A: 可能是 Pulsar 版本不支持该 API,检查 `test_connection` 返回的 `capabilities`。
|
||||
|
||||
### Q: OAuth2 token 一直刷新失败
|
||||
A: 检查 `issuerUrl`、`clientId`、`clientSecret`、`audience` 是否正确。
|
||||
|
||||
### Q: 如何调试 Admin API
|
||||
A: 使用 Raw API 面板或直接调用 `mqRawRequest`:
|
||||
```typescript
|
||||
const response = await mqRawRequest(connectionId, {
|
||||
method: 'GET',
|
||||
path: '/admin/v2/clusters',
|
||||
query: {},
|
||||
body: null
|
||||
})
|
||||
console.log(response.body)
|
||||
```
|
||||
|
||||
|
||||
@@ -97,6 +97,18 @@ test("serializes table tabs with reload context", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes MQ tabs with selected tenant context", () => {
|
||||
const saved = serializeOpenTabs([
|
||||
queryTab({
|
||||
mode: "mq",
|
||||
database: "",
|
||||
mqTenant: "public",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(saved[0]?.mqTenant, "public");
|
||||
});
|
||||
|
||||
test("serializes evicted result cache handles", () => {
|
||||
const saved = serializeOpenTabs([
|
||||
queryTab({
|
||||
@@ -302,6 +314,20 @@ test("restores data and structure tabs with table state", () => {
|
||||
assert.equal(restored.tabs[1]?.structureTableName, "users");
|
||||
});
|
||||
|
||||
test("restores MQ tabs with selected tenant context", () => {
|
||||
const raw = JSON.stringify([
|
||||
queryTab({
|
||||
mode: "mq",
|
||||
database: "",
|
||||
mqTenant: "public",
|
||||
}),
|
||||
]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "tab-1");
|
||||
|
||||
assert.equal(restored.tabs[0]?.mqTenant, "public");
|
||||
});
|
||||
|
||||
test("query-only restore keeps legacy query tabs without a mode", () => {
|
||||
const raw = JSON.stringify([
|
||||
{
|
||||
|
||||
@@ -59,6 +59,33 @@ test("mongo tabs target the matching visible collection node", () => {
|
||||
assert.equal(findSidebarNodeForActiveTab(tab, [flat(collection)])?.id, "events-node");
|
||||
});
|
||||
|
||||
test("MQ tabs with a selected tenant target the matching tenant node", () => {
|
||||
const tab: QueryTab = {
|
||||
id: "tab-1",
|
||||
title: "Apache Pulsar Admin",
|
||||
connectionId: "conn-1",
|
||||
database: "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
mode: "mq",
|
||||
mqTenant: "public",
|
||||
};
|
||||
const tenant: TreeNode = {
|
||||
id: "tenant-node",
|
||||
label: "public",
|
||||
type: "mq-tenant",
|
||||
connectionId: "conn-1",
|
||||
mqTenant: "public",
|
||||
};
|
||||
|
||||
assert.deepEqual(activeTabSidebarTarget(tab), {
|
||||
type: "mq-tenant",
|
||||
connectionId: "conn-1",
|
||||
tenant: "public",
|
||||
});
|
||||
assert.equal(findSidebarNodeForActiveTab(tab, [flat(tenant)])?.id, "tenant-node");
|
||||
});
|
||||
|
||||
test("saved SQL tabs target the matching visible saved SQL file node", () => {
|
||||
const tab: QueryTab = {
|
||||
id: "tab-1",
|
||||
|
||||
@@ -46,7 +46,7 @@ function formatQueryToolResult(result: QueryResult, title?: string) {
|
||||
}
|
||||
|
||||
export const DBX_CONNECTION_TYPE_DESCRIPTION =
|
||||
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access";
|
||||
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access, mq";
|
||||
const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]);
|
||||
|
||||
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
|
||||
function assertIncludes(file, needle, message) {
|
||||
assert.ok(read(file).includes(needle), `${message}\nMissing ${needle} in ${file}`);
|
||||
}
|
||||
|
||||
function assertMatches(file, pattern, message) {
|
||||
assert.match(read(file), pattern, `${message}\nPattern ${pattern} not found in ${file}`);
|
||||
}
|
||||
|
||||
assertMatches("src-tauri/Cargo.toml", /mq-admin\s*=\s*\[[^\]]*"dbx-core\/mq-admin"[^\]]*\]/, "Tauri crate must expose an mq-admin feature that enables dbx-core/mq-admin.");
|
||||
assertMatches("src-tauri/Cargo.toml", /default\s*=\s*\[[^\]]*"mq-admin"[^\]]*\]/, "Tauri default features must include mq-admin so desktop commands are registered.");
|
||||
assertMatches("crates/dbx-web/Cargo.toml", /\[features\][\s\S]*mq-admin\s*=\s*\[[^\]]*"dbx-core\/mq-admin"[^\]]*\]/, "Web crate must expose an mq-admin feature that enables dbx-core/mq-admin.");
|
||||
assertMatches("crates/dbx-web/Cargo.toml", /\[features\][\s\S]*default\s*=\s*\[[^\]]*"mq-admin"[^\]]*\]/, "Web default features must include mq-admin so web routes are registered.");
|
||||
|
||||
assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "MqAdminConsole", "Main content area must import and render the MQ admin console.");
|
||||
assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "activeTab.mode === 'mq'", "Main content area must have an mq mode render branch.");
|
||||
assertIncludes("apps/desktop/src/components/sidebar/TreeItem.vue", '"mq"', "Sidebar connection handling must be aware of MQ connections.");
|
||||
assertIncludes("apps/desktop/src/stores/queryStore.ts", "openMqAdmin", "Query store must expose an MQ admin tab opener.");
|
||||
|
||||
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 mqHttp = read("apps/desktop/src/lib/mq-http.ts");
|
||||
assert.ok(!mqHttp.includes('post("/mq/'), "MQ HTTP client must not call unprefixed /mq paths.");
|
||||
assert.ok(mqHttp.includes('post("/api/mq/'), "MQ HTTP client must call /api/mq paths in web mode.");
|
||||
|
||||
assertIncludes("apps/desktop/src/lib/api.ts", 'mqTestConnection = forward("mqTestConnection")', "MQ frontend calls must use the shared forward() API layer.");
|
||||
assertIncludes("apps/desktop/src/components/connection/ConnectionDialog.vue", "mqAdminUrl", "Connection dialog must include MQ admin URL fields.");
|
||||
assertIncludes("apps/desktop/src/components/connection/ConnectionDialog.vue", "external_config", "Connection dialog must submit MQ external_config.");
|
||||
|
||||
const mqConsole = read("apps/desktop/src/components/mq/MqAdminConsole.vue");
|
||||
for (const tab of ["policies", "permissions", "raw"]) {
|
||||
assert.ok(mqConsole.includes(`'${tab}'`), `MQ console must expose a ${tab} tab.`);
|
||||
}
|
||||
for (const panel of ["PoliciesPanel.vue", "PermissionsPanel.vue", "RawApiPanel.vue"]) {
|
||||
assert.ok(fs.existsSync(path.join(root, "apps/desktop/src/components/mq", panel)), `Missing MQ panel: ${panel}`);
|
||||
assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must honor read-only mode.`);
|
||||
}
|
||||
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.`);
|
||||
}
|
||||
|
||||
console.log("MQ integration checks passed");
|
||||
@@ -13,8 +13,9 @@ name = "dbx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["duckdb-bundled"]
|
||||
default = ["duckdb-bundled", "mq-admin"]
|
||||
duckdb-bundled = ["duckdb", "dbx-core/duckdb-bundled"]
|
||||
mq-admin = ["dbx-core/mq-admin"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -167,8 +168,10 @@ async fn connect_agent_pool(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mongo_legacy_connect_params;
|
||||
use super::{load_connection_configs, mongo_legacy_connect_params, save_connection_configs};
|
||||
use dbx_core::connection::{AppState, PoolKind};
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::storage::Storage;
|
||||
|
||||
fn mongodb_config() -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
@@ -218,6 +221,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn mq_config(id: &str, admin_url: &str) -> ConnectionConfig {
|
||||
let mut config = mongodb_config();
|
||||
config.id = id.to_string();
|
||||
config.name = "Pulsar".to_string();
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.driver_profile = None;
|
||||
config.driver_label = None;
|
||||
config.url_params = None;
|
||||
config.host = String::new();
|
||||
config.port = 0;
|
||||
config.username = String::new();
|
||||
config.password = String::new();
|
||||
config.database = None;
|
||||
config.connection_string = None;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "pulsar",
|
||||
"adminUrl": admin_url,
|
||||
"auth": { "kind": "none" },
|
||||
"pinnedVersion": "3.1"
|
||||
}));
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_legacy_connect_params_preserve_auth_options() {
|
||||
let config = mongodb_config();
|
||||
@@ -231,21 +257,210 @@ mod tests {
|
||||
"mongodb://mongouser:secret@172.22.4.42:27017/RestCloud_V45PUB_Gateway?authSource=admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connection_configs_updates_runtime_cache_and_drops_mq_adapter() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-tauri-conn-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
||||
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
state.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
let first = state.mq_registry.get_or_build(&initial).await.unwrap();
|
||||
|
||||
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||
save_connection_configs(&state, &[updated.clone()]).await.unwrap();
|
||||
|
||||
let cached_admin_url = state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get("mq-conn")
|
||||
.and_then(|config| config.external_config.as_ref())
|
||||
.and_then(|external| external.get("adminUrl"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
|
||||
|
||||
let second = state.mq_registry.get_or_build(&updated).await.unwrap();
|
||||
assert!(!std::sync::Arc::ptr_eq(&first, &second));
|
||||
assert!(!state.connections.read().await.contains_key(&initial.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn load_connection_configs_syncs_runtime_cache_and_drops_stale_pool() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-tauri-conn-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
||||
let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
|
||||
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||
state.storage.save_connections(&[updated.clone()]).await.unwrap();
|
||||
state.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
let loaded = load_connection_configs(&state).await.unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 1);
|
||||
let cached_admin_url = state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get("mq-conn")
|
||||
.and_then(|config| config.external_config.as_ref())
|
||||
.and_then(|external| external.get("adminUrl"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
|
||||
assert!(!state.connections.read().await.contains_key(&initial.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connection_configs_removes_deleted_runtime_config_and_mq_adapter() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-tauri-conn-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
||||
let kept = mongodb_config();
|
||||
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
|
||||
{
|
||||
let mut configs = state.configs.write().await;
|
||||
configs.insert(kept.id.clone(), kept.clone());
|
||||
configs.insert(removed.id.clone(), removed.clone());
|
||||
}
|
||||
let stale = state.mq_registry.get_or_build(&removed).await.unwrap();
|
||||
|
||||
save_connection_configs(&state, &[kept.clone()]).await.unwrap();
|
||||
|
||||
let configs = state.configs.read().await;
|
||||
assert!(configs.contains_key(&kept.id));
|
||||
assert!(!configs.contains_key("removed-mq"));
|
||||
drop(configs);
|
||||
|
||||
let rebuilt = state.mq_registry.get_or_build(&removed).await.unwrap();
|
||||
assert!(!std::sync::Arc::ptr_eq(&stale, &rebuilt));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connection_configs_removes_deleted_connection_pools() {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-tauri-conn-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
||||
let kept = mongodb_config();
|
||||
let removed = mq_config("removed-mq", "http://127.0.0.1:8080");
|
||||
{
|
||||
let mut configs = state.configs.write().await;
|
||||
configs.insert(kept.id.clone(), kept.clone());
|
||||
configs.insert(removed.id.clone(), removed.clone());
|
||||
}
|
||||
state.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
save_connection_configs(&state, &[kept.clone()]).await.unwrap();
|
||||
|
||||
assert!(!state.connections.read().await.contains_key(&removed.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_connections(state: State<'_, Arc<AppState>>, configs: Vec<ConnectionConfig>) -> Result<(), String> {
|
||||
let configs: Vec<ConnectionConfig> = configs.into_iter().map(|config| config.canonicalized()).collect();
|
||||
state.storage.save_connections(&configs).await
|
||||
save_connection_configs(state.inner(), &configs).await
|
||||
}
|
||||
|
||||
async fn save_connection_configs(state: &AppState, configs: &[ConnectionConfig]) -> Result<(), String> {
|
||||
state.storage.save_connections(configs).await?;
|
||||
let sync = sync_connection_configs(state, configs).await;
|
||||
remove_connection_pools_for_connection_ids(state, &sync.connection_pool_ids_to_drop).await;
|
||||
drop_mq_adapters_for_connection_ids(state, &sync.mq_adapter_ids_to_drop).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ConnectionConfigSync {
|
||||
mq_adapter_ids_to_drop: Vec<String>,
|
||||
connection_pool_ids_to_drop: Vec<String>,
|
||||
}
|
||||
|
||||
async fn sync_connection_configs(state: &AppState, configs: &[ConnectionConfig]) -> ConnectionConfigSync {
|
||||
let saved_ids: HashSet<&str> = configs.iter().map(|config| config.id.as_str()).collect();
|
||||
let mut mq_adapter_ids_to_drop = HashSet::new();
|
||||
let mut connection_pool_ids_to_drop = HashSet::new();
|
||||
let mut runtime_configs = state.configs.write().await;
|
||||
runtime_configs.retain(|id, existing| {
|
||||
if saved_ids.contains(id.as_str()) || is_transient_runtime_config_id(id) {
|
||||
true
|
||||
} else {
|
||||
connection_pool_ids_to_drop.insert(id.clone());
|
||||
if existing.db_type == DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(id.clone());
|
||||
}
|
||||
false
|
||||
}
|
||||
});
|
||||
for config in configs {
|
||||
if config.db_type == DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
if let Some(previous) = runtime_configs.insert(config.id.clone(), config.clone()) {
|
||||
if previous.db_type == DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
if &previous != config {
|
||||
connection_pool_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionConfigSync {
|
||||
mq_adapter_ids_to_drop: mq_adapter_ids_to_drop.into_iter().collect(),
|
||||
connection_pool_ids_to_drop: connection_pool_ids_to_drop.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_transient_runtime_config_id(id: &str) -> bool {
|
||||
id.starts_with("__test_") || id.starts_with("__visible_draft_")
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
async fn drop_mq_adapters_for_connection_ids(state: &AppState, connection_ids: &[String]) {
|
||||
for connection_id in connection_ids {
|
||||
state.mq_registry.drop_connection(connection_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
async fn drop_mq_adapters_for_connection_ids(_state: &AppState, _connection_ids: &[String]) {}
|
||||
|
||||
async fn remove_connection_pools_for_connection_ids(state: &AppState, connection_ids: &[String]) {
|
||||
for connection_id in connection_ids {
|
||||
state.remove_connection_pools(connection_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_connections(state: State<'_, Arc<AppState>>) -> Result<Vec<ConnectionConfig>, String> {
|
||||
state
|
||||
.storage
|
||||
.load_connections()
|
||||
.await
|
||||
.map(|configs| configs.into_iter().map(|config| config.canonicalized()).collect())
|
||||
load_connection_configs(state.inner()).await
|
||||
}
|
||||
|
||||
async fn load_connection_configs(state: &AppState) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let configs: Vec<ConnectionConfig> =
|
||||
state.storage.load_connections().await?.into_iter().map(|config| config.canonicalized()).collect();
|
||||
let sync = sync_connection_configs(state, &configs).await;
|
||||
remove_connection_pools_for_connection_ids(state, &sync.connection_pool_ids_to_drop).await;
|
||||
drop_mq_adapters_for_connection_ids(state, &sync.mq_adapter_ids_to_drop).await;
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -481,6 +696,18 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
DatabaseType::MessageQueue => {
|
||||
let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
adapter.test_connection().await?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
DatabaseType::MessageQueue => {
|
||||
Err("Message queue admin support is not compiled in this build. Rebuild with the 'mq-admin' feature."
|
||||
.to_string())
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
test_agent_connection(state.inner(), &config, &host, port).await
|
||||
}
|
||||
@@ -707,6 +934,20 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
|
||||
db::influxdb_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::InfluxDb(client)
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
DatabaseType::MessageQueue => {
|
||||
let mqc = state.mq_admin_config_for_connection(&id, &config).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::MessageQueue
|
||||
}
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
DatabaseType::MessageQueue => {
|
||||
return Err(
|
||||
"Message queue admin support is not compiled in this build. Rebuild with the 'mq-admin' feature."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
connect_agent_pool(state.inner(), &db_config, &host, port).await?
|
||||
}
|
||||
@@ -741,6 +982,7 @@ pub async fn connection_final_proxy_port(
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<(), String> {
|
||||
state.remove_connection_pools_detached(&connection_id).await;
|
||||
drop_mq_adapters_for_connection_ids(state.inner(), std::slice::from_ref(&connection_id)).await;
|
||||
state.reset_connection_transport(&connection_id).await;
|
||||
if connection_id.starts_with("__visible_draft_") {
|
||||
state.configs.write().await.remove(&connection_id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[cfg(not(windows))]
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
@@ -276,6 +277,7 @@ fn default_user_shell() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_command_script(command: &str, args: &[&str]) -> String {
|
||||
let mut words = Vec::with_capacity(args.len() + 1);
|
||||
words.push(shell_quote(command));
|
||||
@@ -283,6 +285,7 @@ fn shell_command_script(command: &str, args: &[&str]) -> String {
|
||||
format!("printf '%s\\n' {}; {}", shell_quote(SHELL_COMMAND_MARKER), words.join(" "))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_quote(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
return "''".to_string();
|
||||
@@ -299,10 +302,13 @@ fn stdout_after_shell_marker(stdout: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bash_login_script, shell_command_script, shell_quote, stdout_after_shell_marker, SHELL_COMMAND_MARKER,
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
use super::bash_login_script;
|
||||
#[cfg(not(windows))]
|
||||
use super::{shell_command_script, shell_quote};
|
||||
use super::{stdout_after_shell_marker, SHELL_COMMAND_MARKER};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn shell_quote_handles_empty_and_single_quotes() {
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
@@ -310,6 +316,7 @@ mod tests {
|
||||
assert_eq!(shell_quote("can't"), "'can'\"'\"'t'");
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn shell_command_script_marks_command_output_after_startup_noise() {
|
||||
let script = shell_command_script("npm", &["list", "-g", "@dbx-app/mcp-server", "--json"]);
|
||||
@@ -318,6 +325,7 @@ mod tests {
|
||||
assert!(script.contains("'@dbx-app/mcp-server'"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn bash_login_script_sources_profile_and_rc_files() {
|
||||
let script = bash_login_script("node --version");
|
||||
|
||||
@@ -18,6 +18,8 @@ pub mod keychain;
|
||||
pub mod mcp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mongo_cmd;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq_cmd;
|
||||
pub mod plugins;
|
||||
pub mod query;
|
||||
pub mod query_cancel;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Desktop (Tauri) commands for message queue admin operations.
|
||||
//!
|
||||
//! These are thin wrappers around `dbx_core::mq::service::*_core` functions,
|
||||
//! with read-only protection (`ensure_connection_writable`) for mutating calls.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
|
||||
// ---- Test connection ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_test_connection(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<dbx_core::mq::MqClusterInfo, String> {
|
||||
dbx_core::mq::service::mq_test_connection_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
// ---- Tenants ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_tenants(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<dbx_core::mq::TenantInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_tenants_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_tenant(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
) -> Result<dbx_core::mq::TenantInfo, String> {
|
||||
dbx_core::mq::service::mq_get_tenant_core(&state, &connection_id, &name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_create_tenant(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
config: dbx_core::mq::TenantConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Create tenant").await?;
|
||||
dbx_core::mq::service::mq_create_tenant_core(&state, &connection_id, &name, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_update_tenant(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
config: dbx_core::mq::TenantConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Update tenant").await?;
|
||||
dbx_core::mq::service::mq_update_tenant_core(&state, &connection_id, &name, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_delete_tenant(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete tenant").await?;
|
||||
dbx_core::mq::service::mq_delete_tenant_core(&state, &connection_id, &name, force).await
|
||||
}
|
||||
|
||||
// ---- Namespaces ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_namespaces(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
tenant: String,
|
||||
) -> Result<Vec<dbx_core::mq::NamespaceInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_namespaces_core(&state, &connection_id, &tenant).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_create_namespace(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
config: dbx_core::mq::NamespaceConfig,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Create namespace").await?;
|
||||
dbx_core::mq::service::mq_create_namespace_core(&state, &connection_id, ns, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_delete_namespace(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete namespace").await?;
|
||||
dbx_core::mq::service::mq_delete_namespace_core(&state, &connection_id, ns, force).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_namespace_policies(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
dbx_core::mq::service::mq_get_namespace_policies_core(&state, &connection_id, ns).await
|
||||
}
|
||||
|
||||
// ---- Topics ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_topics(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
ns: dbx_core::mq::NamespaceRef,
|
||||
opts: dbx_core::mq::ListTopicsOpts,
|
||||
) -> Result<Vec<dbx_core::mq::TopicInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_topics_core(&state, &connection_id, ns, opts).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_create_topic(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
partitions: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Create topic").await?;
|
||||
dbx_core::mq::service::mq_create_topic_core(&state, &connection_id, topic, partitions).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_delete_topic(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete topic").await?;
|
||||
dbx_core::mq::service::mq_delete_topic_core(&state, &connection_id, topic, force).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_update_partitions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
partitions: u32,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Update partitions").await?;
|
||||
dbx_core::mq::service::mq_update_partitions_core(&state, &connection_id, topic, partitions).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_topic_stats(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<dbx_core::mq::TopicStats, String> {
|
||||
dbx_core::mq::service::mq_get_topic_stats_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_topic_internal_stats(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
dbx_core::mq::service::mq_get_topic_internal_stats_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
// ---- Subscriptions ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_subscriptions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<Vec<dbx_core::mq::SubscriptionInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_subscriptions_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_create_subscription(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
pos: dbx_core::mq::ResetPosition,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Create subscription").await?;
|
||||
dbx_core::mq::service::mq_create_subscription_core(&state, &connection_id, topic, sub, pos).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_delete_subscription(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete subscription").await?;
|
||||
dbx_core::mq::service::mq_delete_subscription_core(&state, &connection_id, topic, sub, force).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_skip_messages(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
count: dbx_core::mq::SkipCount,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Skip messages").await?;
|
||||
dbx_core::mq::service::mq_skip_messages_core(&state, &connection_id, topic, sub, count).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_reset_cursor(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
pos: dbx_core::mq::ResetPosition,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Reset cursor").await?;
|
||||
dbx_core::mq::service::mq_reset_cursor_core(&state, &connection_id, topic, sub, pos).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_clear_backlog(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Clear backlog").await?;
|
||||
dbx_core::mq::service::mq_clear_backlog_core(&state, &connection_id, topic, sub).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_peek_messages(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
count: u32,
|
||||
) -> Result<Vec<dbx_core::mq::PeekedMessage>, String> {
|
||||
dbx_core::mq::service::mq_peek_messages_core(&state, &connection_id, topic, sub, count).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_expire_messages(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
expire_seconds: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Expire messages").await?;
|
||||
dbx_core::mq::service::mq_expire_messages_core(&state, &connection_id, topic, sub, expire_seconds).await
|
||||
}
|
||||
|
||||
// ---- Producers / consumers ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_producers(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<Vec<dbx_core::mq::ProducerInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_producers_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_consumers(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: String,
|
||||
) -> Result<Vec<dbx_core::mq::ConsumerInfo>, String> {
|
||||
dbx_core::mq::service::mq_list_consumers_core(&state, &connection_id, topic, sub).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_unload_topic(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Unload topic").await?;
|
||||
dbx_core::mq::service::mq_unload_topic_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
// ---- Rate limits / quotas / retention ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_set_publish_rate(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::PublishRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Set publish rate").await?;
|
||||
dbx_core::mq::service::mq_set_publish_rate_core(&state, &connection_id, scope, rate).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_set_dispatch_rate(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::DispatchRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Set dispatch rate").await?;
|
||||
dbx_core::mq::service::mq_set_dispatch_rate_core(&state, &connection_id, scope, rate).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_set_subscribe_rate(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
rate: dbx_core::mq::SubscribeRate,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Set subscribe rate").await?;
|
||||
dbx_core::mq::service::mq_set_subscribe_rate_core(&state, &connection_id, scope, rate).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_set_backlog_quota(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
quota: dbx_core::mq::BacklogQuota,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Set backlog quota").await?;
|
||||
dbx_core::mq::service::mq_set_backlog_quota_core(&state, &connection_id, scope, quota).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_set_retention(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
retention: dbx_core::mq::RetentionPolicy,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Set retention").await?;
|
||||
dbx_core::mq::service::mq_set_retention_core(&state, &connection_id, scope, retention).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_effective_policies(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
dbx_core::mq::service::mq_get_effective_policies_core(&state, &connection_id, scope).await
|
||||
}
|
||||
|
||||
// ---- Permissions ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_grant_permission(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
role: String,
|
||||
actions: Vec<dbx_core::mq::AuthAction>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Grant permission").await?;
|
||||
dbx_core::mq::service::mq_grant_permission_core(&state, &connection_id, scope, role, actions).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_revoke_permission(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
role: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Revoke permission").await?;
|
||||
dbx_core::mq::service::mq_revoke_permission_core(&state, &connection_id, scope, role).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_permissions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
scope: dbx_core::mq::PolicyScope,
|
||||
) -> Result<dbx_core::mq::PermissionMap, String> {
|
||||
dbx_core::mq::service::mq_list_permissions_core(&state, &connection_id, scope).await
|
||||
}
|
||||
|
||||
// ---- Client tokens ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_issue_token(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::MqTokenIssueRequest,
|
||||
) -> Result<dbx_core::mq::MqIssuedToken, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Issue MQ token").await?;
|
||||
dbx_core::mq::service::mq_issue_token_core(&state, &connection_id, req).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_list_token_records(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
subject: Option<String>,
|
||||
) -> Result<Vec<dbx_core::mq::MqTokenRecord>, String> {
|
||||
dbx_core::mq::service::mq_list_token_records_core(&state, &connection_id, subject).await
|
||||
}
|
||||
|
||||
// ---- Monitoring ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_backlog(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
sub: Option<String>,
|
||||
) -> Result<dbx_core::mq::BacklogStats, String> {
|
||||
dbx_core::mq::service::mq_get_backlog_core(&state, &connection_id, topic, sub).await
|
||||
}
|
||||
|
||||
// ---- Raw request (escape hatch) ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_raw_request(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::MqRawRequest,
|
||||
) -> Result<dbx_core::mq::MqRawResponse, String> {
|
||||
if req.is_mutating() {
|
||||
ensure_connection_writable(&state, &connection_id, "MQ admin write").await?;
|
||||
}
|
||||
dbx_core::mq::service::mq_raw_request_core(&state, &connection_id, req).await
|
||||
}
|
||||
@@ -512,6 +512,86 @@ pub fn run() {
|
||||
commands::mongo_cmd::mongo_update_documents,
|
||||
commands::mongo_cmd::mongo_delete_document,
|
||||
commands::mongo_cmd::mongo_delete_documents,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_test_connection,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_tenants,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_tenant,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_create_tenant,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_update_tenant,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_delete_tenant,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_namespaces,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_create_namespace,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_delete_namespace,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_namespace_policies,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_topics,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_create_topic,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_delete_topic,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_update_partitions,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_topic_stats,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_topic_internal_stats,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_subscriptions,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_create_subscription,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_delete_subscription,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_skip_messages,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_reset_cursor,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_clear_backlog,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_peek_messages,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_expire_messages,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_producers,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_consumers,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_unload_topic,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_set_publish_rate,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_set_dispatch_rate,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_set_subscribe_rate,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_set_backlog_quota,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_set_retention,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_effective_policies,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_grant_permission,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_revoke_permission,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_permissions,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_issue_token,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_token_records,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_backlog,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_raw_request,
|
||||
commands::history::save_history,
|
||||
commands::history::load_history,
|
||||
commands::history::clear_history,
|
||||
|
||||
Reference in New Issue
Block a user