feat(mq): improve Kafka peek and admin localization
This commit is contained in:
1 parent
a9e76dc130
commit
68b1777aa4
29 files changed
+4561
-636
No files matched your search
@@ -503,17 +503,17 @@ const mqTlsSkipVerify = ref(false);
|
||||
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
|
||||
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
|
||||
const mqTokenSigningKey = ref("");
|
||||
const mqSystemOptions: Array<{ value: MqSystemKind; label: string }> = [
|
||||
{ value: "pulsar", label: "Apache Pulsar" },
|
||||
{ value: "kafka", label: "Apache Kafka" },
|
||||
];
|
||||
const mqKafkaSecurityProtocolOptions = [
|
||||
{ value: MQ_KAFKA_SECURITY_PROTOCOL_AUTO, label: "Auto" },
|
||||
const mqSystemOptions = computed(() => [
|
||||
{ value: "pulsar" as const, label: t("connection.mqSystemPulsar") },
|
||||
{ value: "kafka" as const, label: t("connection.mqSystemKafka") },
|
||||
]);
|
||||
const mqKafkaSecurityProtocolOptions = computed(() => [
|
||||
{ value: MQ_KAFKA_SECURITY_PROTOCOL_AUTO, label: t("connection.mqSecurityAuto") },
|
||||
{ value: "PLAINTEXT", label: "PLAINTEXT" },
|
||||
{ value: "SSL", label: "SSL" },
|
||||
{ value: "SASL_PLAINTEXT", label: "SASL_PLAINTEXT" },
|
||||
{ value: "SASL_SSL", label: "SASL_SSL" },
|
||||
];
|
||||
]);
|
||||
const mqKafkaSaslMechanismOptions = [
|
||||
{ value: "PLAIN", label: "PLAIN" },
|
||||
{ value: "SCRAM-SHA-256", label: "SCRAM-SHA-256" },
|
||||
@@ -970,9 +970,9 @@ function buildMqAuth(): MqAuth {
|
||||
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"),
|
||||
issuerUrl: requireMqField(mqOauthIssuerUrl.value, t("connection.mqOauthIssuerRequired")),
|
||||
clientId: requireMqField(mqOauthClientId.value, t("connection.mqOauthClientIdRequired")),
|
||||
clientSecret: requireMqField(mqOauthClientSecret.value, t("connection.mqOauthClientSecretRequired")),
|
||||
audience: mqOauthAudience.value.trim() || undefined,
|
||||
scope: mqOauthScope.value.trim() || undefined,
|
||||
};
|
||||
@@ -991,7 +991,7 @@ function buildMqTokenSigning() {
|
||||
if (mqTokenSigningMode.value === "none") return undefined;
|
||||
return {
|
||||
algorithm: mqTokenSigningMode.value,
|
||||
key: requireMqField(mqTokenSigningKey.value, "Broker token signing key is required"),
|
||||
key: requireMqField(mqTokenSigningKey.value, t("connection.mqTokenSigningKeyRequired")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1025,7 +1025,7 @@ function buildMqAdminConfig(): MqAdminConfig {
|
||||
|
||||
return {
|
||||
systemKind: mqSystemKind.value,
|
||||
adminUrl: requireMqField(mqAdminUrl.value, "MQ Admin URL is required"),
|
||||
adminUrl: requireMqField(mqAdminUrl.value, t("connection.mqAdminUrlRequired")),
|
||||
auth: buildMqAuth(),
|
||||
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
|
||||
pinnedVersion: selectionToPinnedVersion(mqPinnedVersion.value),
|
||||
@@ -1324,7 +1324,7 @@ function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
|
||||
try {
|
||||
parsed = new URL(adminUrl);
|
||||
} catch {
|
||||
throw new Error("MQ Admin URL is invalid");
|
||||
throw new Error(t("connection.mqAdminUrlInvalid"));
|
||||
}
|
||||
const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8080);
|
||||
config.host = parsed.hostname;
|
||||
@@ -1334,12 +1334,12 @@ function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
|
||||
|
||||
function applyMqKafkaBootstrapServers(config: LegacyConnectionConfig, bootstrapServers: string, securityProtocol?: string) {
|
||||
const first = normalizeKafkaBootstrapServers(bootstrapServers).split(",")[0];
|
||||
if (!first) throw new Error("Kafka bootstrap servers are required");
|
||||
if (!first) throw new Error(t("connection.mqBootstrapServersRequired"));
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(`kafka://${first}`);
|
||||
} catch {
|
||||
throw new Error("Kafka bootstrap servers are invalid");
|
||||
throw new Error(t("connection.mqBootstrapServersInvalid"));
|
||||
}
|
||||
config.host = parsed.hostname;
|
||||
config.port = Number(parsed.port) || 9092;
|
||||
@@ -4420,7 +4420,7 @@ function openExternalUrl(url: string) {
|
||||
<!-- 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="connectionLabelClass">System</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqSystem") }}</Label>
|
||||
<Select v-model="mqSystemKind">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
@@ -4434,11 +4434,11 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
<template v-if="mqSystemKind === 'kafka'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Bootstrap Servers</Label>
|
||||
<Input v-model="mqKafkaBootstrapServers" class="col-span-3" placeholder="127.0.0.1:9092" />
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqBootstrapServers") }}</Label>
|
||||
<Input v-model="mqKafkaBootstrapServers" class="col-span-3" :placeholder="t('connection.mqBootstrapServersPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Security</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqSecurity") }}</Label>
|
||||
<Select v-model="mqKafkaSecurityProtocol">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
@@ -4453,24 +4453,24 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Admin URL</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqAdminUrl") }}</Label>
|
||||
<Input v-model="mqAdminUrl" class="col-span-3" placeholder="http://127.0.0.1:8080" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Auth</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqAuth") }}</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 v-if="mqSystemKind !== 'kafka'" 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 v-if="mqSystemKind === 'kafka'" size="sm" :variant="mqAuthKind === 'kerberos' ? 'default' : 'outline'" @click="mqAuthKind = 'kerberos'">Kerberos</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">API Key</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">OAuth2</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">{{ t("connection.mqAuthToken") }}</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">{{ t("connection.mqAuthBasic") }}</Button>
|
||||
<Button v-if="mqSystemKind === 'kafka'" size="sm" :variant="mqAuthKind === 'kerberos' ? 'default' : 'outline'" @click="mqAuthKind = 'kerberos'">{{ t("connection.mqAuthKerberos") }}</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">{{ t("connection.mqAuthApiKey") }}</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">{{ t("connection.mqAuthOauth2") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="mqAuthKind === 'token'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Token</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqToken") }}</Label>
|
||||
<PasswordInput v-model="mqToken" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -4484,7 +4484,7 @@ function openExternalUrl(url: string) {
|
||||
<PasswordInput v-model="mqBasicPassword" class="col-span-3" />
|
||||
</div>
|
||||
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">SASL Mechanism</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqSaslMechanism") }}</Label>
|
||||
<Select v-model="mqKafkaSaslMechanism">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
@@ -4544,45 +4544,45 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'apiKey'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Header</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqApiKeyHeader") }}</Label>
|
||||
<Input v-model="mqApiKeyHeader" class="col-span-3" placeholder="Authorization" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Value</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqApiKeyValue") }}</Label>
|
||||
<PasswordInput v-model="mqApiKeyValue" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'oauth2'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Issuer URL</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqOauthIssuerUrl") }}</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="connectionLabelClass">Client ID</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqOauthClientId") }}</Label>
|
||||
<Input v-model="mqOauthClientId" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Client Secret</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqOauthClientSecret") }}</Label>
|
||||
<PasswordInput v-model="mqOauthClientSecret" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Audience</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqOauthAudience") }}</Label>
|
||||
<Input v-model="mqOauthAudience" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Scope</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqOauthScope") }}</Label>
|
||||
<Input v-model="mqOauthScope" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">TLS</Label>
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.mqTls") }}</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>
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.mqTlsSkipVerify") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Pinned Version</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqPinnedVersion") }}</Label>
|
||||
<Select v-model="mqPinnedVersion">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
@@ -4598,29 +4598,29 @@ function openExternalUrl(url: string) {
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Broker Token 签发</Label>
|
||||
<Label :class="connectionLabelClass">{{ t("connection.mqTokenSigning") }}</Label>
|
||||
<Select v-model="mqTokenSigningMode">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不配置</SelectItem>
|
||||
<SelectItem value="none">{{ t("connection.mqTokenSigningNone") }}</SelectItem>
|
||||
<SelectItem value="hs256">HS256 SECRET</SelectItem>
|
||||
<SelectItem value="rs256">RS256 PRIVATE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="mqSystemKind !== 'kafka' && mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right">签发密钥</Label>
|
||||
<Label class="pt-2 text-right">{{ t("connection.mqTokenSigningKey") }}</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-----'"
|
||||
:placeholder="mqTokenSigningMode === 'hs256' ? t('connection.mqTokenSigningKeyPlaceholderHs256') : t('connection.mqTokenSigningKeyPlaceholderRs256')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mqSystemKind !== 'kafka' && 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>
|
||||
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.mqTokenSigningHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ClusterInfo } from "@/types/mq";
|
||||
import { mqGetClusterInfo } from "@/lib/backend/api";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const clusterInfo = ref<ClusterInfo>();
|
||||
const loading = ref(false);
|
||||
@@ -90,72 +92,72 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="broker-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>Broker 集群</h3>
|
||||
<h3>{{ t("mqBroker.title") }}</h3>
|
||||
<div class="toolbar-actions">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="autoRefresh" />
|
||||
自动刷新
|
||||
{{ t("mqBroker.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>
|
||||
<option :value="5">{{ t("mqBroker.refreshInterval5s") }}</option>
|
||||
<option :value="10">{{ t("mqBroker.refreshInterval10s") }}</option>
|
||||
<option :value="30">{{ t("mqBroker.refreshInterval30s") }}</option>
|
||||
<option :value="60">{{ t("mqBroker.refreshInterval60s") }}</option>
|
||||
</select>
|
||||
<button @click="refreshNow" :disabled="loading" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "立即刷新" }}
|
||||
{{ loading ? t("mqBroker.refreshing") : t("mqBroker.refreshNow") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !clusterInfo" class="panel-loading">加载中...</div>
|
||||
<div v-else-if="loading && !clusterInfo" class="panel-loading">{{ t("mqBroker.loading") }}</div>
|
||||
|
||||
<div v-else-if="clusterInfo" class="broker-content">
|
||||
<!-- 集群概览 -->
|
||||
<div class="stats-section">
|
||||
<h4>集群概览</h4>
|
||||
<h4>{{ t("mqBroker.clusterOverview") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔗</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">集群 ID</div>
|
||||
<div class="stat-value stat-value-sm">{{ clusterInfo.clusterId || "未知" }}</div>
|
||||
<div class="stat-label">{{ t("mqBroker.clusterId") }}</div>
|
||||
<div class="stat-value stat-value-sm">{{ clusterInfo.clusterId || t("mqBroker.unknown") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🖥️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Broker 数量</div>
|
||||
<div class="stat-label">{{ t("mqBroker.brokerCount") }}</div>
|
||||
<div class="stat-value">{{ clusterInfo.brokerCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👑</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Controller</div>
|
||||
<div class="stat-label">{{ t("mqBroker.controller") }}</div>
|
||||
<div class="stat-value stat-value-sm">
|
||||
<template v-if="clusterInfo.controllerHost"> Node {{ clusterInfo.controllerId ?? "?" }} · {{ clusterInfo.controllerHost }} </template>
|
||||
<template v-else>未知</template>
|
||||
<template v-if="clusterInfo.controllerHost">
|
||||
{{ t("mqBroker.controllerNode", { id: clusterInfo.controllerId ?? "?", host: clusterInfo.controllerHost }) }}
|
||||
</template>
|
||||
<template v-else>{{ t("mqBroker.unknown") }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Broker 节点列表 -->
|
||||
<div class="stats-section">
|
||||
<h4>Broker 节点</h4>
|
||||
<h4>{{ t("mqBroker.brokerNodes") }}</h4>
|
||||
<div v-if="clusterInfo.brokers.length" class="broker-table-wrap">
|
||||
<table class="broker-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node ID</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>Rack</th>
|
||||
<th>角色</th>
|
||||
<th>{{ t("mqBroker.nodeId") }}</th>
|
||||
<th>{{ t("mqBroker.host") }}</th>
|
||||
<th>{{ t("mqBroker.port") }}</th>
|
||||
<th>{{ t("mqBroker.rack") }}</th>
|
||||
<th>{{ t("mqBroker.role") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -165,14 +167,14 @@ onUnmounted(() => {
|
||||
<td>{{ broker.port }}</td>
|
||||
<td>{{ broker.rack || "-" }}</td>
|
||||
<td>
|
||||
<span v-if="broker.id === clusterInfo.controllerId" class="role-badge controller">Controller</span>
|
||||
<span v-else class="role-badge follower">Follower</span>
|
||||
<span v-if="broker.id === clusterInfo.controllerId" class="role-badge controller">{{ t("mqBroker.roleController") }}</span>
|
||||
<span v-else class="role-badge follower">{{ t("mqBroker.roleFollower") }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 Broker 节点信息</div>
|
||||
<div v-else class="empty-state">{{ t("mqBroker.noBrokerNodes") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart } from "echarts/charts";
|
||||
@@ -54,6 +55,7 @@ interface KafkaPartitionStatsRow {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const stats = ref<TopicStats>();
|
||||
const backlog = ref<BacklogStats>();
|
||||
@@ -113,38 +115,38 @@ const selectedPartitionSubscriptions = computed(() => {
|
||||
|
||||
const rateChartOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { top: 0, data: ["In", "Out"] },
|
||||
legend: { top: 0, data: [t("mqMonitoring.chartLegendIn"), t("mqMonitoring.chartLegendOut")] },
|
||||
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" },
|
||||
yAxis: { type: "value", name: t("mqMonitoring.chartAxisMsgPerSec") },
|
||||
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) },
|
||||
{ name: t("mqMonitoring.chartLegendIn"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateIn) },
|
||||
{ name: t("mqMonitoring.chartLegendOut"), 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"] },
|
||||
legend: { top: 0, data: [t("mqMonitoring.chartLegendMessages"), t("mqMonitoring.chartLegendBytes")] },
|
||||
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" },
|
||||
{ type: "value", name: t("mqMonitoring.chartAxisMsg") },
|
||||
{ type: "value", name: t("mqMonitoring.chartAxisBytes") },
|
||||
],
|
||||
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) },
|
||||
{ name: t("mqMonitoring.chartLegendMessages"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgBacklog) },
|
||||
{ name: t("mqMonitoring.chartLegendBytes"), 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"] },
|
||||
legend: { top: 0, data: [t("mqMonitoring.chartLegendConsumerLag")] },
|
||||
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) }],
|
||||
yAxis: { type: "value", name: t("mqMonitoring.chartAxisMs") },
|
||||
series: [{ name: t("mqMonitoring.chartLegendConsumerLag"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.consumerLagMs) }],
|
||||
}));
|
||||
|
||||
function getTopicRef(): TopicRef | null {
|
||||
@@ -200,17 +202,17 @@ function refreshNow() {
|
||||
|
||||
function defaultKafkaMessageSql(): string {
|
||||
const topic = props.topic?.shortName;
|
||||
return topic ? `SELECT * FROM "${topic}" PARTITION 0 OFFSET 0 LIMIT 20` : "";
|
||||
return topic ? `SELECT * FROM "${topic}" LIMIT 20` : "";
|
||||
}
|
||||
|
||||
function parseKafkaMessageSql(sql: string): { topic: string; partition: number; offset: number; limit: number } {
|
||||
function parseKafkaMessageSql(sql: string): { topic: string; partition?: number; offset?: number; limit: number } {
|
||||
const match = sql.trim().match(/^\s*select\s+\*\s+from\s+(?:"([^"]+)"|`([^`]+)`|'([^']+)'|([^\s;]+))(?:\s+partition\s+(\d+))?(?:\s+offset\s+(\d+))?(?:\s+limit\s+(\d+))?\s*;?\s*$/i);
|
||||
if (!match) {
|
||||
throw new Error('仅支持 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]');
|
||||
throw new Error(t("mqMonitoring.sqlSyntaxError"));
|
||||
}
|
||||
const topic = match[1] || match[2] || match[3] || match[4] || "";
|
||||
const partition = Math.max(0, Number(match[5] ?? 0));
|
||||
const offset = Math.max(0, Number(match[6] ?? 0));
|
||||
const partition = match[5] != null ? Math.max(0, Number(match[5])) : undefined;
|
||||
const offset = match[6] != null ? Math.max(0, Number(match[6])) : undefined;
|
||||
const limit = Math.max(1, Math.min(100, Number(match[7] ?? 20)));
|
||||
return { topic, partition, offset, limit };
|
||||
}
|
||||
@@ -222,6 +224,9 @@ async function runKafkaMessageSql() {
|
||||
try {
|
||||
const parsed = parseKafkaMessageSql(kafkaMessageSql.value);
|
||||
const selected = props.topic && parsed.topic === props.topic.shortName ? props.topic : undefined;
|
||||
const options: { partition?: number; offset?: number } = {};
|
||||
if (parsed.partition != null) options.partition = parsed.partition;
|
||||
if (parsed.offset != null) options.offset = parsed.offset;
|
||||
kafkaMessages.value = await mqPeekMessages(
|
||||
props.connectionId,
|
||||
{
|
||||
@@ -233,7 +238,7 @@ async function runKafkaMessageSql() {
|
||||
},
|
||||
"__dbx_kafka_monitor__",
|
||||
parsed.limit,
|
||||
{ partition: parsed.partition, offset: parsed.offset },
|
||||
options,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
kafkaMessageError.value = formatError(e);
|
||||
@@ -328,9 +333,9 @@ function isKafkaPartitionHealthy(row: KafkaPartitionStatsRow): boolean {
|
||||
}
|
||||
|
||||
function kafkaPartitionStatusLabel(row: KafkaPartitionStatsRow): string {
|
||||
if (row.leader < 0) return "无 leader";
|
||||
if (row.replicas.length > 0 && row.isr.length < row.replicas.length) return "ISR 不完整";
|
||||
return "正常";
|
||||
if (row.leader < 0) return t("mqMonitoring.statusNoLeader");
|
||||
if (row.replicas.length > 0 && row.isr.length < row.replicas.length) return t("mqMonitoring.statusIsrIncomplete");
|
||||
return t("mqMonitoring.statusHealthy");
|
||||
}
|
||||
|
||||
function partitionBacklogMessages(body: Record<string, unknown>): number {
|
||||
@@ -451,29 +456,29 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="monitoring-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>监控统计</h3>
|
||||
<h3>{{ t("mqMonitoring.title") }}</h3>
|
||||
<div class="toolbar-actions">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="autoRefresh" />
|
||||
<span>自动刷新</span>
|
||||
<span>{{ t("mqMonitoring.autoRefresh") }}</span>
|
||||
</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>
|
||||
<option :value="5">{{ t("mqMonitoring.refreshInterval5s") }}</option>
|
||||
<option :value="10">{{ t("mqMonitoring.refreshInterval10s") }}</option>
|
||||
<option :value="30">{{ t("mqMonitoring.refreshInterval30s") }}</option>
|
||||
<option :value="60">{{ t("mqMonitoring.refreshInterval60s") }}</option>
|
||||
</select>
|
||||
<button @click="refreshNow" :disabled="loading" class="btn-sm">
|
||||
<Loader2 v-if="loading" class="btn-icon spinning" :size="14" />
|
||||
<RefreshCw v-else class="btn-icon" :size="14" />
|
||||
<span>{{ loading ? "刷新中..." : "立即刷新" }}</span>
|
||||
<span>{{ loading ? t("mqMonitoring.refreshing") : t("mqMonitoring.refreshNow") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">
|
||||
<Table2 :size="24" />
|
||||
<span>请先选择一个主题</span>
|
||||
<span>{{ t("mqMonitoring.selectTopicFirst") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">
|
||||
@@ -483,7 +488,7 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else-if="loading && !stats" class="panel-loading">
|
||||
<Loader2 class="loading-icon spinning" :size="22" />
|
||||
<span>加载监控数据...</span>
|
||||
<span>{{ t("mqMonitoring.loadingStats") }}</span>
|
||||
<div class="loading-skeleton-grid" aria-hidden="true">
|
||||
<div v-for="item in 4" :key="item" class="loading-skeleton-card"></div>
|
||||
</div>
|
||||
@@ -491,33 +496,33 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else-if="stats && isKafkaStats" class="stats-container">
|
||||
<div class="stats-section">
|
||||
<h4>Kafka Topic 概览</h4>
|
||||
<h4>{{ t("mqMonitoring.kafkaTopicOverview") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Layers3 :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">分区数</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.partitionCount") }}</div>
|
||||
<div class="stat-value">{{ kafkaOverview.partitionCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Boxes :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">副本因子</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.replicationFactor") }}</div>
|
||||
<div class="stat-value">{{ kafkaOverview.replicationFactor }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Hash :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">消息数</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.messageCount") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(kafkaOverview.totalMessages) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><BarChart3 :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Log end offset</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.logEndOffset") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(kafkaOverview.totalEndOffset) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,33 +530,33 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h4>Offset 与副本状态</h4>
|
||||
<h4>{{ t("mqMonitoring.offsetAndReplicaStatus") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Gauge :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">起始 offset</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.beginOffset") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(kafkaOverview.totalBeginOffset) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><RadioTower :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Leader 数</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.leaderCount") }}</div>
|
||||
<div class="stat-value">{{ kafkaOverview.leaderCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ warning: kafkaOverview.underReplicatedPartitions > 0 }">
|
||||
<div class="stat-icon"><ShieldCheck :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">ISR 健康分区</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.isrHealthyPartitions") }}</div>
|
||||
<div class="stat-value">{{ kafkaOverview.healthyPartitions }} / {{ kafkaOverview.partitionCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ warning: kafkaOverview.offlinePartitions > 0 }">
|
||||
<div class="stat-icon"><AlertTriangle :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">无 leader 分区</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.noLeaderPartitions") }}</div>
|
||||
<div class="stat-value">{{ kafkaOverview.offlinePartitions }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,20 +564,20 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h4>Kafka 分区明细</h4>
|
||||
<h4>{{ t("mqMonitoring.kafkaPartitionDetails") }}</h4>
|
||||
<div v-if="kafkaPartitionRows.length" class="partition-layout">
|
||||
<div class="partition-table-wrap">
|
||||
<table class="partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>起始 offset</th>
|
||||
<th>Log end offset</th>
|
||||
<th>消息数</th>
|
||||
<th>Leader</th>
|
||||
<th>Replicas</th>
|
||||
<th>ISR</th>
|
||||
<th>状态</th>
|
||||
<th>{{ t("mqMonitoring.tablePartition") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableLeader") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableReplicas") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableIsr") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableStatus") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -594,31 +599,32 @@ onUnmounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">当前 Kafka 响应未返回分区指标</div>
|
||||
<div v-else class="empty-state compact">{{ t("mqMonitoring.noKafkaPartitionMetrics") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="section-title-row">
|
||||
<h4>Kafka 消息查询</h4>
|
||||
<h4>{{ t("mqMonitoring.kafkaMessageQuery") }}</h4>
|
||||
<button type="button" class="btn-sm" :disabled="kafkaMessageLoading || !kafkaMessageSql.trim()" @click="runKafkaMessageSql">
|
||||
<Loader2 v-if="kafkaMessageLoading" class="btn-icon spinning" :size="14" />
|
||||
<span>{{ kafkaMessageLoading ? "查询中..." : "查询消息" }}</span>
|
||||
<span>{{ kafkaMessageLoading ? t("mqMonitoring.querying") : t("mqMonitoring.queryMessages") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea v-model="kafkaMessageSql" class="kafka-sql-input" rows="2" spellcheck="false" />
|
||||
<div class="query-hint">支持:SELECT * FROM "topic" PARTITION 0 OFFSET 0 LIMIT 20,单次最多返回 100 条。</div>
|
||||
<div class="query-hint">{{ t("mqMonitoring.queryHint") }}</div>
|
||||
<div v-if="kafkaMessageError" class="panel-error inline-error">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>{{ kafkaMessageError }}</span>
|
||||
</div>
|
||||
<div v-else-if="kafkaMessageLoading" class="empty-state compact">消息加载中...</div>
|
||||
<div v-else-if="!kafkaMessages.length" class="empty-state compact">暂无消息</div>
|
||||
<div v-else-if="kafkaMessageLoading" class="empty-state compact">{{ t("mqMonitoring.messagesLoading") }}</div>
|
||||
<div v-else-if="!kafkaMessages.length" class="empty-state compact">{{ t("mqMonitoring.noMessages") }}</div>
|
||||
<div v-else class="kafka-message-list">
|
||||
<article v-for="message in kafkaMessages" :key="message.messageId || message.position" class="kafka-message-row">
|
||||
<article v-for="message in kafkaMessages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="kafka-message-row">
|
||||
<div class="kafka-message-meta">
|
||||
<span>#{{ message.position }}</span>
|
||||
<span>offset {{ message.messageId || "-" }}</span>
|
||||
<span v-if="message.key">key {{ message.key }}</span>
|
||||
<span v-if="message.properties?.partition != null">{{ t("mqMonitoring.metaPartition", { partition: message.properties.partition }) }}</span>
|
||||
<span>{{ t("mqMonitoring.metaOffset", { offset: message.messageId || "-" }) }}</span>
|
||||
<span v-if="message.key">{{ t("mqMonitoring.metaKey", { key: message.key }) }}</span>
|
||||
<span>{{ formatKafkaMessageTimestamp(message.publishTime) }}</span>
|
||||
</div>
|
||||
<pre class="kafka-message-payload">{{ kafkaMessagePayload(message) }}</pre>
|
||||
@@ -633,33 +639,33 @@ onUnmounted(() => {
|
||||
<div v-else-if="stats" class="stats-container">
|
||||
<!-- Overview Section -->
|
||||
<div class="stats-section">
|
||||
<h4>消息速率</h4>
|
||||
<h4>{{ t("mqMonitoring.messageRate") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Download :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">入站速率</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.inboundRate") }}</div>
|
||||
<div class="stat-value">{{ stats.msgRateIn.toFixed(2) }} msg/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Upload :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">出站速率</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.outboundRate") }}</div>
|
||||
<div class="stat-value">{{ stats.msgRateOut.toFixed(2) }} msg/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Activity :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">入站吞吐量</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.inboundThroughput") }}</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.msgThroughputIn) }}/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><BarChart3 :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">出站吞吐量</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.outboundThroughput") }}</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.msgThroughputOut) }}/s</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,41 +674,41 @@ onUnmounted(() => {
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-panel">
|
||||
<h4>速率趋势</h4>
|
||||
<h4>{{ t("mqMonitoring.rateTrend") }}</h4>
|
||||
<VChart :option="rateChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<h4>积压趋势</h4>
|
||||
<h4>{{ t("mqMonitoring.backlogTrend") }}</h4>
|
||||
<VChart :option="backlogChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<h4>消费延迟</h4>
|
||||
<h4>{{ t("mqMonitoring.consumerLag") }}</h4>
|
||||
<VChart :option="latencyChartOption" autoresize class="trend-chart" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Section -->
|
||||
<div class="stats-section">
|
||||
<h4>存储与积压</h4>
|
||||
<h4>{{ t("mqMonitoring.storageAndBacklog") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Database :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">存储大小</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.storageSize") }}</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"><Package :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">积压大小</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.backlogSize") }}</div>
|
||||
<div class="stat-value">{{ formatBytes(stats.backlogSize) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="backlog">
|
||||
<div class="stat-icon"><HardDrive :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">积压消息数</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.backlogMessageCount") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(backlog.msgBacklog) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -711,19 +717,19 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Counters Section -->
|
||||
<div class="stats-section">
|
||||
<h4>消息计数器</h4>
|
||||
<h4>{{ t("mqMonitoring.messageCounters") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Send :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">已发布消息</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.publishedMessages") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(stats.msgInCounter) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><CheckCircle2 :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">已消费消息</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.consumedMessages") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(stats.msgOutCounter) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -732,19 +738,19 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Connections Section -->
|
||||
<div class="stats-section">
|
||||
<h4>连接统计</h4>
|
||||
<h4>{{ t("mqMonitoring.connectionStats") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><Users :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">订阅数量</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.subscriptionCount") }}</div>
|
||||
<div class="stat-value">{{ stats.subscriptionCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><RadioTower :size="21" /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">生产者数量</div>
|
||||
<div class="stat-label">{{ t("mqMonitoring.producerCount") }}</div>
|
||||
<div class="stat-value">{{ stats.producerCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -752,21 +758,21 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h4>分区明细</h4>
|
||||
<h4>{{ t("mqMonitoring.partitionDetails") }}</h4>
|
||||
<div v-if="partitionRows.length" class="partition-layout">
|
||||
<div class="partition-table-wrap">
|
||||
<table class="partition-table interactive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>入站</th>
|
||||
<th>出站</th>
|
||||
<th>入站吞吐</th>
|
||||
<th>出站吞吐</th>
|
||||
<th>积压消息</th>
|
||||
<th>积压大小</th>
|
||||
<th>生产者</th>
|
||||
<th>订阅</th>
|
||||
<th>{{ t("mqMonitoring.tablePartition") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableInbound") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableOutbound") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableInboundThroughput") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableOutboundThroughput") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableBacklogMessages") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableBacklogSize") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableProducers") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableSubscriptions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -789,13 +795,13 @@ onUnmounted(() => {
|
||||
<h5>{{ selectedPartition.shortName }}</h5>
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-title">生产者</div>
|
||||
<div class="detail-title">{{ t("mqMonitoring.producers") }}</div>
|
||||
<table v-if="selectedPartitionPublishers.length" class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>速率</th>
|
||||
<th>地址</th>
|
||||
<th>{{ t("mqMonitoring.tableName") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableRate") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableAddress") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -806,17 +812,17 @@ onUnmounted(() => {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state compact">暂无生产者</div>
|
||||
<div v-else class="empty-state compact">{{ t("mqMonitoring.noProducers") }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-title">订阅</div>
|
||||
<div class="detail-title">{{ t("mqMonitoring.subscriptions") }}</div>
|
||||
<table v-if="selectedPartitionSubscriptions.length" class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>积压</th>
|
||||
<th>消费者</th>
|
||||
<th>{{ t("mqMonitoring.tableName") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableType") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableBacklog") }}</th>
|
||||
<th>{{ t("mqMonitoring.tableConsumers") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -828,42 +834,42 @@ onUnmounted(() => {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state compact">暂无订阅</div>
|
||||
<div v-else class="empty-state compact">{{ t("mqMonitoring.noSubscriptions") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">
|
||||
{{ topic.partitioned ? "当前 Broker 响应未返回分区指标" : "非分区主题没有分区明细" }}
|
||||
{{ topic.partitioned ? t("mqMonitoring.noPartitionMetricsFromBroker") : t("mqMonitoring.nonPartitionedTopicNoDetails") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Indicators -->
|
||||
<div class="stats-section">
|
||||
<h4>健康指标</h4>
|
||||
<h4>{{ t("mqMonitoring.healthIndicators") }}</h4>
|
||||
<div class="health-indicators">
|
||||
<div class="health-item">
|
||||
<span class="health-label">消息流动:</span>
|
||||
<span class="health-label">{{ t("mqMonitoring.messageFlow") }}:</span>
|
||||
<span :class="['health-badge', stats.msgRateIn > 0 || stats.msgRateOut > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.msgRateIn > 0 || stats.msgRateOut > 0 ? "活跃" : "空闲" }}
|
||||
{{ stats.msgRateIn > 0 || stats.msgRateOut > 0 ? t("mqMonitoring.flowActive") : t("mqMonitoring.flowIdle") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">积压状态:</span>
|
||||
<span class="health-label">{{ t("mqMonitoring.backlogStatus") }}:</span>
|
||||
<span :class="['health-badge', stats.backlogSize < 10 * 1024 * 1024 ? 'healthy' : 'warning']">
|
||||
{{ stats.backlogSize < 10 * 1024 * 1024 ? "正常" : "偏高" }}
|
||||
{{ stats.backlogSize < 10 * 1024 * 1024 ? t("mqMonitoring.backlogNormal") : t("mqMonitoring.backlogHigh") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">生产者:</span>
|
||||
<span class="health-label">{{ t("mqMonitoring.producersLabel") }}:</span>
|
||||
<span :class="['health-badge', stats.producerCount > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.producerCount > 0 ? "已连接" : "无连接" }}
|
||||
{{ stats.producerCount > 0 ? t("mqMonitoring.producerConnected") : t("mqMonitoring.producerDisconnected") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">订阅:</span>
|
||||
<span class="health-label">{{ t("mqMonitoring.subscriptionsLabel") }}:</span>
|
||||
<span :class="['health-badge', stats.subscriptionCount > 0 ? 'healthy' : 'idle']">
|
||||
{{ stats.subscriptionCount > 0 ? "活跃" : "无订阅" }}
|
||||
{{ stats.subscriptionCount > 0 ? t("mqMonitoring.subscriptionActive") : t("mqMonitoring.subscriptionNone") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqClusterInfo, TopicInfo } from "@/types/mq";
|
||||
import { mqTestConnection } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -27,6 +28,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// State
|
||||
@@ -203,31 +205,31 @@ onMounted(async () => {
|
||||
<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>
|
||||
<button v-if="selectedTenant" class="breadcrumb-button" @click="goToTenantLevel" :title="t('mqAdmin.viewTenant')">{{ selectedTenant }}</button>
|
||||
<span v-if="selectedNamespace" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" title="查看命名空间">{{ selectedNamespace }}</button>
|
||||
<button v-if="selectedNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ selectedNamespace }}</button>
|
||||
<span v-if="selectedTopic" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" title="查看主题">{{ selectedTopic.shortName }}</button>
|
||||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
|
||||
</div>
|
||||
<div class="toolbar-status">
|
||||
<span v-if="readOnly" class="readonly-badge">只读</span>
|
||||
<span v-if="readOnly" class="readonly-badge">{{ t("mqAdmin.readOnly") }}</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="canSendMessage" :class="{ active: activeTab === 'messages' }" @click="setActiveTab('messages')">消息</button>
|
||||
<button :class="{ active: activeTab === 'broker' }" @click="setActiveTab('broker')">Broker</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>
|
||||
<button v-if="canManageTenants" :class="{ active: activeTab === 'tenants' }" @click="setActiveTab('tenants')">{{ t("mqAdmin.tabTenants") }}</button>
|
||||
<button v-if="canManageNamespaces" :class="{ active: activeTab === 'namespaces' }" @click="setActiveTab('namespaces')">{{ t("mqAdmin.tabNamespaces") }}</button>
|
||||
<button :class="{ active: activeTab === 'topics' }" @click="setActiveTab('topics')">{{ t("mqAdmin.tabTopics") }}</button>
|
||||
<button v-if="canManageSubscriptions" :class="{ active: activeTab === 'subscriptions' }" @click="setActiveTab('subscriptions')">{{ t("mqAdmin.tabSubscriptions") }}</button>
|
||||
<button :class="{ active: activeTab === 'monitoring' }" @click="setActiveTab('monitoring')">{{ t("mqAdmin.tabMonitoring") }}</button>
|
||||
<button :class="{ active: activeTab === 'clients' }" @click="setActiveTab('clients')">{{ t("mqAdmin.tabClients") }}</button>
|
||||
<button v-if="canSendMessage" :class="{ active: activeTab === 'messages' }" @click="setActiveTab('messages')">{{ t("mqAdmin.tabMessages") }}</button>
|
||||
<button :class="{ active: activeTab === 'broker' }" @click="setActiveTab('broker')">{{ t("mqAdmin.tabBroker") }}</button>
|
||||
<button v-if="canManagePolicies" :class="{ active: activeTab === 'policies' }" @click="setActiveTab('policies')">{{ t("mqAdmin.tabPolicies") }}</button>
|
||||
<button v-if="canManagePermissions" :class="{ active: activeTab === 'permissions' }" @click="setActiveTab('permissions')">{{ t("mqAdmin.tabPermissions") }}</button>
|
||||
<button v-if="canUseRawApi" :class="{ active: activeTab === 'raw' }" @click="setActiveTab('raw')">{{ t("mqAdmin.tabRawApi") }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
|
||||
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
|
||||
|
||||
@@ -17,6 +18,8 @@ const emit = defineEmits<{
|
||||
namespaceRolesSelected: [namespace: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const namespaces = ref<NamespaceInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
@@ -27,11 +30,9 @@ const formData = ref({
|
||||
namespace: "",
|
||||
});
|
||||
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -64,7 +65,7 @@ function openCreateDialog() {
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!formData.value.namespace.trim() || !props.tenant) {
|
||||
error.value = "Namespace name is required";
|
||||
error.value = t("mqNamespaces.namespaceNameRequired");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -87,7 +88,7 @@ async function handleCreate() {
|
||||
|
||||
async function handleDelete(ns: NamespaceInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除命名空间 "${ns.namespace}" 吗?此操作不可逆。`)) return;
|
||||
if (!confirm(t("mqNamespaces.confirmDelete", { name: ns.namespace }))) return;
|
||||
if (!props.tenant) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
@@ -131,26 +132,26 @@ watch(
|
||||
<template>
|
||||
<div class="namespaces-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>命名空间管理</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant" class="btn-primary">+ 创建命名空间</button>
|
||||
<h3>{{ t("mqNamespaces.title") }}</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant" class="btn-primary">+ {{ t("mqNamespaces.createNamespace") }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!supportsNamespaces" class="panel-placeholder">当前消息队列系统不支持命名空间管理</div>
|
||||
<div v-if="!supportsNamespaces" class="panel-placeholder">{{ t("mqNamespaces.notSupported") }}</div>
|
||||
|
||||
<div v-else-if="!tenant" class="panel-placeholder">请先选择一个租户</div>
|
||||
<div v-else-if="!tenant" class="panel-placeholder">{{ t("mqNamespaces.selectTenantFirst") }}</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-if="loading && !namespaces.length" class="panel-loading">{{ t("mqNamespaces.loading") }}</div>
|
||||
|
||||
<div v-else class="namespaces-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>租户</th>
|
||||
<th>管理角色</th>
|
||||
<th>操作</th>
|
||||
<th>{{ t("mqNamespaces.name") }}</th>
|
||||
<th>{{ t("mqNamespaces.tenant") }}</th>
|
||||
<th>{{ t("mqNamespaces.adminRoles") }}</th>
|
||||
<th>{{ t("mqNamespaces.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -161,11 +162,11 @@ watch(
|
||||
<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>
|
||||
<span v-else class="text-muted">{{ t("mqNamespaces.none") }}</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>
|
||||
<button @click.stop="editNamespaceRoles(ns)" class="btn-sm">{{ t("mqNamespaces.editRoles") }}</button>
|
||||
<button @click.stop="handleDelete(ns)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqNamespaces.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -176,23 +177,23 @@ watch(
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>创建命名空间</h3>
|
||||
<h3>{{ t("mqNamespaces.createDialogTitle") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>租户</label>
|
||||
<label>{{ t("mqNamespaces.tenant") }}</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" />
|
||||
<label>{{ t("mqNamespaces.namespaceName") }}</label>
|
||||
<input v-model="formData.namespace" type="text" :placeholder="t('mqNamespaces.namespaceNamePlaceholder')" :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>
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqNamespaces.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqNamespaces.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { AuthAction, MqIssuedToken, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
|
||||
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
|
||||
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
|
||||
|
||||
@@ -37,7 +39,7 @@ const tokenError = ref<string>();
|
||||
const tokenIssueError = ref<MqTokenIssueErrorView>();
|
||||
const issuedToken = ref<MqIssuedToken>();
|
||||
const showTokenDialog = ref(false);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
const readOnlyMessage = computed(() => t("mqPermissions.readOnly"));
|
||||
|
||||
const scope = computed<PolicyScope | null>(() => {
|
||||
if (!props.tenant || !props.namespace) return null;
|
||||
@@ -72,7 +74,7 @@ const permissionRows = computed(() => {
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
@@ -94,7 +96,7 @@ async function loadPermissions() {
|
||||
// Gracefully handle Kafka brokers without an authorizer configured.
|
||||
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
|
||||
error.value = undefined;
|
||||
notice.value = "当前 Kafka 集群未启用权限管理(Authorizer 未配置)。如需 ACL 功能,请在 Broker 配置中添加 authorizer.class.name。";
|
||||
notice.value = t("mqPermissions.kafkaAuthorizerNotConfigured");
|
||||
} else {
|
||||
error.value = msg;
|
||||
}
|
||||
@@ -110,19 +112,19 @@ async function grantPermission() {
|
||||
roleNameError.value = "";
|
||||
actionsError.value = "";
|
||||
if (!current) {
|
||||
error.value = "请先选择命名空间或主题";
|
||||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
return;
|
||||
}
|
||||
if (!role) {
|
||||
error.value = undefined;
|
||||
roleNameError.value = "请输入角色名";
|
||||
roleNameError.value = t("mqPermissions.roleNameRequired");
|
||||
await nextTick();
|
||||
roleNameInput.value?.focus();
|
||||
return;
|
||||
}
|
||||
if (!selectedActions.value.length) {
|
||||
error.value = undefined;
|
||||
actionsError.value = "请至少选择一个权限动作";
|
||||
actionsError.value = t("mqPermissions.selectAtLeastOneAction");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,13 +133,13 @@ async function grantPermission() {
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await mqGrantPermission(props.connectionId, current, role, [...selectedActions.value]);
|
||||
notice.value = `已授权 ${role}`;
|
||||
notice.value = t("mqPermissions.grantedRole", { role });
|
||||
roleName.value = "";
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
const msg = formatError(e);
|
||||
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
|
||||
error.value = "当前 Kafka 集群未启用权限管理,无法执行授权操作。请在 Broker 配置中启用 Authorizer。";
|
||||
error.value = t("mqPermissions.kafkaAuthorizerDisabled");
|
||||
} else {
|
||||
error.value = msg;
|
||||
}
|
||||
@@ -150,17 +152,17 @@ async function revokePermission(role: string) {
|
||||
if (!guardWritable()) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = "请先选择命名空间或主题";
|
||||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定要撤销角色 "${role}" 的权限吗?`)) return;
|
||||
if (!confirm(t("mqPermissions.confirmRevoke", { role }))) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await mqRevokePermission(props.connectionId, current, role);
|
||||
notice.value = `已撤销 ${role}`;
|
||||
notice.value = t("mqPermissions.revokedRole", { role });
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
@@ -208,19 +210,19 @@ async function loadTokenRecords() {
|
||||
|
||||
async function issueToken() {
|
||||
if (props.readOnly) {
|
||||
tokenError.value = readOnlyMessage;
|
||||
tokenError.value = readOnlyMessage.value;
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
tokenError.value = "请先选择命名空间或主题";
|
||||
tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const subject = tokenRole.value.trim();
|
||||
if (!subject) {
|
||||
tokenError.value = "角色名不能为空";
|
||||
tokenError.value = t("mqPermissions.roleNameEmpty");
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +230,7 @@ async function issueToken() {
|
||||
if (!tokenExpiresUnlimited.value) {
|
||||
const days = Number(tokenExpiresDays.value);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
tokenError.value = "有效期必须大于 0 天";
|
||||
tokenError.value = t("mqPermissions.expiryMustBePositive");
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
@@ -267,7 +269,7 @@ async function copyIssuedToken() {
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "长期";
|
||||
if (!value) return t("mqPermissions.unlimited");
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
@@ -307,49 +309,49 @@ watch(
|
||||
<div class="permissions-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h3>权限管理</h3>
|
||||
<h3>{{ t("mqPermissions.title") }}</h3>
|
||||
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
|
||||
</div>
|
||||
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
{{ loading ? t("mqPermissions.refreshing") : t("mqPermissions.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!scope" class="panel-placeholder">请先选择命名空间或主题</div>
|
||||
<div v-if="!scope" class="panel-placeholder">{{ t("mqPermissions.selectNamespaceOrTopic") }}</div>
|
||||
|
||||
<div v-else class="permissions-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,授权和撤销已禁用。</div>
|
||||
<div v-if="readOnly" class="readonly-hint">{{ t("mqPermissions.readonlyHint") }}</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>
|
||||
<h4>{{ t("mqPermissions.grantRole") }}</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" />
|
||||
{{ t("mqPermissions.roleName") }}
|
||||
<input ref="roleNameInput" v-model="roleName" type="text" :placeholder="t('mqPermissions.roleNamePlaceholder')" :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>
|
||||
<span>{{ t("mqPermissions.actions") }}</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>
|
||||
<button @click="grantPermission" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPermissions.grant") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="permissions-table">
|
||||
<h4>当前权限</h4>
|
||||
<h4>{{ t("mqPermissions.currentPermissions") }}</h4>
|
||||
<table v-if="permissionRows.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>角色</th>
|
||||
<th>动作</th>
|
||||
<th>操作</th>
|
||||
<th>{{ t("mqPermissions.role") }}</th>
|
||||
<th>{{ t("mqPermissions.actions") }}</th>
|
||||
<th>{{ t("mqPermissions.operations") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -361,80 +363,80 @@ watch(
|
||||
</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>
|
||||
<button @click="openTokenDialog(row.role)" class="btn-sm">{{ t("mqPermissions.token") }}</button>
|
||||
<button @click="revokePermission(row.role)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqPermissions.revoke") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">暂无权限记录</div>
|
||||
<div v-else class="empty-state">{{ t("mqPermissions.noPermissions") }}</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>
|
||||
<h3>{{ t("mqPermissions.clientTokenTitle", { role: 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 v-if="tokenIssueError?.kind === 'missingSigningKey'" class="token-config-error" role="alert">
|
||||
<strong>{{ t(tokenIssueError.titleKey) }}</strong>
|
||||
<span>{{ t(tokenIssueError.messageKey) }}</span>
|
||||
<small>{{ t(tokenIssueError.detailKey) }}</small>
|
||||
</div>
|
||||
<div v-if="issuedToken" class="issued-token-box">
|
||||
<div class="token-warning">Token 仅显示一次,请立即复制并保存好。</div>
|
||||
<div class="token-warning">{{ t("mqPermissions.tokenShowOnceWarning") }}</div>
|
||||
<textarea :value="issuedToken.token" readonly class="token-textarea" />
|
||||
<button class="btn-sm" @click="copyIssuedToken">复制 Token</button>
|
||||
<button class="btn-sm" @click="copyIssuedToken">{{ t("mqPermissions.copyToken") }}</button>
|
||||
</div>
|
||||
|
||||
<section class="token-section">
|
||||
<h4>签发新 Token</h4>
|
||||
<h4>{{ t("mqPermissions.issueNewToken") }}</h4>
|
||||
<div class="token-form">
|
||||
<label>
|
||||
角色
|
||||
{{ t("mqPermissions.role") }}
|
||||
<input v-model="tokenRole" type="text" />
|
||||
</label>
|
||||
<div class="expiry-control">
|
||||
<label class="checkbox-label expiry-toggle">
|
||||
<input v-model="tokenExpiresUnlimited" type="checkbox" />
|
||||
永不过期
|
||||
{{ t("mqPermissions.neverExpires") }}
|
||||
</label>
|
||||
<label :class="{ muted: tokenExpiresUnlimited }">
|
||||
有效期(天)
|
||||
{{ t("mqPermissions.expiryDays") }}
|
||||
<input v-model.number="tokenExpiresDays" type="number" min="1" step="1" :disabled="tokenExpiresUnlimited" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions-group token-actions">
|
||||
<span>权限动作</span>
|
||||
<span>{{ t("mqPermissions.actions") }}</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" />
|
||||
{{ t("mqPermissions.note") }}
|
||||
<input v-model="tokenNote" type="text" :placeholder="t('mqPermissions.notePlaceholder')" />
|
||||
</label>
|
||||
<button @click="issueToken" :disabled="tokenLoading || readOnly" class="btn-primary">
|
||||
{{ tokenLoading ? "签发中..." : "生成 Token" }}
|
||||
{{ tokenLoading ? t("mqPermissions.issuing") : t("mqPermissions.generateToken") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="token-message">撤销角色权限不会让已签发 JWT 立即失效;需要等待过期,或轮换 Broker 签发密钥。</p>
|
||||
<p class="token-message">{{ t("mqPermissions.tokenRevokeHint") }}</p>
|
||||
</section>
|
||||
|
||||
<section class="token-section">
|
||||
<h4>签发记录</h4>
|
||||
<h4>{{ t("mqPermissions.issueRecords") }}</h4>
|
||||
<table v-if="tokenRecords.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>算法</th>
|
||||
<th>过期</th>
|
||||
<th>指纹</th>
|
||||
<th>备注</th>
|
||||
<th>{{ t("mqPermissions.time") }}</th>
|
||||
<th>{{ t("mqPermissions.algorithm") }}</th>
|
||||
<th>{{ t("mqPermissions.expires") }}</th>
|
||||
<th>{{ t("mqPermissions.fingerprint") }}</th>
|
||||
<th>{{ t("mqPermissions.noteColumn") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -447,11 +449,11 @@ watch(
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">{{ tokenLoading ? "加载中..." : "暂无签发记录" }}</div>
|
||||
<div v-else class="empty-state">{{ tokenLoading ? t("mqPermissions.loading") : t("mqPermissions.noIssueRecords") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="closeTokenDialog" class="btn-secondary">关闭</button>
|
||||
<button @click="closeTokenDialog" class="btn-secondary">{{ t("mqPermissions.close") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
|
||||
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
|
||||
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
|
||||
@@ -18,12 +19,13 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const policies = ref<unknown>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const notice = ref<string>();
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
const readOnlyMessage = computed(() => t("mqPolicies.readOnly"));
|
||||
const defaultForms = defaultMqPolicyForms();
|
||||
|
||||
const publishForm = ref<PublishRate>({ ...defaultForms.publishForm });
|
||||
@@ -55,7 +57,7 @@ const scope = computed<PolicyScope | null>(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const scopePlaceholderMessage = computed(() => (props.isKafkaCluster ? "请先选择主题" : "请选择命名空间或主题"));
|
||||
const scopePlaceholderMessage = computed(() => (props.isKafkaCluster ? t("mqPolicies.selectTopicFirst") : t("mqPolicies.selectNamespaceOrTopic")));
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const current = scope.value;
|
||||
@@ -70,7 +72,7 @@ const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, nu
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
@@ -110,7 +112,7 @@ async function applyPolicy(kind: string, action: (current: PolicyScope) => Promi
|
||||
notice.value = undefined;
|
||||
try {
|
||||
await action(current);
|
||||
notice.value = `${kind}已保存`;
|
||||
notice.value = t("mqPolicies.savedNotice", { policy: kind });
|
||||
await loadPolicies();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
@@ -120,23 +122,23 @@ async function applyPolicy(kind: string, action: (current: PolicyScope) => Promi
|
||||
}
|
||||
|
||||
function savePublishRate() {
|
||||
return applyPolicy("发布限速", (scope) => mqSetPublishRate(props.connectionId, scope, { ...publishForm.value }));
|
||||
return applyPolicy(t("mqPolicies.publishRate"), (scope) => mqSetPublishRate(props.connectionId, scope, { ...publishForm.value }));
|
||||
}
|
||||
|
||||
function saveDispatchRate() {
|
||||
return applyPolicy("派发限速", (scope) => mqSetDispatchRate(props.connectionId, scope, { ...dispatchForm.value }));
|
||||
return applyPolicy(t("mqPolicies.dispatchRate"), (scope) => mqSetDispatchRate(props.connectionId, scope, { ...dispatchForm.value }));
|
||||
}
|
||||
|
||||
function saveSubscribeRate() {
|
||||
return applyPolicy("订阅限速", (scope) => mqSetSubscribeRate(props.connectionId, scope, { ...subscribeForm.value }));
|
||||
return applyPolicy(t("mqPolicies.subscribeRate"), (scope) => mqSetSubscribeRate(props.connectionId, scope, { ...subscribeForm.value }));
|
||||
}
|
||||
|
||||
function saveBacklogQuota() {
|
||||
return applyPolicy("积压配额", (scope) => mqSetBacklogQuota(props.connectionId, scope, { ...backlogForm.value }));
|
||||
return applyPolicy(t("mqPolicies.backlogQuota"), (scope) => mqSetBacklogQuota(props.connectionId, scope, { ...backlogForm.value }));
|
||||
}
|
||||
|
||||
function saveRetention() {
|
||||
return applyPolicy("保留策略", (scope) => mqSetRetention(props.connectionId, scope, { ...retentionForm.value }));
|
||||
return applyPolicy(t("mqPolicies.retention"), (scope) => mqSetRetention(props.connectionId, scope, { ...retentionForm.value }));
|
||||
}
|
||||
|
||||
function currentPolicyForms() {
|
||||
@@ -170,106 +172,106 @@ watch(
|
||||
<div class="policies-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h3>策略管理</h3>
|
||||
<h3>{{ t("mqPolicies.title") }}</h3>
|
||||
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
|
||||
</div>
|
||||
<button @click="loadPolicies" :disabled="loading || !scope" class="btn-sm">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
{{ loading ? t("mqPolicies.refreshing") : t("mqPolicies.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!scope" class="panel-placeholder">{{ scopePlaceholderMessage }}</div>
|
||||
|
||||
<div v-else class="policies-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,策略编辑已禁用。</div>
|
||||
<div v-if="readOnly" class="readonly-hint">{{ t("mqPolicies.readonlyHint") }}</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>
|
||||
<h4>{{ t("mqPolicies.publishRate") }}</h4>
|
||||
<label>
|
||||
消息数 / 秒
|
||||
{{ t("mqPolicies.msgsPerSecond") }}
|
||||
<input v-model.number="publishForm.publishThrottlingRateInMsg" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
字节数 / 秒
|
||||
{{ t("mqPolicies.bytesPerSecond") }}
|
||||
<input v-model.number="publishForm.publishThrottlingRateInByte" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="savePublishRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
<button @click="savePublishRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRateLimits !== false" class="policy-section">
|
||||
<h4>派发限速</h4>
|
||||
<h4>{{ t("mqPolicies.dispatchRate") }}</h4>
|
||||
<label>
|
||||
消息数 / 周期
|
||||
{{ t("mqPolicies.msgsPerPeriod") }}
|
||||
<input v-model.number="dispatchForm.dispatchThrottlingRateInMsg" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
字节数 / 周期
|
||||
{{ t("mqPolicies.bytesPerPeriod") }}
|
||||
<input v-model.number="dispatchForm.dispatchThrottlingRateInByte" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
周期(秒)
|
||||
{{ t("mqPolicies.periodSeconds") }}
|
||||
<input v-model.number="dispatchForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveDispatchRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
<button @click="saveDispatchRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRateLimits !== false" class="policy-section">
|
||||
<h4>订阅限速</h4>
|
||||
<h4>{{ t("mqPolicies.subscribeRate") }}</h4>
|
||||
<label>
|
||||
每消费者消息数 / 周期
|
||||
{{ t("mqPolicies.msgsPerConsumerPerPeriod") }}
|
||||
<input v-model.number="subscribeForm.subscribeThrottlingRatePerConsumer" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
周期(秒)
|
||||
{{ t("mqPolicies.periodSeconds") }}
|
||||
<input v-model.number="subscribeForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveSubscribeRate" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
<button @click="saveSubscribeRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsBacklogQuota !== false" class="policy-section">
|
||||
<h4>积压配额</h4>
|
||||
<h4>{{ t("mqPolicies.backlogQuota") }}</h4>
|
||||
<label>
|
||||
大小限制(字节)
|
||||
{{ t("mqPolicies.sizeLimitBytes") }}
|
||||
<input v-model.number="backlogForm.limitSize" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
时间限制(秒)
|
||||
{{ t("mqPolicies.timeLimitSeconds") }}
|
||||
<input v-model.number="backlogForm.limitTime" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
策略
|
||||
{{ t("mqPolicies.policy") }}
|
||||
<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>
|
||||
<option value="producer_request_hold">{{ t("mqPolicies.backlogPolicyProducerRequestHold") }}</option>
|
||||
<option value="producer_exception">{{ t("mqPolicies.backlogPolicyProducerException") }}</option>
|
||||
<option value="consumer_backlog_eviction">{{ t("mqPolicies.backlogPolicyConsumerBacklogEviction") }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
{{ t("mqPolicies.type") }}
|
||||
<input v-model="backlogForm.quotaType" type="text" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveBacklogQuota" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
<button @click="saveBacklogQuota" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
|
||||
</section>
|
||||
|
||||
<section v-if="supportsRetention !== false" class="policy-section">
|
||||
<h4>消息保留</h4>
|
||||
<h4>{{ t("mqPolicies.retention") }}</h4>
|
||||
<label>
|
||||
保留时间(分钟)
|
||||
{{ t("mqPolicies.retentionTimeMinutes") }}
|
||||
<input v-model.number="retentionForm.retentionTimeInMinutes" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<label>
|
||||
保留大小(MB)
|
||||
{{ t("mqPolicies.retentionSizeMb") }}
|
||||
<input v-model.number="retentionForm.retentionSizeInMb" type="number" :disabled="readOnly" />
|
||||
</label>
|
||||
<button @click="saveRetention" :disabled="loading || readOnly" class="btn-primary">保存</button>
|
||||
<button @click="saveRetention" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="json-section">
|
||||
<h4>当前有效策略</h4>
|
||||
<h4>{{ t("mqPolicies.effectivePolicies") }}</h4>
|
||||
<pre>{{ formattedPolicies }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
interface PartitionClientRow {
|
||||
name: string;
|
||||
@@ -76,7 +78,7 @@ const displayedConsumers = computed(() => {
|
||||
if (selectedPartition.value) return selectedPartitionSubscription.value?.consumers ?? [];
|
||||
return aggregateConsumers.value;
|
||||
});
|
||||
const selectedScopeLabel = computed(() => selectedPartition.value?.shortName ?? "聚合 topic");
|
||||
const selectedScopeLabel = computed(() => selectedPartition.value?.shortName ?? t("mqClients.aggregateTopic"));
|
||||
|
||||
async function loadRuntimeClients() {
|
||||
const loadSeq = ++runtimeLoadSeq;
|
||||
@@ -128,7 +130,7 @@ async function loadRuntimeClients() {
|
||||
async function unloadTopic() {
|
||||
const current = topicRef.value;
|
||||
if (!current || props.readOnly || unloading.value) return;
|
||||
if (!confirm("确认卸载当前主题?活跃生产者和消费者会重新连接。")) return;
|
||||
if (!confirm(t("mqClients.confirmUnload"))) return;
|
||||
|
||||
unloading.value = true;
|
||||
error.value = undefined;
|
||||
@@ -143,7 +145,7 @@ async function unloadTopic() {
|
||||
}
|
||||
|
||||
function formatRate(value: number): string {
|
||||
return `${value.toFixed(2)} msg/s`;
|
||||
return t("mqClients.rateValue", { value: value.toFixed(2) });
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
@@ -416,36 +418,36 @@ watch(
|
||||
<template>
|
||||
<div class="producer-consumer-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>生产者 / 消费者</h3>
|
||||
<h3>{{ t("mqClients.title") }}</h3>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="!isKafkaCluster" class="btn-sm danger" :disabled="readOnly || !topic || unloading" @click="unloadTopic">
|
||||
{{ unloading ? "卸载中..." : "卸载主题" }}
|
||||
{{ unloading ? t("mqClients.unloading") : t("mqClients.unloadTopic") }}
|
||||
</button>
|
||||
<button class="btn-sm" :disabled="loading || !topic" @click="loadRuntimeClients">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
{{ loading ? t("mqClients.refreshing") : t("mqClients.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">请先选择一个 topic</div>
|
||||
<div v-if="!topic" class="panel-placeholder">{{ t("mqClients.selectTopicFirst") }}</div>
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else class="runtime-content">
|
||||
<section v-if="isKafkaStats" class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>Kafka 分区状态</h4>
|
||||
<span>{{ kafkaPartitionRows.length }} 个分区</span>
|
||||
<h4>{{ t("mqClients.kafkaPartitionStatus") }}</h4>
|
||||
<span>{{ t("mqClients.partitionCount", { count: kafkaPartitionRows.length }) }}</span>
|
||||
</div>
|
||||
<table class="runtime-table partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>起始 offset</th>
|
||||
<th>最新 offset</th>
|
||||
<th>消息数</th>
|
||||
<th>Leader</th>
|
||||
<th>Replicas</th>
|
||||
<th>ISR</th>
|
||||
<th>{{ t("mqClients.partition") }}</th>
|
||||
<th>{{ t("mqClients.beginOffset") }}</th>
|
||||
<th>{{ t("mqClients.latestOffset") }}</th>
|
||||
<th>{{ t("mqClients.messageCount") }}</th>
|
||||
<th>{{ t("mqClients.leader") }}</th>
|
||||
<th>{{ t("mqClients.replicas") }}</th>
|
||||
<th>{{ t("mqClients.isr") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -464,18 +466,18 @@ watch(
|
||||
|
||||
<section v-else-if="partitionRows.length" class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>分区客户端</h4>
|
||||
<span>{{ partitionRows.length }} 个分区</span>
|
||||
<h4>{{ t("mqClients.partitionClients") }}</h4>
|
||||
<span>{{ t("mqClients.partitionCount", { count: partitionRows.length }) }}</span>
|
||||
</div>
|
||||
<table class="runtime-table partition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分区</th>
|
||||
<th>入站速率</th>
|
||||
<th>出站速率</th>
|
||||
<th>生产者</th>
|
||||
<th>订阅</th>
|
||||
<th>消费者</th>
|
||||
<th>{{ t("mqClients.partition") }}</th>
|
||||
<th>{{ t("mqClients.inboundRate") }}</th>
|
||||
<th>{{ t("mqClients.outboundRate") }}</th>
|
||||
<th>{{ t("mqClients.producers") }}</th>
|
||||
<th>{{ t("mqClients.subscriptions") }}</th>
|
||||
<th>{{ t("mqClients.consumers") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -493,22 +495,22 @@ watch(
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>活跃生产者</h4>
|
||||
<h4>{{ t("mqClients.activeProducers") }}</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>
|
||||
<div v-if="!displayedProducers.length && !loading" class="empty-state">{{ t("mqClients.noActiveProducers") }}</div>
|
||||
<table v-else class="runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>ID</th>
|
||||
<th>地址</th>
|
||||
<th>版本</th>
|
||||
<th>速率</th>
|
||||
<th>吞吐</th>
|
||||
<th>{{ t("mqClients.name") }}</th>
|
||||
<th>{{ t("mqClients.id") }}</th>
|
||||
<th>{{ t("mqClients.address") }}</th>
|
||||
<th>{{ t("mqClients.version") }}</th>
|
||||
<th>{{ t("mqClients.rate") }}</th>
|
||||
<th>{{ t("mqClients.throughput") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -526,7 +528,7 @@ watch(
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="section-heading">
|
||||
<h4>活跃消费者</h4>
|
||||
<h4>{{ t("mqClients.activeConsumers") }}</h4>
|
||||
<div class="subscription-selector">
|
||||
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
|
||||
<span>{{ displayedConsumers.length }}</span>
|
||||
@@ -538,19 +540,19 @@ watch(
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!subscriptionOptions.length && !loading" class="empty-state">当前 topic 没有订阅</div>
|
||||
<div v-if="!subscriptionOptions.length && !loading" class="empty-state">{{ t("mqClients.noSubscriptions") }}</div>
|
||||
<div v-else-if="!displayedConsumers.length && !loading" class="empty-state">
|
||||
{{ selectedPartition ? "当前分区订阅没有活跃消费者" : "当前订阅没有活跃消费者" }}
|
||||
{{ selectedPartition ? t("mqClients.noConsumersOnPartition") : t("mqClients.noConsumersOnSubscription") }}
|
||||
</div>
|
||||
<table v-else class="runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>地址</th>
|
||||
<th>版本</th>
|
||||
<th>速率</th>
|
||||
<th>吞吐</th>
|
||||
<th>Permits</th>
|
||||
<th>{{ t("mqClients.name") }}</th>
|
||||
<th>{{ t("mqClients.address") }}</th>
|
||||
<th>{{ t("mqClients.version") }}</th>
|
||||
<th>{{ t("mqClients.rate") }}</th>
|
||||
<th>{{ t("mqClients.throughput") }}</th>
|
||||
<th>{{ t("mqClients.permits") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqRawResponse, TopicInfo } from "@/types/mq";
|
||||
import { mqRawRequest } from "@/lib/backend/api";
|
||||
import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
|
||||
type RawApiPreset = {
|
||||
@@ -34,7 +36,7 @@ const response = ref<MqRawResponse>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const presetsCollapsed = ref(true);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
const readOnlyMessage = computed(() => t("mqRaw.readOnly"));
|
||||
|
||||
const isReadMethod = computed(() => method.value === "GET" || method.value === "HEAD" || method.value === "OPTIONS");
|
||||
const requestDisabled = computed(() => loading.value || (props.readOnly && !isReadMethod.value));
|
||||
@@ -59,56 +61,56 @@ const presets = computed<RawApiPreset[]>(() => {
|
||||
const topic = topicPath.value;
|
||||
return [
|
||||
{
|
||||
label: "Broker 版本",
|
||||
description: "查看 broker 暴露的版本字符串",
|
||||
label: t("mqRaw.presetBrokerVersion"),
|
||||
description: t("mqRaw.presetBrokerVersionDesc"),
|
||||
method: "GET",
|
||||
path: "/admin/v2/brokers/version",
|
||||
},
|
||||
{
|
||||
label: "集群列表",
|
||||
description: "列出 Pulsar 已配置的 clusters",
|
||||
label: t("mqRaw.presetClusters"),
|
||||
description: t("mqRaw.presetClustersDesc"),
|
||||
method: "GET",
|
||||
path: "/admin/v2/clusters",
|
||||
},
|
||||
{
|
||||
label: "租户详情",
|
||||
description: "查看 adminRoles / allowedClusters",
|
||||
label: t("mqRaw.presetTenant"),
|
||||
description: t("mqRaw.presetTenantDesc"),
|
||||
method: "GET",
|
||||
path: tenant ?? "/admin/v2/tenants/{tenant}",
|
||||
unavailable: !tenant,
|
||||
},
|
||||
{
|
||||
label: "命名空间策略",
|
||||
description: "查看 namespace policies 原始结构",
|
||||
label: t("mqRaw.presetNamespacePolicies"),
|
||||
description: t("mqRaw.presetNamespacePoliciesDesc"),
|
||||
method: "GET",
|
||||
path: namespace ?? "/admin/v2/namespaces/{tenant}/{namespace}",
|
||||
unavailable: !namespace,
|
||||
},
|
||||
{
|
||||
label: "Bundle 列表",
|
||||
description: "查看 namespace bundle 分布",
|
||||
label: t("mqRaw.presetBundles"),
|
||||
description: t("mqRaw.presetBundlesDesc"),
|
||||
method: "GET",
|
||||
path: namespace ? `${namespace}/bundles` : "/admin/v2/namespaces/{tenant}/{namespace}/bundles",
|
||||
unavailable: !namespace,
|
||||
},
|
||||
{
|
||||
label: "Topic 内部状态",
|
||||
description: "查看 ledger / cursor / backlog 细节",
|
||||
label: t("mqRaw.presetTopicInternalStats"),
|
||||
description: t("mqRaw.presetTopicInternalStatsDesc"),
|
||||
method: "GET",
|
||||
path: topic ? `${topic}/internalStats` : "/admin/v2/persistent/{tenant}/{namespace}/{topic}/internalStats",
|
||||
unavailable: !topic,
|
||||
},
|
||||
{
|
||||
label: "分区明细指标",
|
||||
description: "按分区返回速率、生产者和消费者指标",
|
||||
label: t("mqRaw.presetPartitionedStats"),
|
||||
description: t("mqRaw.presetPartitionedStatsDesc"),
|
||||
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 定义",
|
||||
label: t("mqRaw.presetSchema"),
|
||||
description: t("mqRaw.presetSchemaDesc"),
|
||||
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,
|
||||
@@ -160,7 +162,7 @@ function formatJsonBody() {
|
||||
bodyText.value = safeJsonFormat(text, 2);
|
||||
error.value = undefined;
|
||||
} catch (e: unknown) {
|
||||
error.value = `JSON Body 格式错误: ${formatError(e)}`;
|
||||
error.value = t("mqRaw.jsonBodyInvalid", { error: formatError(e) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +181,11 @@ async function executeRequest() {
|
||||
response.value = undefined;
|
||||
|
||||
if (props.readOnly && !isReadMethod.value) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = readOnlyMessage.value;
|
||||
return;
|
||||
}
|
||||
if (!path.value.trim()) {
|
||||
error.value = "请输入请求路径";
|
||||
error.value = t("mqRaw.pathRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,21 +208,21 @@ async function executeRequest() {
|
||||
<template>
|
||||
<div class="raw-api-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>Raw API</h3>
|
||||
<h3>{{ t("mqRaw.title") }}</h3>
|
||||
<button @click="executeRequest" :disabled="requestDisabled" class="btn-primary">
|
||||
{{ loading ? "请求中..." : "发送请求" }}
|
||||
{{ loading ? t("mqRaw.sending") : t("mqRaw.sendRequest") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="raw-content">
|
||||
<div v-if="readOnly" class="readonly-hint">当前连接为只读模式,仅允许 GET、HEAD 和 OPTIONS 请求。</div>
|
||||
<div v-if="readOnly" class="readonly-hint">{{ t("mqRaw.readOnlyHint") }}</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>
|
||||
<h4>{{ t("mqRaw.commonEndpoints") }}</h4>
|
||||
<span>{{ t("mqRaw.fillFromSelection") }}</span>
|
||||
<span class="preset-toggle">{{ presetsCollapsed ? t("mqRaw.expand") : t("mqRaw.collapse") }}</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)">
|
||||
@@ -236,39 +238,39 @@ async function executeRequest() {
|
||||
<section class="request-panel">
|
||||
<div class="request-line">
|
||||
<label>
|
||||
方法
|
||||
{{ t("mqRaw.method") }}
|
||||
<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/..." />
|
||||
{{ t("mqRaw.path") }}
|
||||
<input v-model="path" type="text" :placeholder="t('mqRaw.pathPlaceholder')" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
查询参数
|
||||
<textarea v-model="queryText" rows="4" placeholder="key=value,每行一个"></textarea>
|
||||
{{ t("mqRaw.queryParams") }}
|
||||
<textarea v-model="queryText" rows="4" :placeholder="t('mqRaw.queryPlaceholder')"></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>{{ t("mqRaw.jsonBody") }}</span>
|
||||
<button type="button" class="btn-secondary compact" :disabled="isReadMethod || !bodyText.trim()" @click="formatJsonBody">{{ t("mqRaw.format") }}</button>
|
||||
</span>
|
||||
<textarea v-model="bodyText" class="json-body-textarea" :rows="bodyTextareaRows" placeholder='例如: {"key":"value"}' :disabled="isReadMethod"></textarea>
|
||||
<textarea v-model="bodyText" class="json-body-textarea" :rows="bodyTextareaRows" :placeholder="t('mqRaw.bodyPlaceholder')" :disabled="isReadMethod"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="response-panel">
|
||||
<h4>响应</h4>
|
||||
<h4>{{ t("mqRaw.response") }}</h4>
|
||||
<div v-if="response" class="response-meta">
|
||||
<span>HTTP {{ response.status }}</span>
|
||||
<span v-if="response.text">文本响应</span>
|
||||
<span v-if="response.text">{{ t("mqRaw.textResponse") }}</span>
|
||||
</div>
|
||||
<pre v-if="response">{{ response.text || formattedBody }}</pre>
|
||||
<div v-else class="empty-state">尚未发送请求</div>
|
||||
<div v-else class="empty-state">{{ t("mqRaw.noRequestYet") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { PeekedMessage, TopicInfo, TopicRef, SendMessageRequest, SendMessageResponse } from "@/types/mq";
|
||||
import { mqSendMessage, mqListTopics, mqPeekMessages } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -15,6 +17,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const topicName = ref("");
|
||||
const messageKey = ref("");
|
||||
@@ -26,16 +29,17 @@ const success = ref<SendMessageResponse>();
|
||||
const availableTopics = ref<TopicInfo[]>([]);
|
||||
const topicsLoading = ref(false);
|
||||
const headersExpanded = ref(false);
|
||||
const peekAdvancedExpanded = ref(false);
|
||||
const peekLoading = ref(false);
|
||||
const peekError = ref<string>();
|
||||
const peekMessages = ref<PeekedMessage[]>([]);
|
||||
const peekPartition = ref(0);
|
||||
const peekOffset = ref(0);
|
||||
const peekPartition = ref("");
|
||||
const peekOffset = ref("");
|
||||
const peekCount = ref(20);
|
||||
|
||||
let successTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const readOnlyMessage = "当前连接为只读模式,不能发送消息";
|
||||
const readOnlyMessage = computed(() => t("mqMessages.readOnlyCannotSend"));
|
||||
|
||||
const topicOptions = computed(() => {
|
||||
return availableTopics.value.map((t) => ({
|
||||
@@ -74,7 +78,7 @@ onUnmounted(() => {
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = readOnlyMessage.value;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -123,11 +127,11 @@ async function sendMessage() {
|
||||
|
||||
const topic = topicName.value.trim();
|
||||
if (!topic) {
|
||||
error.value = "请选择目标主题";
|
||||
error.value = t("mqMessages.selectTargetTopic");
|
||||
return;
|
||||
}
|
||||
if (!messageValue.value) {
|
||||
error.value = "消息内容不能为空";
|
||||
error.value = t("mqMessages.messageContentRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,8 +148,8 @@ async function sendMessage() {
|
||||
};
|
||||
success.value = await mqSendMessage(props.connectionId, req);
|
||||
if (canBrowseMessages.value) {
|
||||
peekPartition.value = success.value.partition;
|
||||
peekOffset.value = success.value.offset;
|
||||
peekPartition.value = String(success.value.partition);
|
||||
peekOffset.value = String(success.value.offset);
|
||||
void loadMessages();
|
||||
}
|
||||
messageValue.value = "";
|
||||
@@ -161,19 +165,34 @@ async function sendMessage() {
|
||||
async function loadMessages() {
|
||||
const topic = selectedTopicRef.value;
|
||||
if (!topic) {
|
||||
peekError.value = "Select a topic before loading messages";
|
||||
peekError.value = t("mqMessages.selectTopicBeforeLoad");
|
||||
return;
|
||||
}
|
||||
peekLoading.value = true;
|
||||
peekError.value = undefined;
|
||||
try {
|
||||
const count = Math.max(1, Math.min(100, Number(peekCount.value) || 20));
|
||||
const partition = Math.max(0, Number(peekPartition.value) || 0);
|
||||
const offset = Math.max(0, Number(peekOffset.value) || 0);
|
||||
peekCount.value = count;
|
||||
peekPartition.value = partition;
|
||||
peekOffset.value = offset;
|
||||
peekMessages.value = await mqPeekMessages(props.connectionId, topic, "__dbx_kafka_viewer__", count, { partition, offset });
|
||||
const options: { partition?: number; offset?: number } = {};
|
||||
const partitionText = peekPartition.value.trim();
|
||||
const offsetText = peekOffset.value.trim();
|
||||
if (partitionText !== "") {
|
||||
const partition = parseNonNegativeSafeInteger(partitionText);
|
||||
if (partition == null) {
|
||||
throw new Error(t("mqMessages.partitionMustBeNonNegativeInt"));
|
||||
}
|
||||
options.partition = partition;
|
||||
peekPartition.value = String(partition);
|
||||
}
|
||||
if (offsetText !== "") {
|
||||
const offset = parseNonNegativeSafeInteger(offsetText);
|
||||
if (offset == null) {
|
||||
throw new Error(t("mqMessages.offsetMustBeNonNegativeInt"));
|
||||
}
|
||||
options.offset = offset;
|
||||
peekOffset.value = String(offset);
|
||||
}
|
||||
peekMessages.value = await mqPeekMessages(props.connectionId, topic, "__dbx_kafka_viewer__", count, options);
|
||||
} catch (e: unknown) {
|
||||
peekError.value = formatError(e);
|
||||
} finally {
|
||||
@@ -236,103 +255,114 @@ watch(
|
||||
<template>
|
||||
<div class="send-message-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>发送消息</h3>
|
||||
<button @click="clearForm" :disabled="loading" class="btn-sm">清空</button>
|
||||
<h3>{{ t("mqMessages.title") }}</h3>
|
||||
<button @click="clearForm" :disabled="loading" class="btn-sm">{{ t("mqMessages.clear") }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">请先选择命名空间或主题</div>
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqMessages.selectNamespaceOrTopicFirst") }}</div>
|
||||
|
||||
<div v-else class="send-form">
|
||||
<div v-if="readOnly" class="readonly-hint">{{ readOnlyMessage }}</div>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-if="success" class="panel-success">
|
||||
<span class="success-icon">✓</span>
|
||||
<span>消息发送成功 — 分区: {{ success.partition }},偏移: {{ success.offset }}</span>
|
||||
<span>{{ t("mqMessages.sendSuccess", { partition: success.partition, offset: success.offset }) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 主题选择 -->
|
||||
<div class="form-group">
|
||||
<label>目标主题 <span class="required">*</span></label>
|
||||
<label>{{ t("mqMessages.targetTopic") }} <span class="required">*</span></label>
|
||||
<div class="topic-select-row">
|
||||
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? '加载中...' : '输入或搜索主题...'" autocomplete="off" />
|
||||
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? t('mqMessages.topicLoading') : t('mqMessages.topicSearchPlaceholder')" autocomplete="off" />
|
||||
<datalist :id="topicListId">
|
||||
<option v-for="t in topicOptions" :key="t.value" :value="t.value" :label="t.partitions != null ? `${t.label} (${t.partitions} 分区)` : t.label" />
|
||||
<option v-for="t in topicOptions" :key="t.value" :value="t.value" :label="t.partitions != null ? $t('mqMessages.topicOptionWithPartitions', { label: t.label, partitions: t.partitions }) : t.label" />
|
||||
</datalist>
|
||||
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" title="刷新主题列表">
|
||||
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" :title="t('mqMessages.refreshTopicList')">
|
||||
<span v-if="topicsLoading" class="spin">⟳</span>
|
||||
<span v-else>⟳</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">暂无可用主题</div>
|
||||
<div v-else class="form-hint">可输入关键词搜索,也可以直接粘贴 topic 名称。</div>
|
||||
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">{{ t("mqMessages.noTopicsAvailable") }}</div>
|
||||
<div v-else class="form-hint">{{ t("mqMessages.topicSearchHint") }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息键 -->
|
||||
<div class="form-group">
|
||||
<label>消息键 (Key)</label>
|
||||
<input v-model="messageKey" type="text" placeholder="可选" :disabled="readOnly" />
|
||||
<label>{{ t("mqMessages.messageKey") }}</label>
|
||||
<input v-model="messageKey" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label>消息内容 <span class="required">*</span></label>
|
||||
<button @click="formatJson" :disabled="readOnly || !messageValue" class="btn-sm">格式化 JSON</button>
|
||||
<label>{{ t("mqMessages.messageContent") }} <span class="required">*</span></label>
|
||||
<button @click="formatJson" :disabled="readOnly || !messageValue" class="btn-sm">{{ t("mqMessages.formatJson") }}</button>
|
||||
</div>
|
||||
<textarea v-model="messageValue" :disabled="readOnly" placeholder='{"key": "value"}' rows="8" class="code-textarea" />
|
||||
<textarea v-model="messageValue" :disabled="readOnly" :placeholder="t('mqMessages.jsonBodyPlaceholder')" rows="8" class="code-textarea" />
|
||||
</div>
|
||||
|
||||
<!-- 消息头(可折叠) -->
|
||||
<div class="form-group">
|
||||
<button type="button" class="collapse-toggle" @click="headersExpanded = !headersExpanded">
|
||||
<span class="collapse-arrow" :class="{ expanded: headersExpanded }">▶</span>
|
||||
<span>消息头 (Headers)</span>
|
||||
<span>{{ t("mqMessages.messageHeaders") }}</span>
|
||||
<span v-if="headersText.trim() && !headersExpanded" class="collapse-badge">·</span>
|
||||
</button>
|
||||
<div v-if="headersExpanded" class="collapse-body">
|
||||
<textarea v-model="headersText" :disabled="readOnly" placeholder="key: value(每行一个)" rows="3" class="headers-textarea" />
|
||||
<textarea v-model="headersText" :disabled="readOnly" :placeholder="t('mqMessages.headersPlaceholder')" rows="3" class="headers-textarea" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="form-actions">
|
||||
<button @click="sendMessage" :disabled="loading || readOnly || !topicName || !messageValue" class="btn-primary">
|
||||
{{ loading ? "发送中..." : "发送消息" }}
|
||||
{{ loading ? t("mqMessages.sending") : t("mqMessages.sendMessage") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="canBrowseMessages" class="message-browser">
|
||||
<div class="message-browser-header">
|
||||
<h4>消息列表</h4>
|
||||
<h4>{{ t("mqMessages.messageList") }}</h4>
|
||||
<button type="button" class="btn-sm" :disabled="peekLoading || !selectedTopicRef" @click="loadMessages">
|
||||
{{ peekLoading ? "加载中..." : "加载消息" }}
|
||||
{{ peekLoading ? t("mqMessages.loading") : t("mqMessages.loadMessages") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="peek-default-hint">{{ t("mqMessages.peekDefaultHint", { count: peekCount }) }}</p>
|
||||
|
||||
<div class="peek-controls">
|
||||
<label>
|
||||
<span>分区</span>
|
||||
<input v-model.number="peekPartition" type="number" min="0" :disabled="peekLoading" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Offset</span>
|
||||
<input v-model.number="peekOffset" type="number" min="0" :disabled="peekLoading" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数量</span>
|
||||
<span>{{ t("mqMessages.count") }}</span>
|
||||
<input v-model.number="peekCount" type="number" min="1" max="100" :disabled="peekLoading" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="collapse-toggle peek-advanced-toggle" @click="peekAdvancedExpanded = !peekAdvancedExpanded">
|
||||
<span class="collapse-arrow" :class="{ expanded: peekAdvancedExpanded }">▶</span>
|
||||
<span>{{ t("mqMessages.advancedFilter") }}</span>
|
||||
<span v-if="(peekPartition || peekOffset) && !peekAdvancedExpanded" class="collapse-badge">·</span>
|
||||
</button>
|
||||
<div v-if="peekAdvancedExpanded" class="peek-controls collapse-body">
|
||||
<label>
|
||||
<span>{{ t("mqMessages.partition") }}</span>
|
||||
<input v-model="peekPartition" type="number" min="0" :placeholder="t('mqMessages.partitionPlaceholderAll')" :disabled="peekLoading" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("mqMessages.offset") }}</span>
|
||||
<input v-model="peekOffset" type="number" min="0" :placeholder="t('mqMessages.offsetPlaceholderEarliest')" :disabled="peekLoading" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="peekError" class="panel-error">{{ peekError }}</div>
|
||||
<div v-else-if="peekLoading" class="message-empty">消息加载中...</div>
|
||||
<div v-else-if="!peekMessages.length" class="message-empty">暂无消息</div>
|
||||
<div v-else-if="peekLoading" class="message-empty">{{ t("mqMessages.messagesLoading") }}</div>
|
||||
<div v-else-if="!peekMessages.length" class="message-empty">{{ t("mqMessages.noMessages") }}</div>
|
||||
<div v-else class="message-list">
|
||||
<article v-for="message in peekMessages" :key="message.messageId || message.position" class="message-row">
|
||||
<article v-for="message in peekMessages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="message-row">
|
||||
<div class="message-meta">
|
||||
<span>#{{ message.position }}</span>
|
||||
<span>offset {{ message.messageId || "-" }}</span>
|
||||
<span v-if="message.key">key {{ message.key }}</span>
|
||||
<span v-if="message.properties?.partition != null">{{ t("mqMessages.metaPartition", { partition: message.properties.partition }) }}</span>
|
||||
<span>{{ t("mqMessages.metaOffset", { offset: message.messageId || "-" }) }}</span>
|
||||
<span v-if="message.key">{{ t("mqMessages.metaKey", { key: message.key }) }}</span>
|
||||
<span>{{ formatMessageTimestamp(message.publishTime) }}</span>
|
||||
</div>
|
||||
<pre class="message-payload">{{ messagePayload(message) }}</pre>
|
||||
@@ -655,9 +685,23 @@ input[type="number"]:focus {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peek-default-hint {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.peek-advanced-toggle {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.peek-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage } from "@/types/mq";
|
||||
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
|
||||
@@ -23,6 +24,8 @@ const emit = defineEmits<{
|
||||
subscriptionSelected: [subscription: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
@@ -52,11 +55,10 @@ const skipFormData = ref({
|
||||
});
|
||||
|
||||
const expireSeconds = ref(3600);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -143,7 +145,7 @@ async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!formData.value.subName.trim() || !topicRef) {
|
||||
error.value = "Subscription name is required";
|
||||
error.value = t("mqSubscriptions.subscriptionNameRequired");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -162,7 +164,7 @@ async function handleCreate() {
|
||||
|
||||
async function handleDelete(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除订阅 "${sub.name}" 吗?此操作不可逆。`)) return;
|
||||
if (!confirm(t("mqSubscriptions.confirmDelete", { name: sub.name }))) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
@@ -220,7 +222,7 @@ async function handleSkipMessages() {
|
||||
|
||||
async function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要清空订阅 "${sub.name}" 的所有积压消息吗?`)) return;
|
||||
if (!confirm(t("mqSubscriptions.confirmClearBacklog", { name: sub.name }))) return;
|
||||
const topicRef = getTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
@@ -281,28 +283,28 @@ watch(
|
||||
<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>
|
||||
<h3>{{ t("mqSubscriptions.title") }}</h3>
|
||||
<button v-if="supportsCreateSubscription !== false" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!topic" class="panel-placeholder">请先选择一个主题</div>
|
||||
<div v-if="!topic" class="panel-placeholder">{{ t("mqSubscriptions.selectTopicFirst") }}</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="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
|
||||
<div v-else-if="!subscriptions.length" class="panel-placeholder">该主题暂无订阅</div>
|
||||
<div v-else-if="!subscriptions.length" class="panel-placeholder">{{ t("mqSubscriptions.noSubscriptions") }}</div>
|
||||
|
||||
<div v-else class="subscriptions-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订阅名称</th>
|
||||
<th>类型</th>
|
||||
<th>积压消息</th>
|
||||
<th>消费速率</th>
|
||||
<th>消费者</th>
|
||||
<th>操作</th>
|
||||
<th>{{ t("mqSubscriptions.subscriptionName") }}</th>
|
||||
<th>{{ t("mqSubscriptions.type") }}</th>
|
||||
<th>{{ t("mqSubscriptions.backlog") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumeRate") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumers") }}</th>
|
||||
<th>{{ t("mqSubscriptions.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -316,15 +318,15 @@ watch(
|
||||
{{ sub.msgBacklog.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ sub.msgRateOut.toFixed(2) }} msg/s</td>
|
||||
<td>{{ sub.consumers.length }} 个</td>
|
||||
<td>{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
|
||||
<td>{{ t("mqSubscriptions.consumerCount", { count: 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>
|
||||
<button v-if="supportsResetCursor !== false" @click.stop="openResetDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.resetCursor") }}</button>
|
||||
<button v-if="supportsSkipMessages !== false" @click.stop="openSkipDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.skipMessages") }}</button>
|
||||
<button v-if="supportsClearBacklog !== false" @click.stop="handleClearBacklog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.clearBacklog") }}</button>
|
||||
<button v-if="supportsPeekMessages" @click.stop="openPeekDialog(sub)" class="btn-sm">{{ t("mqSubscriptions.peek") }}</button>
|
||||
<button v-if="supportsExpireMessages !== false" @click.stop="openExpireDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.expireMessages") }}</button>
|
||||
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -335,36 +337,36 @@ watch(
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>创建订阅</h3>
|
||||
<h3>{{ t("mqSubscriptions.createDialogTitle") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<label>{{ t("mqSubscriptions.topic") }}</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" />
|
||||
<label>{{ t("mqSubscriptions.subscriptionNameLabel") }}</label>
|
||||
<input v-model="formData.subName" type="text" :placeholder="t('mqSubscriptions.subscriptionNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>起始位置</label>
|
||||
<label>{{ t("mqSubscriptions.startPosition") }}</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="formData.startFrom" value="earliest" :disabled="readOnly" />
|
||||
从最早消息开始(Earliest)
|
||||
{{ t("mqSubscriptions.startFromEarliest") }}
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="formData.startFrom" value="latest" :disabled="readOnly" />
|
||||
从最新消息开始(Latest)
|
||||
{{ t("mqSubscriptions.startFromLatest") }}
|
||||
</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>
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,37 +375,37 @@ watch(
|
||||
<div v-if="showResetDialog" class="dialog-overlay" @click="showResetDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>重置游标: {{ selectedSub?.name }}</h3>
|
||||
<h3>{{ t("mqSubscriptions.resetDialogTitle", { name: selectedSub?.name }) }}</h3>
|
||||
<button @click="showResetDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>重置到</label>
|
||||
<label>{{ t("mqSubscriptions.resetTo") }}</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="earliest" :disabled="readOnly" />
|
||||
最早消息(Earliest)
|
||||
{{ t("mqSubscriptions.earliest") }}
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="latest" :disabled="readOnly" />
|
||||
最新消息(Latest)
|
||||
{{ t("mqSubscriptions.latest") }}
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="resetFormData.position" value="timestamp" :disabled="readOnly" />
|
||||
指定时间戳
|
||||
{{ t("mqSubscriptions.timestamp") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resetFormData.position === 'timestamp'" class="form-group">
|
||||
<label>时间戳(毫秒)</label>
|
||||
<label>{{ t("mqSubscriptions.timestampMs") }}</label>
|
||||
<input v-model.number="resetFormData.timestampMs" type="number" :disabled="readOnly" />
|
||||
<div class="form-hint">当前时间: {{ new Date(resetFormData.timestampMs).toLocaleString() }}</div>
|
||||
<div class="form-hint">{{ t("mqSubscriptions.currentTime", { time: 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>
|
||||
<button @click="showResetDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
|
||||
<button @click="handleResetCursor" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.reset") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,32 +414,32 @@ watch(
|
||||
<div v-if="showSkipDialog" class="dialog-overlay" @click="showSkipDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>跳过消息: {{ selectedSub?.name }}</h3>
|
||||
<h3>{{ t("mqSubscriptions.skipDialogTitle", { name: selectedSub?.name }) }}</h3>
|
||||
<button @click="showSkipDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>跳过模式</label>
|
||||
<label>{{ t("mqSubscriptions.skipMode") }}</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="skipFormData.mode" value="count" :disabled="readOnly" />
|
||||
跳过指定数量
|
||||
{{ t("mqSubscriptions.skipCount") }}
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" v-model="skipFormData.mode" value="all" :disabled="readOnly" />
|
||||
跳过全部积压
|
||||
{{ t("mqSubscriptions.skipAll") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="skipFormData.mode === 'count'" class="form-group">
|
||||
<label>跳过数量</label>
|
||||
<label>{{ t("mqSubscriptions.skipCountLabel") }}</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>
|
||||
<button @click="showSkipDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
|
||||
<button @click="handleSkipMessages" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.skip") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,28 +448,28 @@ watch(
|
||||
<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>
|
||||
<h3>{{ t("mqSubscriptions.peekDialogTitle", { name: selectedSub?.name }) }}</h3>
|
||||
<button @click="showPeekDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="peek-toolbar">
|
||||
<label>
|
||||
数量
|
||||
{{ t("mqSubscriptions.count") }}
|
||||
<input v-model.number="peekCount" type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button @click="handlePeekMessages" :disabled="peekLoading" class="btn-sm">
|
||||
{{ peekLoading ? "加载中..." : "刷新" }}
|
||||
{{ peekLoading ? t("mqSubscriptions.loading") : t("mqSubscriptions.refresh") }}
|
||||
</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-if="peekLoading && !peekedMessages.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
<div v-else-if="!peekedMessages.length" class="panel-placeholder">{{ t("mqSubscriptions.noPeekMessages") }}</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>
|
||||
<span v-if="message.key">{{ t("mqSubscriptions.peekMessageKey", { 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>
|
||||
@@ -477,7 +479,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPeekDialog = false" class="btn-secondary">关闭</button>
|
||||
<button @click="showPeekDialog = false" class="btn-secondary">{{ t("mqSubscriptions.close") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,20 +488,20 @@ watch(
|
||||
<div v-if="showExpireDialog" class="dialog-overlay" @click="showExpireDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>过期消息: {{ selectedSub?.name }}</h3>
|
||||
<h3>{{ t("mqSubscriptions.expireDialogTitle", { name: selectedSub?.name }) }}</h3>
|
||||
<button @click="showExpireDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>过期时间(秒)</label>
|
||||
<label>{{ t("mqSubscriptions.expireSeconds") }}</label>
|
||||
<input v-model.number="expireSeconds" type="number" min="1" :disabled="readOnly" />
|
||||
<div class="form-hint">将删除所有早于 {{ expireSeconds }} 秒的消息</div>
|
||||
<div class="form-hint">{{ t("mqSubscriptions.expireHint", { seconds: 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>
|
||||
<button @click="showExpireDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
|
||||
<button @click="handleExpireMessages" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.expire") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { computed, ref, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { TenantInfo, TenantConfig } from "@/types/mq";
|
||||
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
|
||||
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
|
||||
@@ -17,6 +18,8 @@ const emit = defineEmits<{
|
||||
tenantSelected: [tenant: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tenants = ref<TenantInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
@@ -37,7 +40,6 @@ 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));
|
||||
@@ -46,7 +48,7 @@ const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && se
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -96,7 +98,7 @@ async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
error.value = t(validationError);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -118,7 +120,7 @@ async function handleUpdate() {
|
||||
if (!editingTenant.value) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
error.value = t(validationError);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -137,7 +139,7 @@ async function handleUpdate() {
|
||||
|
||||
async function handleDelete(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除租户 "${tenant.name}" 吗?此操作不可逆。`)) return;
|
||||
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
@@ -225,24 +227,24 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<template>
|
||||
<div class="tenants-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3>租户管理</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly" class="btn-primary">+ 创建租户</button>
|
||||
<h3>{{ t("mqTenants.title") }}</h3>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly" class="btn-primary">+ {{ t("mqTenants.createTenant") }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!supportsTenants" class="panel-placeholder">当前消息队列系统不支持租户管理</div>
|
||||
<div v-if="!supportsTenants" class="panel-placeholder">{{ t("mqTenants.notSupported") }}</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-if="loading && !tenants.length" class="panel-loading">{{ t("mqTenants.loading") }}</div>
|
||||
|
||||
<div v-else class="tenants-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>管理角色</th>
|
||||
<th>允许集群</th>
|
||||
<th>操作</th>
|
||||
<th>{{ t("mqTenants.name") }}</th>
|
||||
<th>{{ t("mqTenants.adminRoles") }}</th>
|
||||
<th>{{ t("mqTenants.allowedClusters") }}</th>
|
||||
<th>{{ t("mqTenants.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -252,17 +254,17 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<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>
|
||||
<span v-else class="text-muted">{{ t("mqTenants.none") }}</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>
|
||||
<span v-else class="text-muted">{{ t("mqTenants.none") }}</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>
|
||||
<button @click.stop="openEditDialog(tenant)" :disabled="readOnly" class="btn-sm">{{ t("mqTenants.edit") }}</button>
|
||||
<button @click.stop="handleDelete(tenant)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqTenants.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -273,19 +275,19 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>创建租户</h3>
|
||||
<h3>{{ t("mqTenants.createDialogTitle") }}</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" />
|
||||
<label>{{ t("mqTenants.tenantName") }}</label>
|
||||
<input v-model="formData.name" type="text" :placeholder="t('mqTenants.tenantNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理角色</label>
|
||||
<label>{{ t("mqTenants.adminRoles") }}</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>
|
||||
<input v-model="newRole" type="text" :placeholder="t('mqTenants.addRolePlaceholder')" :disabled="readOnly" @keyup.enter="addRole" />
|
||||
<button @click="addRole" :disabled="readOnly" class="btn-sm">{{ t("mqTenants.add") }}</button>
|
||||
</div>
|
||||
<div v-if="formData.config.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in formData.config.adminRoles" :key="role" class="tag">
|
||||
@@ -295,7 +297,7 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>允许集群*</label>
|
||||
<label>{{ t("mqTenants.allowedClusters") }}</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">
|
||||
@@ -304,7 +306,7 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<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 v-else class="cluster-placeholder">{{ normalizedClusterOptions.length ? t("mqTenants.selectClustersPlaceholder") : t("mqTenants.noClustersDetectedPlaceholder") }}</span>
|
||||
<span class="cluster-arrow">{{ clusterDropdownOpen ? "⌃" : "⌄" }}</span>
|
||||
</button>
|
||||
<div v-if="clusterDropdownOpen" class="cluster-options">
|
||||
@@ -312,20 +314,20 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<span>{{ cluster }}</span>
|
||||
<span v-if="isClusterSelected(cluster)" class="cluster-check">✓</span>
|
||||
</button>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">当前连接未返回集群列表</div>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">{{ t("mqTenants.clustersNotReturned") }}</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>
|
||||
<input v-model="newCluster" type="text" :placeholder="t('mqTenants.addClusterPlaceholder')" :disabled="readOnly" @keyup.enter="addCluster" />
|
||||
<button @click="addCluster" :disabled="readOnly" class="btn-sm">{{ t("mqTenants.add") }}</button>
|
||||
</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">至少选择或添加一个允许集群</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">{{ t("mqTenants.clustersRequiredHint") }}</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>
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTenants.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly || !canSubmitTenant" class="btn-primary">{{ t("mqTenants.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,15 +336,15 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<div v-if="showEditDialog" class="dialog-overlay" @click="showEditDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>编辑租户: {{ editingTenant?.name }}</h3>
|
||||
<h3>{{ t("mqTenants.editDialogTitle", { name: editingTenant?.name }) }}</h3>
|
||||
<button @click="showEditDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>管理角色</label>
|
||||
<label>{{ t("mqTenants.adminRoles") }}</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>
|
||||
<input v-model="newRole" type="text" :placeholder="t('mqTenants.addRolePlaceholder')" :disabled="readOnly" @keyup.enter="addRole" />
|
||||
<button @click="addRole" :disabled="readOnly" class="btn-sm">{{ t("mqTenants.add") }}</button>
|
||||
</div>
|
||||
<div v-if="formData.config.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in formData.config.adminRoles" :key="role" class="tag">
|
||||
@@ -352,7 +354,7 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>允许集群*</label>
|
||||
<label>{{ t("mqTenants.allowedClusters") }}</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">
|
||||
@@ -361,7 +363,7 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<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 v-else class="cluster-placeholder">{{ normalizedClusterOptions.length ? t("mqTenants.selectClustersPlaceholder") : t("mqTenants.noClustersDetectedPlaceholder") }}</span>
|
||||
<span class="cluster-arrow">{{ clusterDropdownOpen ? "⌃" : "⌄" }}</span>
|
||||
</button>
|
||||
<div v-if="clusterDropdownOpen" class="cluster-options">
|
||||
@@ -369,20 +371,20 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
<span>{{ cluster }}</span>
|
||||
<span v-if="isClusterSelected(cluster)" class="cluster-check">✓</span>
|
||||
</button>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">当前连接未返回集群列表</div>
|
||||
<div v-if="!normalizedClusterOptions.length" class="cluster-empty">{{ t("mqTenants.clustersNotReturned") }}</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>
|
||||
<input v-model="newCluster" type="text" :placeholder="t('mqTenants.addClusterPlaceholder')" :disabled="readOnly" @keyup.enter="addCluster" />
|
||||
<button @click="addCluster" :disabled="readOnly" class="btn-sm">{{ t("mqTenants.add") }}</button>
|
||||
</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">至少选择或添加一个允许集群</div>
|
||||
<div v-if="!selectedAllowedClusters.length" class="form-hint form-hint-error">{{ t("mqTenants.clustersRequiredHint") }}</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>
|
||||
<button @click="showEditDialog = false" class="btn-secondary">{{ t("mqTenants.cancel") }}</button>
|
||||
<button @click="handleUpdate" :disabled="loading || readOnly || !canSubmitTenant" class="btn-primary">{{ t("mqTenants.save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user