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:
miracleandt8y2 authored and GitHub committed 2026-06-16 21:57:17 +08:00
1 parent 8339d23df5
commit 2a59d3143e
83 files changed
+15979 -37

No files matched your search

@@ -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>
+30
View File
@@ -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;