feat(mq): improve Kafka peek and admin localization

This commit is contained in:
Freedom 2026-07-16 18:20:27 +08:00 committed by GitHub
parent a9e76dc130
commit 68b1777aa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 4561 additions and 636 deletions

View File

@ -793,9 +793,9 @@ public final class KafkaAgent {
private static Object peekMessages(JsonObject params) throws Exception {
String topic = stringOrEmpty(params, "topic");
int partition = intOrDefault(params, "partition", 0);
long offset = longOrDefault(params, "offset", 0);
int count = intOrDefault(params, "count", 10);
Integer partition = integerOrNull(params, "partition");
Long offset = longOrNull(params, "offset");
int count = Math.max(1, intOrDefault(params, "count", 10));
// Build a temporary consumer for peeking (no commit)
Properties props = new Properties();
@ -818,52 +818,196 @@ public final class KafkaAgent {
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "none");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, count);
TopicPartition tp = new TopicPartition(topic, partition);
try (KafkaConsumer<String, byte[]> consumer = new KafkaConsumer<>(props)) {
consumer.assign(Collections.singletonList(tp));
Map<TopicPartition, Long> beginningOffsets =
consumer.beginningOffsets(Collections.singletonList(tp), Duration.ofSeconds(5));
Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(Collections.singletonList(tp), Duration.ofSeconds(5));
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
Long seekOffset = normalizePeekOffset(offset, beginningOffset, endOffset);
if (seekOffset == null) {
List<TopicPartition> candidatePartitions = resolvePeekPartitions(consumer, topic, partition);
if (candidatePartitions.isEmpty()) {
return Collections.singletonMap("messages", Collections.emptyList());
}
consumer.seek(tp, seekOffset);
List<Map<String, Object>> messages = new ArrayList<>();
ConsumerRecords<String, byte[]> records = consumer.poll(Duration.ofSeconds(5));
for (ConsumerRecord<String, byte[]> record : records) {
if (messages.size() >= count) break;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("topic", record.topic());
msg.put("partition", record.partition());
msg.put("offset", record.offset());
msg.put("timestamp", record.timestamp());
msg.put("key", record.key());
// Headers
Map<String, String> headers = new LinkedHashMap<>();
record.headers().forEach(h ->
headers.put(h.key(), new String(h.value(), StandardCharsets.UTF_8)));
msg.put("headers", headers);
// Payload
if (record.value() != null) {
msg.put("payloadBase64", Base64.getEncoder().encodeToString(record.value()));
String text = tryDecodeUtf8(record.value());
if (text != null) {
msg.put("payloadText", text);
}
} else {
msg.put("payloadBase64", "");
Map<TopicPartition, Long> beginningOffsets =
consumer.beginningOffsets(candidatePartitions, Duration.ofSeconds(5));
Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(candidatePartitions, Duration.ofSeconds(5));
List<TopicPartition> readablePartitions = new ArrayList<>();
Map<TopicPartition, Long> seekOffsets = new LinkedHashMap<>();
for (TopicPartition tp : candidatePartitions) {
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
long requestedOffset = offset != null ? offset : beginningOffset;
Long seekOffset = normalizePeekOffset(requestedOffset, beginningOffset, endOffset);
if (seekOffset == null) {
continue;
}
messages.add(msg);
readablePartitions.add(tp);
seekOffsets.put(tp, seekOffset);
}
if (readablePartitions.isEmpty()) {
return Collections.singletonMap("messages", Collections.emptyList());
}
consumer.assign(readablePartitions);
for (Map.Entry<TopicPartition, Long> entry : seekOffsets.entrySet()) {
consumer.seek(entry.getKey(), entry.getValue());
}
List<Map<String, Object>> messages = collectPeekedMessages(
timeout -> consumer.poll(timeout),
() -> {
Map<TopicPartition, Long> positions = new LinkedHashMap<>();
for (TopicPartition tp : readablePartitions) {
positions.put(tp, consumer.position(tp));
}
return allPeekPartitionsCaughtUp(readablePartitions, positions, endOffsets);
},
count,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(500)
);
sortPeekedMessages(messages);
if (messages.size() > count) {
messages = new ArrayList<>(messages.subList(0, count));
}
return Collections.singletonMap("messages", messages);
}
}
/**
* Poll until {@code count} messages are collected, every assigned partition has reached its
* end offset, or {@code deadlineNs} expires. Empty polls retry until caught-up or deadline
* they must not abort early (broker / network / first-fetch latency can exceed one poll).
*/
static List<Map<String, Object>> collectPeekedMessages(
PeekRecordPoller poller,
PeekCaughtUpChecker caughtUpChecker,
int count,
long deadlineNs,
Duration pollTimeout
) {
List<Map<String, Object>> messages = new ArrayList<>();
while (messages.size() < count && System.nanoTime() < deadlineNs) {
long remainingNs = deadlineNs - System.nanoTime();
if (remainingNs <= 0) {
break;
}
Duration timeout = pollTimeout.toNanos() > remainingNs
? Duration.ofNanos(remainingNs)
: pollTimeout;
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
if (records.isEmpty()) {
if (caughtUpChecker.allPartitionsCaughtUp()) {
break;
}
continue;
}
for (ConsumerRecord<String, byte[]> record : records) {
messages.add(peekedMessageFromRecord(record));
if (messages.size() >= count) {
break;
}
}
}
return messages;
}
static boolean allPeekPartitionsCaughtUp(
List<TopicPartition> partitions,
Map<TopicPartition, Long> positions,
Map<TopicPartition, Long> endOffsets
) {
for (TopicPartition tp : partitions) {
long endOffset = endOffsets.getOrDefault(tp, 0L);
long position = positions.getOrDefault(tp, 0L);
if (position < endOffset) {
return false;
}
}
return true;
}
@FunctionalInterface
interface PeekRecordPoller {
ConsumerRecords<String, byte[]> poll(Duration timeout);
}
@FunctionalInterface
interface PeekCaughtUpChecker {
boolean allPartitionsCaughtUp();
}
/** When partition is null, peek across every partition of the topic. */
static List<TopicPartition> resolvePeekPartitions(
KafkaConsumer<String, byte[]> consumer,
String topic,
Integer partition
) {
if (partition != null) {
return resolvePeekPartitions(topic, partition, Collections.emptyList());
}
List<PartitionInfo> infos = consumer.partitionsFor(topic, Duration.ofSeconds(5));
if (infos == null || infos.isEmpty()) {
return Collections.emptyList();
}
List<Integer> available = infos.stream().map(PartitionInfo::partition).collect(Collectors.toList());
return resolvePeekPartitions(topic, null, available);
}
static List<TopicPartition> resolvePeekPartitions(String topic, Integer partition, List<Integer> availablePartitions) {
if (partition != null) {
return Collections.singletonList(new TopicPartition(topic, partition));
}
if (availablePartitions == null || availablePartitions.isEmpty()) {
return Collections.emptyList();
}
return availablePartitions.stream()
.sorted()
.map(id -> new TopicPartition(topic, id))
.collect(Collectors.toList());
}
static void sortPeekedMessages(List<Map<String, Object>> messages) {
messages.sort((left, right) -> {
long leftTs = ((Number) left.getOrDefault("timestamp", 0L)).longValue();
long rightTs = ((Number) right.getOrDefault("timestamp", 0L)).longValue();
int byTs = Long.compare(leftTs, rightTs);
if (byTs != 0) {
return byTs;
}
int leftPartition = ((Number) left.getOrDefault("partition", 0)).intValue();
int rightPartition = ((Number) right.getOrDefault("partition", 0)).intValue();
int byPartition = Integer.compare(leftPartition, rightPartition);
if (byPartition != 0) {
return byPartition;
}
long leftOffset = ((Number) left.getOrDefault("offset", 0L)).longValue();
long rightOffset = ((Number) right.getOrDefault("offset", 0L)).longValue();
return Long.compare(leftOffset, rightOffset);
});
}
private static Map<String, Object> peekedMessageFromRecord(ConsumerRecord<String, byte[]> record) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("topic", record.topic());
msg.put("partition", record.partition());
msg.put("offset", record.offset());
msg.put("timestamp", record.timestamp());
msg.put("key", record.key());
Map<String, String> headers = new LinkedHashMap<>();
record.headers().forEach(h ->
headers.put(h.key(), new String(h.value(), StandardCharsets.UTF_8)));
msg.put("headers", headers);
if (record.value() != null) {
msg.put("payloadBase64", Base64.getEncoder().encodeToString(record.value()));
String text = tryDecodeUtf8(record.value());
if (text != null) {
msg.put("payloadText", text);
}
} else {
msg.put("payloadBase64", "");
}
return msg;
}
private static Object sendMessage(JsonObject params) throws Exception {
if (producer == null) {
throw new IllegalStateException("Producer is not initialized. Call connect first.");
@ -1229,14 +1373,24 @@ public final class KafkaAgent {
return value == null ? fallback : value;
}
private static int intOrDefault(JsonObject object, String key, int fallback) {
private static Integer integerOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? fallback : element.getAsInt();
return element == null || element.isJsonNull() ? null : element.getAsInt();
}
private static Long longOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? null : element.getAsLong();
}
private static int intOrDefault(JsonObject object, String key, int fallback) {
Integer value = integerOrNull(object, key);
return value == null ? fallback : value;
}
private static long longOrDefault(JsonObject object, String key, long fallback) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? fallback : element.getAsLong();
Long value = longOrNull(object, key);
return value == null ? fallback : value;
}
private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) {

View File

@ -1,11 +1,21 @@
package com.dbx.agent.kafka;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.Test;
class KafkaAgentTest {
@ -34,6 +44,99 @@ class KafkaAgentTest {
assertNull(KafkaAgent.normalizePeekOffset(0, 5, 5));
}
@Test
void resolvePeekPartitionsUsesSinglePartitionWhenSpecified() {
var partitions = KafkaAgent.resolvePeekPartitions("events", 2, List.of(0, 1, 2));
assertEquals(1, partitions.size());
assertEquals(2, partitions.get(0).partition());
assertEquals("events", partitions.get(0).topic());
}
@Test
void resolvePeekPartitionsUsesAllPartitionsWhenUnspecified() {
var partitions = KafkaAgent.resolvePeekPartitions("events", null, List.of(2, 0, 1));
assertEquals(List.of(0, 1, 2), partitions.stream().map(org.apache.kafka.common.TopicPartition::partition).toList());
}
@Test
void sortPeekedMessagesOrdersByTimestampThenPartitionThenOffset() {
var messages = new java.util.ArrayList<Map<String, Object>>();
messages.add(Map.of("timestamp", 20L, "partition", 1, "offset", 1L));
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 5L));
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 2L));
messages.add(Map.of("timestamp", 10L, "partition", 1, "offset", 0L));
KafkaAgent.sortPeekedMessages(messages);
assertEquals(2L, messages.get(0).get("offset"));
assertEquals(5L, messages.get(1).get("offset"));
assertEquals(1, messages.get(2).get("partition"));
assertEquals(20L, messages.get(3).get("timestamp"));
}
@Test
void allPeekPartitionsCaughtUpRequiresEveryPartitionAtEndOffset() {
TopicPartition p0 = new TopicPartition("events", 0);
TopicPartition p1 = new TopicPartition("events", 1);
Map<TopicPartition, Long> endOffsets = Map.of(p0, 10L, p1, 5L);
assertFalse(KafkaAgent.allPeekPartitionsCaughtUp(
List.of(p0, p1),
Map.of(p0, 10L, p1, 4L),
endOffsets
));
assertTrue(KafkaAgent.allPeekPartitionsCaughtUp(
List.of(p0, p1),
Map.of(p0, 10L, p1, 5L),
endOffsets
));
}
@Test
void collectPeekedMessagesRetriesAfterEmptyFirstPoll() {
TopicPartition tp = new TopicPartition("events", 0);
ConsumerRecord<String, byte[]> record = new ConsumerRecord<>(
"events",
0,
7L,
"k",
"hello".getBytes(StandardCharsets.UTF_8)
);
Map<TopicPartition, List<ConsumerRecord<String, byte[]>>> batch = new HashMap<>();
batch.put(tp, List.of(record));
ConsumerRecords<String, byte[]> withData = new ConsumerRecords<>(batch);
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> polls.getAndIncrement() == 0 ? ConsumerRecords.empty() : withData,
() -> false,
1,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(2, polls.get());
assertEquals(1, messages.size());
assertEquals(7L, messages.get(0).get("offset"));
assertEquals("hello", messages.get(0).get("payloadText"));
}
@Test
void collectPeekedMessagesStopsOnEmptyPollWhenCaughtUp() {
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> {
polls.incrementAndGet();
return ConsumerRecords.empty();
},
() -> true,
10,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(1, polls.get());
assertTrue(messages.isEmpty());
}
@Test
void appliesKerberosKafkaProperties() {
Properties props = new Properties();

View File

@ -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 使用 HS256PRIVATE 使用 RS256密钥会走连接 secret 存储</p>
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.mqTokenSigningHint") }}</p>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">当前连接为只读模式仅允许 GETHEAD 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>

View File

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

View File

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

View File

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

View File

@ -322,6 +322,47 @@ export default {
kafkaKerberosKrb5ConfPlaceholder: "Optional path on DBX Agent machine, e.g. /etc/krb5.conf",
kafkaKerberosPathHint: "The keytab and krb5.conf paths are read by DBX Agent and must exist on the machine running DBX Agent; files are not uploaded from this browser.",
kafkaKerberosAuthHint: "Uses GSSAPI + keytab login. If the server requires encrypted transport, set Security to SASL_SSL; otherwise Auto or SASL_PLAINTEXT can be used.",
mqSystem: "System",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka bootstrap servers are required",
mqBootstrapServersInvalid: "Kafka bootstrap servers are invalid",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "MQ Admin URL is required",
mqAdminUrlInvalid: "MQ Admin URL is invalid",
mqAuth: "Auth",
mqAuthNone: "None",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "OAuth2 auth requires an issuer URL",
mqOauthClientIdRequired: "OAuth2 auth requires a client ID",
mqOauthClientSecretRequired: "OAuth2 auth requires a client secret",
mqTls: "TLS",
mqTlsSkipVerify: "Skip certificate verification",
mqPinnedVersion: "Pinned Version",
mqTokenSigning: "Broker token signing",
mqTokenSigningNone: "Not configured",
mqTokenSigningKey: "Signing key",
mqTokenSigningKeyRequired: "Broker token signing key is required",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Choose based on the broker jwt.broker.token.mode: SECRET uses HS256, PRIVATE uses RS256. The key is stored with connection secrets.",
searchDatabasePlaceholder: "Search database types",
jdbcConnection: "JDBC connection",
iconView: "Icon view",
@ -3957,6 +3998,472 @@ export default {
partitionMustIncrease: "New partition count must be greater than the current partition count.",
confirmDelete: 'Delete topic "{name}"? This action cannot be undone.',
},
mqAdmin: {
viewTenant: "View tenant",
viewNamespace: "View namespace",
viewTopic: "View topic",
readOnly: "Read-only",
tabTenants: "Tenants",
tabNamespaces: "Namespaces",
tabTopics: "Topics",
tabSubscriptions: "Subscriptions",
tabMonitoring: "Monitoring",
tabClients: "Clients",
tabMessages: "Messages",
tabBroker: "Broker",
tabPolicies: "Policies",
tabPermissions: "Permissions",
tabRawApi: "Raw API",
},
mqMessages: {
title: "Send Message",
clear: "Clear",
selectNamespaceOrTopicFirst: "Select a namespace or topic first",
readOnlyCannotSend: "This connection is read-only and cannot send messages",
selectTargetTopic: "Select a target topic",
messageContentRequired: "Message content is required",
sendSuccess: "Message sent — partition: {partition}, offset: {offset}",
targetTopic: "Target topic",
topicLoading: "Loading...",
topicSearchPlaceholder: "Type or search topics...",
refreshTopicList: "Refresh topic list",
topicOptionWithPartitions: "{label} ({partitions} partitions)",
noTopicsAvailable: "No topics available",
topicSearchHint: "Search by keyword, or paste a topic name.",
messageKey: "Message key",
optional: "Optional",
messageContent: "Message content",
formatJson: "Format JSON",
messageHeaders: "Headers",
headersPlaceholder: "key: value (one per line)",
sending: "Sending...",
sendMessage: "Send message",
messageList: "Messages",
loadMessages: "Load messages",
loading: "Loading...",
peekDefaultHint: "Without a partition, reads all partitions from earliest, up to {count} messages.",
count: "Count",
advancedFilter: "Advanced (partition / offset)",
partition: "Partition",
partitionPlaceholderAll: "Empty = all",
offset: "Offset",
offsetPlaceholderEarliest: "Empty = earliest",
messagesLoading: "Loading messages...",
noMessages: "No messages",
selectTopicBeforeLoad: "Select a topic before loading messages",
partitionMustBeNonNegativeInt: "Partition must be an integer ≥ 0; leave empty for all partitions",
offsetMustBeNonNegativeInt: "Offset must be an integer ≥ 0; leave empty for earliest",
metaPartition: "partition {partition}",
metaOffset: "offset {offset}",
metaKey: "key {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "Monitoring",
autoRefresh: "Auto refresh",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Refreshing...",
refreshNow: "Refresh",
selectTopicFirst: "Select a topic first",
loadingStats: "Loading monitoring data...",
kafkaTopicOverview: "Kafka topic overview",
partitionCount: "Partitions",
replicationFactor: "Replication factor",
messageCount: "Messages",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offsets & replicas",
beginOffset: "Begin offset",
leaderCount: "Leaders",
isrHealthyPartitions: "ISR healthy",
noLeaderPartitions: "No leader",
kafkaPartitionDetails: "Kafka partitions",
noKafkaPartitionMetrics: "No partition metrics in the Kafka response",
tablePartition: "Partition",
tableBeginOffset: "Begin offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "Messages",
tableLeader: "Leader",
tableReplicas: "Replicas",
tableIsr: "ISR",
tableStatus: "Status",
statusNoLeader: "No leader",
statusIsrIncomplete: "ISR incomplete",
statusHealthy: "Healthy",
kafkaMessageQuery: "Kafka message query",
querying: "Querying...",
queryMessages: "Query",
queryHint: "Omit PARTITION / OFFSET to read all partitions (max 100). Add them to narrow the range.",
sqlSyntaxError: 'Only SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] is supported',
messagesLoading: "Loading messages...",
noMessages: "No messages",
messageRate: "Message rate",
inboundRate: "Inbound rate",
outboundRate: "Outbound rate",
inboundThroughput: "Inbound throughput",
outboundThroughput: "Outbound throughput",
rateTrend: "Rate trend",
backlogTrend: "Backlog trend",
consumerLag: "Consumer lag",
storageAndBacklog: "Storage & backlog",
storageSize: "Storage size",
backlogSize: "Backlog size",
backlogMessageCount: "Backlog messages",
messageCounters: "Message counters",
publishedMessages: "Published",
consumedMessages: "Consumed",
connectionStats: "Connections",
subscriptionCount: "Subscriptions",
producerCount: "Producers",
partitionDetails: "Partitions",
tableInbound: "In",
tableOutbound: "Out",
tableInboundThroughput: "In throughput",
tableOutboundThroughput: "Out throughput",
tableBacklogMessages: "Backlog msgs",
tableBacklogSize: "Backlog size",
tableProducers: "Producers",
tableSubscriptions: "Subscriptions",
producers: "Producers",
subscriptions: "Subscriptions",
tableName: "Name",
tableRate: "Rate",
tableAddress: "Address",
tableType: "Type",
tableBacklog: "Backlog",
tableConsumers: "Consumers",
noProducers: "No producers",
noSubscriptions: "No subscriptions",
noPartitionMetricsFromBroker: "No partition metrics from broker",
nonPartitionedTopicNoDetails: "Non-partitioned topics have no partition details",
healthIndicators: "Health",
messageFlow: "Message flow",
backlogStatus: "Backlog",
producersLabel: "Producers",
subscriptionsLabel: "Subscriptions",
flowActive: "Active",
flowIdle: "Idle",
backlogNormal: "Normal",
backlogHigh: "High",
producerConnected: "Connected",
producerDisconnected: "None",
subscriptionActive: "Active",
subscriptionNone: "None",
chartLegendIn: "In",
chartLegendOut: "Out",
chartLegendMessages: "Messages",
chartLegendBytes: "Bytes",
chartLegendConsumerLag: "Consumer lag",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "msg",
chartAxisBytes: "bytes",
chartAxisMs: "ms",
metaPartition: "partition {partition}",
metaOffset: "offset {offset}",
metaKey: "key {key}",
},
mqTenants: {
title: "Tenant Management",
createTenant: "Create Tenant",
notSupported: "Tenant management is not supported for this messaging system",
readOnly: "This connection is read-only and cannot perform write operations.",
loading: "Loading...",
name: "Name",
adminRoles: "Admin Roles",
allowedClusters: "Allowed Clusters*",
actions: "Actions",
none: "None",
edit: "Edit",
delete: "Delete",
createDialogTitle: "Create Tenant",
editDialogTitle: "Edit Tenant: {name}",
tenantName: "Tenant name*",
tenantNamePlaceholder: "e.g. my-tenant",
addRolePlaceholder: "Add role",
add: "Add",
selectClustersPlaceholder: "Select allowed clusters",
noClustersDetectedPlaceholder: "No clusters detected; add manually",
clustersNotReturned: "Connection did not return a cluster list",
addClusterPlaceholder: "Add cluster manually",
clustersRequiredHint: "Select or add at least one allowed cluster",
cancel: "Cancel",
create: "Create",
save: "Save",
confirmDelete: 'Delete tenant "{name}"? This action cannot be undone.',
tenantNameRequired: "Tenant name is required",
allowedClustersRequired: "Allowed clusters are required",
},
mqNamespaces: {
title: "Namespace Management",
createNamespace: "Create Namespace",
notSupported: "Namespace management is not supported for this messaging system",
selectTenantFirst: "Select a tenant first",
readOnly: "This connection is read-only and cannot perform write operations.",
loading: "Loading...",
name: "Name",
tenant: "Tenant",
adminRoles: "Admin Roles",
actions: "Actions",
none: "None",
editRoles: "Edit Roles",
delete: "Delete",
createDialogTitle: "Create Namespace",
namespaceName: "Namespace name*",
namespaceNamePlaceholder: "e.g. my-namespace",
cancel: "Cancel",
create: "Create",
confirmDelete: 'Delete namespace "{name}"? This action cannot be undone.',
namespaceNameRequired: "Namespace name is required",
},
mqSubscriptions: {
title: "Subscription Management",
createSubscription: "Create Subscription",
selectTopicFirst: "Select a topic first",
readOnly: "This connection is read-only and cannot perform write operations.",
loading: "Loading...",
noSubscriptions: "No subscriptions for this topic",
subscriptionName: "Subscription name",
type: "Type",
backlog: "Backlog",
consumeRate: "Consume rate",
consumers: "Consumers",
actions: "Actions",
consumerCount: "{count} consumers",
msgRate: "{rate} msg/s",
resetCursor: "Reset cursor",
skipMessages: "Skip messages",
clearBacklog: "Clear backlog",
peek: "Peek",
expireMessages: "Expire messages",
delete: "Delete",
createDialogTitle: "Create Subscription",
topic: "Topic",
subscriptionNameLabel: "Subscription name*",
subscriptionNamePlaceholder: "e.g. my-subscription",
startPosition: "Start position",
startFromEarliest: "From earliest message (Earliest)",
startFromLatest: "From latest message (Latest)",
resetDialogTitle: "Reset cursor: {name}",
resetTo: "Reset to",
earliest: "Earliest message (Earliest)",
latest: "Latest message (Latest)",
timestamp: "Specific timestamp",
timestampMs: "Timestamp (ms)",
currentTime: "Current time: {time}",
reset: "Reset",
skipDialogTitle: "Skip messages: {name}",
skipMode: "Skip mode",
skipCount: "Skip specified count",
skipAll: "Skip all backlog",
skipCountLabel: "Skip count",
skip: "Skip",
peekDialogTitle: "Peek messages: {name}",
count: "Count",
refresh: "Refresh",
noPeekMessages: "No messages to view",
close: "Close",
expireDialogTitle: "Expire messages: {name}",
expireSeconds: "Expire time (seconds)",
expireHint: "Will delete all messages older than {seconds} seconds",
expire: "Expire",
cancel: "Cancel",
create: "Create",
confirmDelete: 'Delete subscription "{name}"? This action cannot be undone.',
confirmClearBacklog: 'Clear all backlog messages for subscription "{name}"?',
subscriptionNameRequired: "Subscription name is required",
peekMessageKey: "key={key}",
},
mqClients: {
title: "Producers / Consumers",
unloadTopic: "Unload topic",
unloading: "Unloading...",
refresh: "Refresh",
refreshing: "Refreshing...",
selectTopicFirst: "Select a topic first",
confirmUnload: "Unload this topic? Active producers and consumers will reconnect.",
aggregateTopic: "Aggregate topic",
kafkaPartitionStatus: "Kafka partition status",
partitionCount: "{count} partitions",
partition: "Partition",
beginOffset: "Begin offset",
latestOffset: "Latest offset",
messageCount: "Messages",
leader: "Leader",
replicas: "Replicas",
isr: "ISR",
partitionClients: "Partition clients",
inboundRate: "Inbound rate",
outboundRate: "Outbound rate",
producers: "Producers",
subscriptions: "Subscriptions",
consumers: "Consumers",
activeProducers: "Active producers",
noActiveProducers: "No active producers",
name: "Name",
id: "ID",
address: "Address",
version: "Version",
rate: "Rate",
throughput: "Throughput",
rateValue: "{value} msg/s",
activeConsumers: "Active consumers",
noSubscriptions: "This topic has no subscriptions",
noConsumersOnPartition: "No active consumers on this partition subscription",
noConsumersOnSubscription: "No active consumers on this subscription",
permits: "Permits",
},
mqPolicies: {
title: "Policy management",
refresh: "Refresh",
refreshing: "Refreshing...",
selectTopicFirst: "Select a topic first",
selectNamespaceOrTopic: "Select a namespace or topic",
readOnly: "This connection is read-only and cannot perform write operations.",
readonlyHint: "This connection is read-only; policy editing is disabled.",
publishRate: "Publish rate limit",
msgsPerSecond: "Messages / second",
bytesPerSecond: "Bytes / second",
save: "Save",
dispatchRate: "Dispatch rate limit",
msgsPerPeriod: "Messages / period",
bytesPerPeriod: "Bytes / period",
periodSeconds: "Period (seconds)",
subscribeRate: "Subscribe rate limit",
msgsPerConsumerPerPeriod: "Messages per consumer / period",
backlogQuota: "Backlog quota",
sizeLimitBytes: "Size limit (bytes)",
timeLimitSeconds: "Time limit (seconds)",
policy: "Policy",
type: "Type",
backlogPolicyProducerRequestHold: "Hold producer requests",
backlogPolicyProducerException: "Reject with exception",
backlogPolicyConsumerBacklogEviction: "Evict consumer backlog",
retention: "Message retention",
retentionTimeMinutes: "Retention time (minutes)",
retentionSizeMb: "Retention size (MB)",
effectivePolicies: "Effective policies",
savedNotice: "{policy} saved",
},
mqPermissions: {
title: "Permissions",
refresh: "Refresh",
refreshing: "Refreshing...",
selectNamespaceOrTopic: "Select a namespace or topic first",
readOnly: "This connection is read-only and cannot perform write operations.",
readonlyHint: "This connection is read-only; grant and revoke are disabled.",
grantRole: "Grant role",
roleName: "Role name",
roleNamePlaceholder: "e.g. app-producer",
actions: "Actions",
grant: "Grant",
currentPermissions: "Current permissions",
role: "Role",
operations: "Operations",
token: "Token",
revoke: "Revoke",
noPermissions: "No permission records",
clientTokenTitle: "Client token: {role}",
missingSigningKeyTitle: "Token signing key not configured",
missingSigningKeyMessage: "This MQ connection has no broker token signing key configured, so client tokens cannot be issued.",
missingSigningKeyDetail: 'Edit the connection and set "Broker token signing" to HS256 SECRET or RS256 PRIVATE, then provide the signing key before generating tokens.',
tokenShowOnceWarning: "The token is shown only once. Copy and store it immediately.",
copyToken: "Copy token",
issueNewToken: "Issue new token",
neverExpires: "Never expires",
expiryDays: "Expiry (days)",
note: "Note",
notePlaceholder: "e.g. for rt-erp-server",
issuing: "Issuing...",
generateToken: "Generate token",
tokenRevokeHint: "Revoking role permissions does not invalidate issued JWTs immediately; wait for expiry or rotate the broker signing key.",
issueRecords: "Issue records",
time: "Time",
algorithm: "Algorithm",
expires: "Expires",
fingerprint: "Fingerprint",
noteColumn: "Note",
unlimited: "Permanent",
loading: "Loading...",
noIssueRecords: "No issue records",
close: "Close",
kafkaAuthorizerNotConfigured: "This Kafka cluster has no authorizer configured. Add authorizer.class.name in broker config for ACL support.",
kafkaAuthorizerDisabled: "This Kafka cluster has no authorizer enabled. Enable an authorizer in broker config to grant permissions.",
roleNameRequired: "Role name is required",
selectAtLeastOneAction: "Select at least one action",
grantedRole: "Granted {role}",
confirmRevoke: 'Revoke permissions for role "{role}"?',
revokedRole: "Revoked {role}",
roleNameEmpty: "Role name cannot be empty",
expiryMustBePositive: "Expiry must be greater than 0 days",
},
mqBroker: {
title: "Broker cluster",
autoRefresh: "Auto refresh",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Refreshing...",
refreshNow: "Refresh now",
loading: "Loading...",
clusterOverview: "Cluster overview",
clusterId: "Cluster ID",
unknown: "Unknown",
brokerCount: "Brokers",
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker nodes",
nodeId: "Node ID",
host: "Host",
port: "Port",
rack: "Rack",
role: "Role",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "No broker node information",
},
mqRaw: {
title: "Raw API",
sending: "Sending...",
sendRequest: "Send request",
readOnlyHint: "This connection is read-only; only GET, HEAD, and OPTIONS requests are allowed.",
commonEndpoints: "Common endpoints",
fillFromSelection: "Fill request from current selection",
expand: "Expand",
collapse: "Collapse",
method: "Method",
path: "Path",
pathPlaceholder: "/admin/v2/...",
queryParams: "Query parameters",
queryPlaceholder: "key=value, one per line",
jsonBody: "JSON Body",
format: "Format",
bodyPlaceholder: "e.g. {'{'}\"key\":\"value\"{'}'}",
response: "Response",
textResponse: "Text response",
noRequestYet: "No request sent yet",
readOnly: "This connection is read-only and cannot perform write operations.",
pathRequired: "Request path is required",
jsonBodyInvalid: "Invalid JSON body: {error}",
presetBrokerVersion: "Broker version",
presetBrokerVersionDesc: "Broker version string",
presetClusters: "Cluster list",
presetClustersDesc: "List configured Pulsar clusters",
presetTenant: "Tenant details",
presetTenantDesc: "View adminRoles / allowedClusters",
presetNamespacePolicies: "Namespace policies",
presetNamespacePoliciesDesc: "Raw namespace policies structure",
presetBundles: "Bundle list",
presetBundlesDesc: "Namespace bundle distribution",
presetTopicInternalStats: "Topic internal stats",
presetTopicInternalStatsDesc: "Ledger / cursor / backlog details",
presetPartitionedStats: "Partition metrics",
presetPartitionedStatsDesc: "Per-partition rates, producers, and consumers",
presetSchema: "Latest schema",
presetSchemaDesc: "Current topic schema definition",
},
nacos: {
configs: "Configs",
services: "Services",

View File

@ -450,6 +450,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opcional, ruta en la máquina donde se ejecuta DBX Agent, por ejemplo /etc/krb5.conf",
kafkaKerberosPathHint: "Las rutas de keytab y krb5.conf son leídas por DBX Agent y deben existir en la máquina donde se ejecuta DBX Agent; no se subirán archivos desde el navegador actual.",
kafkaKerberosAuthHint: "Iniciar sesión con GSSAPI + keytab. Si el servidor requiere transmisión cifrada, configure Security como SASL_SSL; de lo contrario, puede usar Auto o SASL_PLAINTEXT.",
mqSystem: "Sistema",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Los bootstrap servers de Kafka son obligatorios",
mqBootstrapServersInvalid: "Los bootstrap servers de Kafka no son válidos",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "La Admin URL de MQ es obligatoria",
mqAdminUrlInvalid: "La Admin URL de MQ no es válida",
mqAuth: "Autenticación",
mqAuthNone: "Ninguna",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "La autenticación OAuth2 requiere una Issuer URL",
mqOauthClientIdRequired: "La autenticación OAuth2 requiere un Client ID",
mqOauthClientSecretRequired: "La autenticación OAuth2 requiere un Client Secret",
mqTls: "TLS",
mqTlsSkipVerify: "Omitir verificación de certificado",
mqPinnedVersion: "Versión fijada",
mqTokenSigning: "Firma de token del Broker",
mqTokenSigningNone: "Sin configurar",
mqTokenSigningKey: "Clave de firma",
mqTokenSigningKeyRequired: "La clave de firma del token del Broker es obligatoria",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Elija según jwt.broker.token.mode del broker: SECRET usa HS256, PRIVATE usa RS256. La clave se almacena en los secretos de la conexión.",
databaseInfo: {
title: "Información de la base de datos",
open: "Abrir información de la base de datos {database}",
@ -3866,7 +3907,7 @@ export default withEnglishFallback({
},
mqTopics: {
title: "Administración de temas",
searchPlaceholder: "Buscar topic",
searchPlaceholder: "Buscar temas",
includeNonPersistent: "Incluir temas no persistentes",
refresh: "Actualizar",
refreshing: "Actualizando...",
@ -3907,6 +3948,472 @@ export default withEnglishFallback({
partitionMustIncrease: "El nuevo número de particiones debe ser mayor que el actual.",
confirmDelete: '¿Está seguro de que desea eliminar el tema "{name}"? Esta operación no se puede deshacer.',
},
mqAdmin: {
viewTenant: "Ver tenant",
viewNamespace: "Ver namespace",
viewTopic: "Ver tema",
readOnly: "Solo lectura",
tabTenants: "Tenants",
tabNamespaces: "Namespaces",
tabTopics: "Temas",
tabSubscriptions: "Suscripciones",
tabMonitoring: "Monitoreo",
tabClients: "Clientes",
tabMessages: "Mensajes",
tabBroker: "Broker",
tabPolicies: "Políticas",
tabPermissions: "Permisos",
tabRawApi: "Raw API",
},
mqMessages: {
title: "Enviar mensaje",
clear: "Limpiar",
selectNamespaceOrTopicFirst: "Seleccione primero un namespace o un tema",
readOnlyCannotSend: "Esta conexión es de solo lectura y no puede enviar mensajes",
selectTargetTopic: "Seleccione un tema de destino",
messageContentRequired: "El contenido del mensaje es obligatorio",
sendSuccess: "Mensaje enviado — partición: {partition}, offset: {offset}",
targetTopic: "Tema de destino",
topicLoading: "Cargando...",
topicSearchPlaceholder: "Escriba o busque temas...",
refreshTopicList: "Actualizar lista de temas",
topicOptionWithPartitions: "{label} ({partitions} particiones)",
noTopicsAvailable: "No hay temas disponibles",
topicSearchHint: "Busque por palabra clave o pegue un nombre de topic.",
messageKey: "Clave del mensaje",
optional: "Opcional",
messageContent: "Contenido del mensaje",
formatJson: "Formatear JSON",
messageHeaders: "Encabezados",
headersPlaceholder: "clave: valor (una por línea)",
sending: "Enviando...",
sendMessage: "Enviar mensaje",
messageList: "Mensajes",
loadMessages: "Cargar mensajes",
loading: "Cargando...",
peekDefaultHint: "Sin partición, lee todas las particiones desde earliest, hasta {count} mensajes.",
count: "Cantidad",
advancedFilter: "Avanzado (partición / offset)",
partition: "Partición",
partitionPlaceholderAll: "Vacío = todas",
offset: "Offset",
offsetPlaceholderEarliest: "Vacío = earliest",
messagesLoading: "Cargando mensajes...",
noMessages: "No hay mensajes",
selectTopicBeforeLoad: "Seleccione un tema antes de cargar mensajes",
partitionMustBeNonNegativeInt: "La partición debe ser un entero ≥ 0; déjelo vacío para todas las particiones",
offsetMustBeNonNegativeInt: "El offset debe ser un entero ≥ 0; déjelo vacío para earliest",
metaPartition: "partición {partition}",
metaOffset: "offset {offset}",
metaKey: "clave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "Monitoreo",
autoRefresh: "Actualización automática",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Actualizando...",
refreshNow: "Actualizar",
selectTopicFirst: "Seleccione primero un tema",
loadingStats: "Cargando datos de monitoreo...",
kafkaTopicOverview: "Resumen del topic de Kafka",
partitionCount: "Particiones",
replicationFactor: "Factor de replicación",
messageCount: "Mensajes",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offsets y réplicas",
beginOffset: "Begin offset",
leaderCount: "Leaders",
isrHealthyPartitions: "ISR saludable",
noLeaderPartitions: "Sin leader",
kafkaPartitionDetails: "Particiones de Kafka",
noKafkaPartitionMetrics: "No hay métricas de partición en la respuesta de Kafka",
tablePartition: "Partición",
tableBeginOffset: "Begin offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "Mensajes",
tableLeader: "Leader",
tableReplicas: "Réplicas",
tableIsr: "ISR",
tableStatus: "Estado",
statusNoLeader: "Sin leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Saludable",
kafkaMessageQuery: "Consulta de mensajes de Kafka",
querying: "Consultando...",
queryMessages: "Consultar",
queryHint: "Omita PARTITION / OFFSET para leer todas las particiones (máx. 100). Agréguelos para acotar el rango.",
sqlSyntaxError: 'Solo se admite SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "Cargando mensajes...",
noMessages: "No hay mensajes",
messageRate: "Tasa de mensajes",
inboundRate: "Tasa de entrada",
outboundRate: "Tasa de salida",
inboundThroughput: "Rendimiento de entrada",
outboundThroughput: "Rendimiento de salida",
rateTrend: "Tendencia de tasa",
backlogTrend: "Tendencia de backlog",
consumerLag: "Retraso del consumidor",
storageAndBacklog: "Almacenamiento y backlog",
storageSize: "Tamaño de almacenamiento",
backlogSize: "Tamaño de backlog",
backlogMessageCount: "Mensajes en backlog",
messageCounters: "Contadores de mensajes",
publishedMessages: "Publicados",
consumedMessages: "Consumidos",
connectionStats: "Conexiones",
subscriptionCount: "Suscripciones",
producerCount: "Productores",
partitionDetails: "Particiones",
tableInbound: "Entrada",
tableOutbound: "Salida",
tableInboundThroughput: "Rendimiento de entrada",
tableOutboundThroughput: "Rendimiento de salida",
tableBacklogMessages: "Msgs en backlog",
tableBacklogSize: "Tamaño de backlog",
tableProducers: "Productores",
tableSubscriptions: "Suscripciones",
producers: "Productores",
subscriptions: "Suscripciones",
tableName: "Nombre",
tableRate: "Tasa",
tableAddress: "Dirección",
tableType: "Tipo",
tableBacklog: "Backlog",
tableConsumers: "Consumidores",
noProducers: "No hay productores",
noSubscriptions: "No hay suscripciones",
noPartitionMetricsFromBroker: "No hay métricas de partición del broker",
nonPartitionedTopicNoDetails: "Los temas no particionados no tienen detalles de partición",
healthIndicators: "Salud",
messageFlow: "Flujo de mensajes",
backlogStatus: "Backlog",
producersLabel: "Productores",
subscriptionsLabel: "Suscripciones",
flowActive: "Activo",
flowIdle: "Inactivo",
backlogNormal: "Normal",
backlogHigh: "Alto",
producerConnected: "Conectado",
producerDisconnected: "Ninguno",
subscriptionActive: "Activa",
subscriptionNone: "Ninguna",
chartLegendIn: "Entrada",
chartLegendOut: "Salida",
chartLegendMessages: "Mensajes",
chartLegendBytes: "Bytes",
chartLegendConsumerLag: "Retraso del consumidor",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "msg",
chartAxisBytes: "bytes",
chartAxisMs: "ms",
metaPartition: "partición {partition}",
metaOffset: "offset {offset}",
metaKey: "clave {key}",
},
mqTenants: {
title: "Administración de tenants",
createTenant: "Crear tenant",
notSupported: "La administración de tenants no es compatible con este sistema de mensajería",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
loading: "Cargando...",
name: "Nombre",
adminRoles: "Admin Roles",
allowedClusters: "Allowed Clusters*",
actions: "Acciones",
none: "Ninguno",
edit: "Editar",
delete: "Eliminar",
createDialogTitle: "Crear tenant",
editDialogTitle: "Editar tenant: {name}",
tenantName: "Nombre del tenant*",
tenantNamePlaceholder: "p. ej. my-tenant",
addRolePlaceholder: "Agregar rol",
add: "Agregar",
selectClustersPlaceholder: "Seleccionar clusters permitidos",
noClustersDetectedPlaceholder: "No se detectaron clusters; agregar manualmente",
clustersNotReturned: "La conexión no devolvió una lista de clusters",
addClusterPlaceholder: "Agregar cluster manualmente",
clustersRequiredHint: "Seleccione o agregue al menos un cluster permitido",
cancel: "Cancelar",
create: "Crear",
save: "Guardar",
confirmDelete: '¿Está seguro de que desea eliminar el tenant "{name}"? Esta operación no se puede deshacer.',
tenantNameRequired: "El nombre del tenant es obligatorio",
allowedClustersRequired: "Los clusters permitidos son obligatorios",
},
mqNamespaces: {
title: "Administración de namespaces",
createNamespace: "Crear namespace",
notSupported: "La administración de namespaces no es compatible con este sistema de mensajería",
selectTenantFirst: "Seleccione primero un tenant",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
loading: "Cargando...",
name: "Nombre",
tenant: "Tenant",
adminRoles: "Admin Roles",
actions: "Acciones",
none: "Ninguno",
editRoles: "Editar roles",
delete: "Eliminar",
createDialogTitle: "Crear namespace",
namespaceName: "Nombre del namespace*",
namespaceNamePlaceholder: "p. ej. my-namespace",
cancel: "Cancelar",
create: "Crear",
confirmDelete: '¿Está seguro de que desea eliminar el namespace "{name}"? Esta operación no se puede deshacer.',
namespaceNameRequired: "El nombre del namespace es obligatorio",
},
mqSubscriptions: {
title: "Administración de suscripciones",
createSubscription: "Crear suscripción",
selectTopicFirst: "Seleccione primero un tema",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
loading: "Cargando...",
noSubscriptions: "No hay suscripciones para este tema",
subscriptionName: "Nombre de la suscripción",
type: "Tipo",
backlog: "Backlog",
consumeRate: "Tasa de consumo",
consumers: "Consumidores",
actions: "Acciones",
consumerCount: "{count} consumidores",
msgRate: "{rate} msg/s",
resetCursor: "Restablecer cursor",
skipMessages: "Omitir mensajes",
clearBacklog: "Limpiar backlog",
peek: "Peek",
expireMessages: "Expirar mensajes",
delete: "Eliminar",
createDialogTitle: "Crear suscripción",
topic: "Tema",
subscriptionNameLabel: "Nombre de la suscripción*",
subscriptionNamePlaceholder: "p. ej. my-subscription",
startPosition: "Posición inicial",
startFromEarliest: "Desde el mensaje más antiguo (Earliest)",
startFromLatest: "Desde el mensaje más reciente (Latest)",
resetDialogTitle: "Restablecer cursor: {name}",
resetTo: "Restablecer a",
earliest: "Mensaje más antiguo (Earliest)",
latest: "Mensaje más reciente (Latest)",
timestamp: "Marca de tiempo específica",
timestampMs: "Marca de tiempo (ms)",
currentTime: "Hora actual: {time}",
reset: "Restablecer",
skipDialogTitle: "Omitir mensajes: {name}",
skipMode: "Modo de omisión",
skipCount: "Omitir cantidad especificada",
skipAll: "Omitir todo el backlog",
skipCountLabel: "Cantidad a omitir",
skip: "Omitir",
peekDialogTitle: "Peek de mensajes: {name}",
count: "Cantidad",
refresh: "Actualizar",
noPeekMessages: "No hay mensajes para ver",
close: "Cerrar",
expireDialogTitle: "Expirar mensajes: {name}",
expireSeconds: "Tiempo de expiración (segundos)",
expireHint: "Eliminará todos los mensajes anteriores a {seconds} segundos",
expire: "Expirar",
cancel: "Cancelar",
create: "Crear",
confirmDelete: '¿Está seguro de que desea eliminar la suscripción "{name}"? Esta operación no se puede deshacer.',
confirmClearBacklog: '¿Limpiar todos los mensajes en backlog de la suscripción "{name}"?',
subscriptionNameRequired: "El nombre de la suscripción es obligatorio",
peekMessageKey: "clave={key}",
},
mqClients: {
title: "Productores / Consumidores",
unloadTopic: "Descargar tema",
unloading: "Descargando...",
refresh: "Actualizar",
refreshing: "Actualizando...",
selectTopicFirst: "Seleccione primero un tema",
confirmUnload: "¿Descargar este tema? Los productores y consumidores activos se reconectarán.",
aggregateTopic: "Tema agregado",
kafkaPartitionStatus: "Estado de particiones de Kafka",
partitionCount: "{count} particiones",
partition: "Partición",
beginOffset: "Begin offset",
latestOffset: "Latest offset",
messageCount: "Mensajes",
leader: "Leader",
replicas: "Réplicas",
isr: "ISR",
partitionClients: "Clientes de partición",
inboundRate: "Tasa de entrada",
outboundRate: "Tasa de salida",
producers: "Productores",
subscriptions: "Suscripciones",
consumers: "Consumidores",
activeProducers: "Productores activos",
noActiveProducers: "No hay productores activos",
name: "Nombre",
id: "ID",
address: "Dirección",
version: "Versión",
rate: "Tasa",
throughput: "Rendimiento",
rateValue: "{value} msg/s",
activeConsumers: "Consumidores activos",
noSubscriptions: "Este tema no tiene suscripciones",
noConsumersOnPartition: "No hay consumidores activos en la suscripción de esta partición",
noConsumersOnSubscription: "No hay consumidores activos en esta suscripción",
permits: "Permisos",
},
mqPolicies: {
title: "Administración de políticas",
refresh: "Actualizar",
refreshing: "Actualizando...",
selectTopicFirst: "Seleccione primero un tema",
selectNamespaceOrTopic: "Seleccione un namespace o un tema",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
readonlyHint: "Esta conexión es de solo lectura; la edición de políticas está deshabilitada.",
publishRate: "Límite de tasa de publicación",
msgsPerSecond: "Mensajes / segundo",
bytesPerSecond: "Bytes / segundo",
save: "Guardar",
dispatchRate: "Límite de tasa de despacho",
msgsPerPeriod: "Mensajes / período",
bytesPerPeriod: "Bytes / período",
periodSeconds: "Período (segundos)",
subscribeRate: "Límite de tasa de suscripción",
msgsPerConsumerPerPeriod: "Mensajes por consumidor / período",
backlogQuota: "Cuota de backlog",
sizeLimitBytes: "Límite de tamaño (bytes)",
timeLimitSeconds: "Límite de tiempo (segundos)",
policy: "Política",
type: "Tipo",
backlogPolicyProducerRequestHold: "Retener solicitudes del productor",
backlogPolicyProducerException: "Rechazar con excepción",
backlogPolicyConsumerBacklogEviction: "Desalojar backlog del consumidor",
retention: "Retención de mensajes",
retentionTimeMinutes: "Tiempo de retención (minutos)",
retentionSizeMb: "Tamaño de retención (MB)",
effectivePolicies: "Políticas efectivas",
savedNotice: "{policy} guardada",
},
mqPermissions: {
title: "Permisos",
refresh: "Actualizar",
refreshing: "Actualizando...",
selectNamespaceOrTopic: "Seleccione primero un namespace o un tema",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
readonlyHint: "Esta conexión es de solo lectura; conceder y revocar están deshabilitados.",
grantRole: "Conceder rol",
roleName: "Nombre del rol",
roleNamePlaceholder: "p. ej. app-producer",
actions: "Acciones",
grant: "Conceder",
currentPermissions: "Permisos actuales",
role: "Rol",
operations: "Operaciones",
token: "Token",
revoke: "Revocar",
noPermissions: "No hay registros de permisos",
clientTokenTitle: "Token de cliente: {role}",
missingSigningKeyTitle: "Clave de firma de token no configurada",
missingSigningKeyMessage: "Esta conexión MQ no tiene configurada una clave de firma de token del Broker, por lo que no se pueden emitir tokens de cliente.",
missingSigningKeyDetail: 'Edite la conexión y configure "Firma de token del Broker" como HS256 SECRET o RS256 PRIVATE, y proporcione la clave de firma antes de generar tokens.',
tokenShowOnceWarning: "El token se muestra solo una vez. Cópielo y guárdelo de inmediato.",
copyToken: "Copiar token",
issueNewToken: "Emitir nuevo token",
neverExpires: "Nunca expira",
expiryDays: "Expiración (días)",
note: "Nota",
notePlaceholder: "p. ej. para rt-erp-server",
issuing: "Emitiendo...",
generateToken: "Generar token",
tokenRevokeHint: "Revocar permisos de rol no invalida los JWT emitidos de inmediato; espere a la expiración o rote la clave de firma del broker.",
issueRecords: "Registros de emisión",
time: "Hora",
algorithm: "Algoritmo",
expires: "Expira",
fingerprint: "Huella digital",
noteColumn: "Nota",
unlimited: "Permanente",
loading: "Cargando...",
noIssueRecords: "No hay registros de emisión",
close: "Cerrar",
kafkaAuthorizerNotConfigured: "Este cluster de Kafka no tiene authorizer configurado. Agregue authorizer.class.name en la configuración del broker para soporte de ACL.",
kafkaAuthorizerDisabled: "Este cluster de Kafka no tiene authorizer habilitado. Habilite un authorizer en la configuración del broker para conceder permisos.",
roleNameRequired: "El nombre del rol es obligatorio",
selectAtLeastOneAction: "Seleccione al menos una acción",
grantedRole: "Rol {role} concedido",
confirmRevoke: '¿Revocar permisos del rol "{role}"?',
revokedRole: "Rol {role} revocado",
roleNameEmpty: "El nombre del rol no puede estar vacío",
expiryMustBePositive: "La expiración debe ser mayor que 0 días",
},
mqBroker: {
title: "Cluster de Broker",
autoRefresh: "Actualización automática",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Actualizando...",
refreshNow: "Actualizar ahora",
loading: "Cargando...",
clusterOverview: "Resumen del cluster",
clusterId: "ID del cluster",
unknown: "Desconocido",
brokerCount: "Brokers",
controller: "Controller",
controllerNode: "Nodo {id} · {host}",
brokerNodes: "Nodos del Broker",
nodeId: "ID del nodo",
host: "Host",
port: "Puerto",
rack: "Rack",
role: "Rol",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "No hay información de nodos del Broker",
},
mqRaw: {
title: "Raw API",
sending: "Enviando...",
sendRequest: "Enviar solicitud",
readOnlyHint: "Esta conexión es de solo lectura; solo se permiten solicitudes GET, HEAD y OPTIONS.",
commonEndpoints: "Endpoints comunes",
fillFromSelection: "Rellenar solicitud desde la selección actual",
expand: "Expandir",
collapse: "Contraer",
method: "Método",
path: "Ruta",
pathPlaceholder: "/admin/v2/...",
queryParams: "Parámetros de consulta",
queryPlaceholder: "clave=valor, uno por línea",
jsonBody: "JSON Body",
format: "Formatear",
bodyPlaceholder: "p. ej. {'{'}\"key\":\"value\"{'}'}",
response: "Respuesta",
textResponse: "Respuesta de texto",
noRequestYet: "Aún no se ha enviado ninguna solicitud",
readOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
pathRequired: "La ruta de la solicitud es obligatoria",
jsonBodyInvalid: "Cuerpo JSON no válido: {error}",
presetBrokerVersion: "Versión del Broker",
presetBrokerVersionDesc: "Cadena de versión del Broker",
presetClusters: "Lista de clusters",
presetClustersDesc: "Listar clusters de Pulsar configurados",
presetTenant: "Detalles del tenant",
presetTenantDesc: "Ver adminRoles / allowedClusters",
presetNamespacePolicies: "Políticas del namespace",
presetNamespacePoliciesDesc: "Estructura de políticas del namespace sin procesar",
presetBundles: "Lista de bundles",
presetBundlesDesc: "Distribución de bundles del namespace",
presetTopicInternalStats: "Estadísticas internas del tema",
presetTopicInternalStatsDesc: "Detalles de ledger / cursor / backlog",
presetPartitionedStats: "Métricas de partición",
presetPartitionedStatsDesc: "Tasas, productores y consumidores por partición",
presetSchema: "Último schema",
presetSchemaDesc: "Definición del schema actual del tema",
},
production: {
title: "Entorno de producción",
connection: "Conexión de producción",

View File

@ -448,6 +448,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opzionale, percorso sulla macchina DBX Agent, ad esempio /etc/krb5.conf",
kafkaKerberosPathHint: "I percorsi di keytab e krb5.conf vengono letti da DBX Agent e devono esistere sulla macchina in cui è in esecuzione DBX Agent; non vengono caricati file dal browser corrente.",
kafkaKerberosAuthHint: "Accedi con GSSAPI + keytab. Se il server richiede trasmissione crittografata, imposta Security su SASL_SSL; altrimenti puoi utilizzare Auto o SASL_PLAINTEXT.",
mqSystem: "Sistema",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "I bootstrap server Kafka sono obbligatori",
mqBootstrapServersInvalid: "I bootstrap server Kafka non sono validi",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "L'Admin URL MQ è obbligatorio",
mqAdminUrlInvalid: "L'Admin URL MQ non è valido",
mqAuth: "Autenticazione",
mqAuthNone: "Nessuna",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "L'autenticazione OAuth2 richiede un Issuer URL",
mqOauthClientIdRequired: "L'autenticazione OAuth2 richiede un Client ID",
mqOauthClientSecretRequired: "L'autenticazione OAuth2 richiede un Client Secret",
mqTls: "TLS",
mqTlsSkipVerify: "Ignora verifica certificato",
mqPinnedVersion: "Versione fissa",
mqTokenSigning: "Firma token Broker",
mqTokenSigningNone: "Non configurato",
mqTokenSigningKey: "Chiave di firma",
mqTokenSigningKeyRequired: "La chiave di firma del token Broker è obbligatoria",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Seleziona in base a jwt.broker.token.mode del broker: SECRET usa HS256, PRIVATE usa RS256. La chiave viene salvata nei secret della connessione.",
databaseInfo: {
title: "Informazioni sul database",
open: "Apri informazioni database {database}",
@ -3905,6 +3946,472 @@ export default withEnglishFallback({
partitionMustIncrease: "Il nuovo numero di partizioni deve essere maggiore di quello attuale.",
confirmDelete: 'Confermi di eliminare il topic "{name}"? Questa operazione è irreversibile.',
},
mqAdmin: {
viewTenant: "Visualizza tenant",
viewNamespace: "Visualizza namespace",
viewTopic: "Visualizza topic",
readOnly: "Sola lettura",
tabTenants: "Tenant",
tabNamespaces: "Namespace",
tabTopics: "Topic",
tabSubscriptions: "Sottoscrizioni",
tabMonitoring: "Monitoraggio",
tabClients: "Clienti",
tabMessages: "Messaggi",
tabBroker: "Broker",
tabPolicies: "Politiche",
tabPermissions: "Permessi",
tabRawApi: "Raw API",
},
mqMessages: {
title: "Invia messaggio",
clear: "Pulisci",
selectNamespaceOrTopicFirst: "Seleziona prima un namespace o un topic",
readOnlyCannotSend: "Questa connessione è in sola lettura e non può inviare messaggi",
selectTargetTopic: "Seleziona un topic di destinazione",
messageContentRequired: "Il contenuto del messaggio è obbligatorio",
sendSuccess: "Messaggio inviato — partizione: {partition}, offset: {offset}",
targetTopic: "Topic di destinazione",
topicLoading: "Caricamento in corso...",
topicSearchPlaceholder: "Digita o cerca topic...",
refreshTopicList: "Aggiorna elenco topic",
topicOptionWithPartitions: "{label} ({partitions} partizioni)",
noTopicsAvailable: "Nessun topic disponibile",
topicSearchHint: "Cerca per parola chiave o incolla il nome di un topic.",
messageKey: "Chiave del messaggio",
optional: "Opzionale",
messageContent: "Contenuto del messaggio",
formatJson: "Formatta JSON",
messageHeaders: "Intestazioni",
headersPlaceholder: "chiave: valore (una per riga)",
sending: "Invio in corso...",
sendMessage: "Invia messaggio",
messageList: "Messaggi",
loadMessages: "Carica messaggi",
loading: "Caricamento in corso...",
peekDefaultHint: "Senza partizione, legge tutte le partizioni dall'inizio (earliest), fino a {count} messaggi.",
count: "Quantità",
advancedFilter: "Avanzate (partizione / offset)",
partition: "Partizione",
partitionPlaceholderAll: "Vuoto = tutte",
offset: "Offset",
offsetPlaceholderEarliest: "Vuoto = inizio (earliest)",
messagesLoading: "Caricamento messaggi...",
noMessages: "Nessun messaggio",
selectTopicBeforeLoad: "Seleziona un topic prima di caricare i messaggi",
partitionMustBeNonNegativeInt: "La partizione deve essere un intero ≥ 0; lascia vuoto per tutte le partizioni",
offsetMustBeNonNegativeInt: "L'offset deve essere un intero ≥ 0; lascia vuoto per l'inizio (earliest)",
metaPartition: "partizione {partition}",
metaOffset: "offset {offset}",
metaKey: "chiave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "Monitoraggio",
autoRefresh: "Aggiornamento automatico",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Aggiornamento in corso...",
refreshNow: "Aggiorna",
selectTopicFirst: "Seleziona prima un topic",
loadingStats: "Caricamento dati di monitoraggio...",
kafkaTopicOverview: "Panoramica topic Kafka",
partitionCount: "Partizioni",
replicationFactor: "Fattore di replica",
messageCount: "Messaggi",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offset e repliche",
beginOffset: "Offset iniziale",
leaderCount: "Leader",
isrHealthyPartitions: "ISR integre",
noLeaderPartitions: "Senza leader",
kafkaPartitionDetails: "Partizioni Kafka",
noKafkaPartitionMetrics: "Nessuna metrica di partizione nella risposta Kafka",
tablePartition: "Partizione",
tableBeginOffset: "Offset iniziale",
tableLogEndOffset: "Log end offset",
tableMessageCount: "Messaggi",
tableLeader: "Leader",
tableReplicas: "Replica",
tableIsr: "ISR",
tableStatus: "Stato",
statusNoLeader: "Senza leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Integro",
kafkaMessageQuery: "Interrogazione messaggi Kafka",
querying: "Interrogazione in corso...",
queryMessages: "Interroga",
queryHint: "Ometti PARTITION / OFFSET per leggere tutte le partizioni (max 100). Aggiungili per restringere l'intervallo.",
sqlSyntaxError: 'È supportato solo SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "Caricamento messaggi...",
noMessages: "Nessun messaggio",
messageRate: "Velocità messaggi",
inboundRate: "Velocità in ingresso",
outboundRate: "Velocità in uscita",
inboundThroughput: "Throughput in ingresso",
outboundThroughput: "Throughput in uscita",
rateTrend: "Andamento velocità",
backlogTrend: "Andamento backlog",
consumerLag: "Ritardo consumer",
storageAndBacklog: "Storage e backlog",
storageSize: "Dimensione storage",
backlogSize: "Dimensione backlog",
backlogMessageCount: "Messaggi in backlog",
messageCounters: "Contatori messaggi",
publishedMessages: "Pubblicati",
consumedMessages: "Consumati",
connectionStats: "Connessioni",
subscriptionCount: "Sottoscrizioni",
producerCount: "Produttori",
partitionDetails: "Partizioni",
tableInbound: "In",
tableOutbound: "Out",
tableInboundThroughput: "Throughput in",
tableOutboundThroughput: "Throughput out",
tableBacklogMessages: "Msg backlog",
tableBacklogSize: "Dimensione backlog",
tableProducers: "Produttori",
tableSubscriptions: "Sottoscrizioni",
producers: "Produttori",
subscriptions: "Sottoscrizioni",
tableName: "Nome",
tableRate: "Velocità",
tableAddress: "Indirizzo",
tableType: "Tipo",
tableBacklog: "Backlog",
tableConsumers: "Consumatori",
noProducers: "Nessun produttore",
noSubscriptions: "Nessuna sottoscrizione",
noPartitionMetricsFromBroker: "Nessuna metrica di partizione dal broker",
nonPartitionedTopicNoDetails: "I topic non partizionati non hanno dettagli sulle partizioni",
healthIndicators: "Integrità",
messageFlow: "Flusso messaggi",
backlogStatus: "Backlog",
producersLabel: "Produttori",
subscriptionsLabel: "Sottoscrizioni",
flowActive: "Attivo",
flowIdle: "Inattivo",
backlogNormal: "Normale",
backlogHigh: "Elevato",
producerConnected: "Connesso",
producerDisconnected: "Nessuno",
subscriptionActive: "Attiva",
subscriptionNone: "Nessuna",
chartLegendIn: "In",
chartLegendOut: "Out",
chartLegendMessages: "Messaggi",
chartLegendBytes: "Byte",
chartLegendConsumerLag: "Ritardo consumer",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "msg",
chartAxisBytes: "byte",
chartAxisMs: "ms",
metaPartition: "partizione {partition}",
metaOffset: "offset {offset}",
metaKey: "chiave {key}",
},
mqTenants: {
title: "Gestione tenant",
createTenant: "Crea tenant",
notSupported: "La gestione tenant non è supportata per questo sistema di messaggistica",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
loading: "Caricamento in corso...",
name: "Nome",
adminRoles: "Ruoli amministratore",
allowedClusters: "Cluster consentiti*",
actions: "Azioni",
none: "Nessuno",
edit: "Modifica",
delete: "Elimina",
createDialogTitle: "Crea tenant",
editDialogTitle: "Modifica tenant: {name}",
tenantName: "Nome tenant*",
tenantNamePlaceholder: "es. my-tenant",
addRolePlaceholder: "Aggiungi ruolo",
add: "Aggiungi",
selectClustersPlaceholder: "Seleziona cluster consentiti",
noClustersDetectedPlaceholder: "Nessun cluster rilevato; aggiungi manualmente",
clustersNotReturned: "La connessione non ha restituito un elenco di cluster",
addClusterPlaceholder: "Aggiungi cluster manualmente",
clustersRequiredHint: "Seleziona o aggiungi almeno un cluster consentito",
cancel: "Annulla",
create: "Crea",
save: "Salva",
confirmDelete: 'Confermi di eliminare il tenant "{name}"? Questa operazione è irreversibile.',
tenantNameRequired: "Il nome del tenant è obbligatorio",
allowedClustersRequired: "I cluster consentiti sono obbligatori",
},
mqNamespaces: {
title: "Gestione namespace",
createNamespace: "Crea namespace",
notSupported: "La gestione namespace non è supportata per questo sistema di messaggistica",
selectTenantFirst: "Seleziona prima un tenant",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
loading: "Caricamento in corso...",
name: "Nome",
tenant: "Tenant",
adminRoles: "Ruoli amministratore",
actions: "Azioni",
none: "Nessuno",
editRoles: "Modifica ruoli",
delete: "Elimina",
createDialogTitle: "Crea namespace",
namespaceName: "Nome namespace*",
namespaceNamePlaceholder: "es. my-namespace",
cancel: "Annulla",
create: "Crea",
confirmDelete: 'Confermi di eliminare il namespace "{name}"? Questa operazione è irreversibile.',
namespaceNameRequired: "Il nome del namespace è obbligatorio",
},
mqSubscriptions: {
title: "Gestione sottoscrizioni",
createSubscription: "Crea sottoscrizione",
selectTopicFirst: "Seleziona prima un topic",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
loading: "Caricamento in corso...",
noSubscriptions: "Nessuna sottoscrizione per questo topic",
subscriptionName: "Nome sottoscrizione",
type: "Tipo",
backlog: "Backlog",
consumeRate: "Velocità di consumo",
consumers: "Consumatori",
actions: "Azioni",
consumerCount: "{count} consumatori",
msgRate: "{rate} msg/s",
resetCursor: "Reimposta cursore",
skipMessages: "Salta messaggi",
clearBacklog: "Svuota backlog",
peek: "Peek",
expireMessages: "Fai scadere messaggi",
delete: "Elimina",
createDialogTitle: "Crea sottoscrizione",
topic: "Topic",
subscriptionNameLabel: "Nome sottoscrizione*",
subscriptionNamePlaceholder: "es. my-subscription",
startPosition: "Posizione iniziale",
startFromEarliest: "Dal primo messaggio (Earliest)",
startFromLatest: "Dall'ultimo messaggio (Latest)",
resetDialogTitle: "Reimposta cursore: {name}",
resetTo: "Reimposta a",
earliest: "Primo messaggio (Earliest)",
latest: "Ultimo messaggio (Latest)",
timestamp: "Timestamp specifico",
timestampMs: "Timestamp (ms)",
currentTime: "Ora corrente: {time}",
reset: "Reimposta",
skipDialogTitle: "Salta messaggi: {name}",
skipMode: "Modalità salto",
skipCount: "Salta quantità specificata",
skipAll: "Salta tutto il backlog",
skipCountLabel: "Quantità da saltare",
skip: "Salta",
peekDialogTitle: "Peek messaggi: {name}",
count: "Quantità",
refresh: "Aggiorna",
noPeekMessages: "Nessun messaggio da visualizzare",
close: "Chiudi",
expireDialogTitle: "Fai scadere messaggi: {name}",
expireSeconds: "Tempo di scadenza (secondi)",
expireHint: "Eliminerà tutti i messaggi più vecchi di {seconds} secondi",
expire: "Fai scadere",
cancel: "Annulla",
create: "Crea",
confirmDelete: 'Confermi di eliminare la sottoscrizione "{name}"? Questa operazione è irreversibile.',
confirmClearBacklog: 'Svuotare tutti i messaggi in backlog per la sottoscrizione "{name}"?',
subscriptionNameRequired: "Il nome della sottoscrizione è obbligatorio",
peekMessageKey: "chiave={key}",
},
mqClients: {
title: "Produttori / Consumatori",
unloadTopic: "Scarica topic",
unloading: "Scaricamento in corso...",
refresh: "Aggiorna",
refreshing: "Aggiornamento in corso...",
selectTopicFirst: "Seleziona prima un topic",
confirmUnload: "Scaricare questo topic? I produttori e i consumatori attivi si riconnetteranno.",
aggregateTopic: "Topic aggregato",
kafkaPartitionStatus: "Stato partizioni Kafka",
partitionCount: "{count} partizioni",
partition: "Partizione",
beginOffset: "Offset iniziale",
latestOffset: "Offset più recente",
messageCount: "Messaggi",
leader: "Leader",
replicas: "Replica",
isr: "ISR",
partitionClients: "Clienti per partizione",
inboundRate: "Velocità in ingresso",
outboundRate: "Velocità in uscita",
producers: "Produttori",
subscriptions: "Sottoscrizioni",
consumers: "Consumatori",
activeProducers: "Produttori attivi",
noActiveProducers: "Nessun produttore attivo",
name: "Nome",
id: "ID",
address: "Indirizzo",
version: "Versione",
rate: "Velocità",
throughput: "Throughput",
rateValue: "{value} msg/s",
activeConsumers: "Consumatori attivi",
noSubscriptions: "Questo topic non ha sottoscrizioni",
noConsumersOnPartition: "Nessun consumatore attivo su questa sottoscrizione di partizione",
noConsumersOnSubscription: "Nessun consumatore attivo su questa sottoscrizione",
permits: "Permessi",
},
mqPolicies: {
title: "Gestione politiche",
refresh: "Aggiorna",
refreshing: "Aggiornamento in corso...",
selectTopicFirst: "Seleziona prima un topic",
selectNamespaceOrTopic: "Seleziona un namespace o un topic",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
readonlyHint: "Questa connessione è in sola lettura; la modifica delle politiche è disabilitata.",
publishRate: "Limite velocità di pubblicazione",
msgsPerSecond: "Messaggi / secondo",
bytesPerSecond: "Byte / secondo",
save: "Salva",
dispatchRate: "Limite velocità di dispatch",
msgsPerPeriod: "Messaggi / periodo",
bytesPerPeriod: "Byte / periodo",
periodSeconds: "Periodo (secondi)",
subscribeRate: "Limite velocità di sottoscrizione",
msgsPerConsumerPerPeriod: "Messaggi per consumatore / periodo",
backlogQuota: "Quota backlog",
sizeLimitBytes: "Limite dimensione (byte)",
timeLimitSeconds: "Limite tempo (secondi)",
policy: "Politica",
type: "Tipo",
backlogPolicyProducerRequestHold: "Metti in attesa le richieste del produttore",
backlogPolicyProducerException: "Rifiuta con eccezione",
backlogPolicyConsumerBacklogEviction: "Elimina backlog del consumatore",
retention: "Retention messaggi",
retentionTimeMinutes: "Tempo di retention (minuti)",
retentionSizeMb: "Dimensione retention (MB)",
effectivePolicies: "Politiche effettive",
savedNotice: "{policy} salvata",
},
mqPermissions: {
title: "Permessi",
refresh: "Aggiorna",
refreshing: "Aggiornamento in corso...",
selectNamespaceOrTopic: "Seleziona prima un namespace o un topic",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
readonlyHint: "Questa connessione è in sola lettura; concessione e revoca sono disabilitate.",
grantRole: "Concedi ruolo",
roleName: "Nome ruolo",
roleNamePlaceholder: "es. app-producer",
actions: "Azioni",
grant: "Concedi",
currentPermissions: "Permessi attuali",
role: "Ruolo",
operations: "Operazioni",
token: "Token",
revoke: "Revoca",
noPermissions: "Nessun record di permesso",
clientTokenTitle: "Token client: {role}",
missingSigningKeyTitle: "Chiave di firma token non configurata",
missingSigningKeyMessage: "Questa connessione MQ non ha una chiave di firma token Broker configurata, quindi non è possibile emettere token client.",
missingSigningKeyDetail: 'Modifica la connessione e imposta "Broker token signing" su HS256 SECRET o RS256 PRIVATE, quindi fornisci la chiave di firma prima di generare i token.',
tokenShowOnceWarning: "Il token viene mostrato una sola volta. Copialo e conservalo immediatamente.",
copyToken: "Copia token",
issueNewToken: "Emetti nuovo token",
neverExpires: "Non scade mai",
expiryDays: "Scadenza (giorni)",
note: "Nota",
notePlaceholder: "es. per rt-erp-server",
issuing: "Emissione in corso...",
generateToken: "Genera token",
tokenRevokeHint: "La revoca dei permessi del ruolo non invalida immediatamente i JWT emessi; attendi la scadenza o ruota la chiave di firma del broker.",
issueRecords: "Registri emissione",
time: "Ora",
algorithm: "Algoritmo",
expires: "Scadenza",
fingerprint: "Fingerprint",
noteColumn: "Nota",
unlimited: "Permanente",
loading: "Caricamento in corso...",
noIssueRecords: "Nessun registro di emissione",
close: "Chiudi",
kafkaAuthorizerNotConfigured: "Questo cluster Kafka non ha un authorizer configurato. Aggiungi authorizer.class.name nella configurazione del broker per il supporto ACL.",
kafkaAuthorizerDisabled: "Questo cluster Kafka non ha un authorizer abilitato. Abilita un authorizer nella configurazione del broker per concedere permessi.",
roleNameRequired: "Il nome del ruolo è obbligatorio",
selectAtLeastOneAction: "Seleziona almeno un'azione",
grantedRole: "Concesso {role}",
confirmRevoke: 'Revocare i permessi per il ruolo "{role}"?',
revokedRole: "Revocato {role}",
roleNameEmpty: "Il nome del ruolo non può essere vuoto",
expiryMustBePositive: "La scadenza deve essere maggiore di 0 giorni",
},
mqBroker: {
title: "Cluster Broker",
autoRefresh: "Aggiornamento automatico",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Aggiornamento in corso...",
refreshNow: "Aggiorna ora",
loading: "Caricamento in corso...",
clusterOverview: "Panoramica cluster",
clusterId: "ID cluster",
unknown: "Sconosciuto",
brokerCount: "Broker",
controller: "Controller",
controllerNode: "Nodo {id} · {host}",
brokerNodes: "Nodi broker",
nodeId: "ID nodo",
host: "Host",
port: "Porta",
rack: "Rack",
role: "Ruolo",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Nessuna informazione sui nodi broker",
},
mqRaw: {
title: "Raw API",
sending: "Invio in corso...",
sendRequest: "Invia richiesta",
readOnlyHint: "Questa connessione è in sola lettura; sono consentite solo richieste GET, HEAD e OPTIONS.",
commonEndpoints: "Endpoint comuni",
fillFromSelection: "Compila richiesta dalla selezione corrente",
expand: "Espandi",
collapse: "Comprimi",
method: "Metodo",
path: "Percorso",
pathPlaceholder: "/admin/v2/...",
queryParams: "Parametri query",
queryPlaceholder: "key=value, uno per riga",
jsonBody: "JSON Body",
format: "Formatta",
bodyPlaceholder: "es. {'{'}\"key\":\"value\"{'}'}",
response: "Risposta",
textResponse: "Risposta testuale",
noRequestYet: "Nessuna richiesta inviata ancora",
readOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
pathRequired: "Il percorso della richiesta è obbligatorio",
jsonBodyInvalid: "Corpo JSON non valido: {error}",
presetBrokerVersion: "Versione broker",
presetBrokerVersionDesc: "Stringa versione broker",
presetClusters: "Elenco cluster",
presetClustersDesc: "Elenca cluster Pulsar configurati",
presetTenant: "Dettagli tenant",
presetTenantDesc: "Visualizza adminRoles / allowedClusters",
presetNamespacePolicies: "Politiche namespace",
presetNamespacePoliciesDesc: "Struttura grezza delle politiche namespace",
presetBundles: "Elenco bundle",
presetBundlesDesc: "Distribuzione bundle namespace",
presetTopicInternalStats: "Statistiche interne topic",
presetTopicInternalStatsDesc: "Dettagli ledger / cursore / backlog",
presetPartitionedStats: "Metriche per partizione",
presetPartitionedStatsDesc: "Velocità, produttori e consumatori per partizione",
presetSchema: "Schema più recente",
presetSchemaDesc: "Definizione schema topic corrente",
},
production: {
title: "Ambiente di produzione",
connection: "Connessione di produzione",

View File

@ -448,6 +448,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "オプション。DBX Agent が動作するマシンのパス。例: /etc/krb5.conf",
kafkaKerberosPathHint: "keytab と krb5.conf のパスは DBX Agent が読み取るため、DBX Agent が動作するマシン上に存在する必要があります。ブラウザからファイルをアップロードすることはありません。",
kafkaKerberosAuthHint: "GSSAPI + keytab を使用してログインします。サーバーが暗号化転送を要求する場合は、Security を SASL_SSL に設定してください。それ以外の場合は Auto または SASL_PLAINTEXT を使用できます。",
mqSystem: "システム",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers は必須です",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers が無効です",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "MQ Admin URL は必須です",
mqAdminUrlInvalid: "MQ Admin URL が無効です",
mqAuth: "認証",
mqAuthNone: "なし",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "OAuth2 認証には Issuer URL が必要です",
mqOauthClientIdRequired: "OAuth2 認証には Client ID が必要です",
mqOauthClientSecretRequired: "OAuth2 認証には Client Secret が必要です",
mqTls: "TLS",
mqTlsSkipVerify: "証明書検証をスキップ",
mqPinnedVersion: "固定バージョン",
mqTokenSigning: "Broker Token 署名",
mqTokenSigningNone: "未設定",
mqTokenSigningKey: "署名キー",
mqTokenSigningKeyRequired: "Broker Token 署名キーは必須です",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Broker の jwt.broker.token.mode に応じて選択してください: SECRET は HS256、PRIVATE は RS256。キーは接続 secret として保存されます。",
databaseInfo: {
title: "データベース情報",
open: "{database} データベース情報を開く",
@ -3906,6 +3947,472 @@ export default withEnglishFallback({
partitionMustIncrease: "新しいパーティション数は現在のパーティション数より大きくなければなりません。",
confirmDelete: "トピック「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
},
mqAdmin: {
viewTenant: "テナントを表示",
viewNamespace: "名前空間を表示",
viewTopic: "トピックを表示",
readOnly: "読み取り専用",
tabTenants: "テナント",
tabNamespaces: "名前空間",
tabTopics: "トピック",
tabSubscriptions: "サブスクリプション",
tabMonitoring: "モニタリング",
tabClients: "クライアント",
tabMessages: "メッセージ",
tabBroker: "Broker",
tabPolicies: "ポリシー",
tabPermissions: "権限",
tabRawApi: "Raw API",
},
mqMessages: {
title: "メッセージ送信",
clear: "クリア",
selectNamespaceOrTopicFirst: "先に名前空間またはトピックを選択してください",
readOnlyCannotSend: "この接続は読み取り専用のため、メッセージを送信できません",
selectTargetTopic: "送信先トピックを選択してください",
messageContentRequired: "メッセージ内容は必須です",
sendSuccess: "メッセージを送信しました — パーティション: {partition}, offset: {offset}",
targetTopic: "送信先トピック",
topicLoading: "読み込み中...",
topicSearchPlaceholder: "トピックを入力または検索...",
refreshTopicList: "トピック一覧を更新",
topicOptionWithPartitions: "{label} ({partitions} パーティション)",
noTopicsAvailable: "利用可能なトピックがありません",
topicSearchHint: "キーワードで検索するか、トピック名を貼り付けてください。",
messageKey: "メッセージキー",
optional: "任意",
messageContent: "メッセージ内容",
formatJson: "JSON を整形",
messageHeaders: "ヘッダー",
headersPlaceholder: "key: value (1行1件)",
sending: "送信中...",
sendMessage: "メッセージを送信",
messageList: "メッセージ一覧",
loadMessages: "メッセージを読み込む",
loading: "読み込み中...",
peekDefaultHint: "パーティション未指定時は、全パーティションから Earliest 位置で最大 {count} 件まで読み取ります。",
count: "件数",
advancedFilter: "詳細 (パーティション / offset)",
partition: "パーティション",
partitionPlaceholderAll: "空欄 = すべて",
offset: "Offset",
offsetPlaceholderEarliest: "空欄 = Earliest",
messagesLoading: "メッセージを読み込み中...",
noMessages: "メッセージがありません",
selectTopicBeforeLoad: "メッセージを読み込む前にトピックを選択してください",
partitionMustBeNonNegativeInt: "パーティションは 0 以上の整数である必要があります。空欄の場合はすべてのパーティション",
offsetMustBeNonNegativeInt: "Offset は 0 以上の整数である必要があります。空欄の場合は Earliest",
metaPartition: "パーティション {partition}",
metaOffset: "オフセット {offset}",
metaKey: "キー {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "モニタリング",
autoRefresh: "自動更新",
refreshInterval5s: "5秒",
refreshInterval10s: "10秒",
refreshInterval30s: "30秒",
refreshInterval60s: "60秒",
refreshing: "更新中...",
refreshNow: "更新",
selectTopicFirst: "先にトピックを選択してください",
loadingStats: "モニタリングデータを読み込み中...",
kafkaTopicOverview: "Kafka トピック概要",
partitionCount: "パーティション",
replicationFactor: "レプリケーション係数",
messageCount: "メッセージ",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offset とレプリカ",
beginOffset: "Begin offset",
leaderCount: "Leader",
isrHealthyPartitions: "ISR 正常",
noLeaderPartitions: "Leader なし",
kafkaPartitionDetails: "Kafka パーティション",
noKafkaPartitionMetrics: "Kafka レスポンスにパーティションメトリクスがありません",
tablePartition: "パーティション",
tableBeginOffset: "Begin offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "メッセージ",
tableLeader: "Leader",
tableReplicas: "Replicas",
tableIsr: "ISR",
tableStatus: "ステータス",
statusNoLeader: "Leader なし",
statusIsrIncomplete: "ISR 不完全",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka メッセージクエリ",
querying: "クエリ中...",
queryMessages: "クエリ",
queryHint: "PARTITION / OFFSET を省略すると全パーティションを読み取ります (最大 100 件)。指定すると範囲を絞り込めます。",
sqlSyntaxError: 'SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] のみサポートしています',
messagesLoading: "メッセージを読み込み中...",
noMessages: "メッセージがありません",
messageRate: "メッセージレート",
inboundRate: "受信レート",
outboundRate: "送信レート",
inboundThroughput: "受信スループット",
outboundThroughput: "送信スループット",
rateTrend: "レート推移",
backlogTrend: "バックログ推移",
consumerLag: "コンシューマー遅延",
storageAndBacklog: "ストレージとバックログ",
storageSize: "ストレージサイズ",
backlogSize: "バックログサイズ",
backlogMessageCount: "バックログメッセージ",
messageCounters: "メッセージカウンター",
publishedMessages: "公開済み",
consumedMessages: "消費済み",
connectionStats: "接続",
subscriptionCount: "サブスクリプション",
producerCount: "プロデューサー",
partitionDetails: "パーティション",
tableInbound: "In",
tableOutbound: "Out",
tableInboundThroughput: "In スループット",
tableOutboundThroughput: "Out スループット",
tableBacklogMessages: "バックログ msg",
tableBacklogSize: "バックログサイズ",
tableProducers: "プロデューサー",
tableSubscriptions: "サブスクリプション",
producers: "プロデューサー",
subscriptions: "サブスクリプション",
tableName: "名前",
tableRate: "レート",
tableAddress: "アドレス",
tableType: "タイプ",
tableBacklog: "バックログ",
tableConsumers: "コンシューマー",
noProducers: "プロデューサーがありません",
noSubscriptions: "サブスクリプションがありません",
noPartitionMetricsFromBroker: "Broker からパーティションメトリクスが返されませんでした",
nonPartitionedTopicNoDetails: "非パーティショントピックにはパーティション詳細がありません",
healthIndicators: "ヘルス",
messageFlow: "メッセージフロー",
backlogStatus: "バックログ",
producersLabel: "プロデューサー",
subscriptionsLabel: "サブスクリプション",
flowActive: "アクティブ",
flowIdle: "アイドル",
backlogNormal: "正常",
backlogHigh: "高",
producerConnected: "接続済み",
producerDisconnected: "なし",
subscriptionActive: "アクティブ",
subscriptionNone: "なし",
chartLegendIn: "In",
chartLegendOut: "Out",
chartLegendMessages: "メッセージ",
chartLegendBytes: "Bytes",
chartLegendConsumerLag: "コンシューマー遅延",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "msg",
chartAxisBytes: "bytes",
chartAxisMs: "ms",
metaPartition: "パーティション {partition}",
metaOffset: "オフセット {offset}",
metaKey: "キー {key}",
},
mqTenants: {
title: "テナント管理",
createTenant: "テナントを作成",
notSupported: "このメッセージングシステムではテナント管理はサポートされていません",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
loading: "読み込み中...",
name: "名前",
adminRoles: "Admin Roles",
allowedClusters: "Allowed Clusters*",
actions: "操作",
none: "なし",
edit: "編集",
delete: "削除",
createDialogTitle: "テナントを作成",
editDialogTitle: "テナントを編集: {name}",
tenantName: "テナント名*",
tenantNamePlaceholder: "例: my-tenant",
addRolePlaceholder: "ロールを追加",
add: "追加",
selectClustersPlaceholder: "許可クラスターを選択",
noClustersDetectedPlaceholder: "クラスターが検出されませんでした。手動で追加してください",
clustersNotReturned: "接続からクラスター一覧が返されませんでした",
addClusterPlaceholder: "クラスターを手動で追加",
clustersRequiredHint: "許可クラスターを 1 つ以上選択または追加してください",
cancel: "キャンセル",
create: "作成",
save: "保存",
confirmDelete: "テナント「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
tenantNameRequired: "テナント名は必須です",
allowedClustersRequired: "許可クラスターは必須です",
},
mqNamespaces: {
title: "名前空間管理",
createNamespace: "名前空間を作成",
notSupported: "このメッセージングシステムでは名前空間管理はサポートされていません",
selectTenantFirst: "先にテナントを選択してください",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
loading: "読み込み中...",
name: "名前",
tenant: "テナント",
adminRoles: "Admin Roles",
actions: "操作",
none: "なし",
editRoles: "ロールを編集",
delete: "削除",
createDialogTitle: "名前空間を作成",
namespaceName: "名前空間名*",
namespaceNamePlaceholder: "例: my-namespace",
cancel: "キャンセル",
create: "作成",
confirmDelete: "名前空間「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
namespaceNameRequired: "名前空間名は必須です",
},
mqSubscriptions: {
title: "サブスクリプション管理",
createSubscription: "サブスクリプションを作成",
selectTopicFirst: "先にトピックを選択してください",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
loading: "読み込み中...",
noSubscriptions: "このトピックにはサブスクリプションがありません",
subscriptionName: "サブスクリプション名",
type: "タイプ",
backlog: "バックログ",
consumeRate: "消費レート",
consumers: "コンシューマー",
actions: "操作",
consumerCount: "{count} コンシューマー",
msgRate: "{rate} msg/s",
resetCursor: "カーソルをリセット",
skipMessages: "メッセージをスキップ",
clearBacklog: "バックログをクリア",
peek: "Peek",
expireMessages: "メッセージを期限切れにする",
delete: "削除",
createDialogTitle: "サブスクリプションを作成",
topic: "トピック",
subscriptionNameLabel: "サブスクリプション名*",
subscriptionNamePlaceholder: "例: my-subscription",
startPosition: "開始位置",
startFromEarliest: "最古のメッセージから (Earliest)",
startFromLatest: "最新のメッセージから (Latest)",
resetDialogTitle: "カーソルをリセット: {name}",
resetTo: "リセット先",
earliest: "最古のメッセージ (Earliest)",
latest: "最新のメッセージ (Latest)",
timestamp: "特定のタイムスタンプ",
timestampMs: "タイムスタンプ (ms)",
currentTime: "現在時刻: {time}",
reset: "リセット",
skipDialogTitle: "メッセージをスキップ: {name}",
skipMode: "スキップモード",
skipCount: "指定件数をスキップ",
skipAll: "すべてのバックログをスキップ",
skipCountLabel: "スキップ件数",
skip: "スキップ",
peekDialogTitle: "メッセージを Peek: {name}",
count: "件数",
refresh: "更新",
noPeekMessages: "表示するメッセージがありません",
close: "閉じる",
expireDialogTitle: "メッセージを期限切れにする: {name}",
expireSeconds: "期限 (秒)",
expireHint: "{seconds} 秒より古いメッセージをすべて削除します",
expire: "期限切れ",
cancel: "キャンセル",
create: "作成",
confirmDelete: "サブスクリプション「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
confirmClearBacklog: "サブスクリプション「{name}」のバックログメッセージをすべてクリアしますか?",
subscriptionNameRequired: "サブスクリプション名は必須です",
peekMessageKey: "key={key}",
},
mqClients: {
title: "プロデューサー / コンシューマー",
unloadTopic: "トピックをアンロード",
unloading: "アンロード中...",
refresh: "更新",
refreshing: "更新中...",
selectTopicFirst: "先にトピックを選択してください",
confirmUnload: "このトピックをアンロードしますか?アクティブなプロデューサーとコンシューマーは再接続します。",
aggregateTopic: "集約トピック",
kafkaPartitionStatus: "Kafka パーティションステータス",
partitionCount: "{count} パーティション",
partition: "パーティション",
beginOffset: "Begin offset",
latestOffset: "Latest offset",
messageCount: "メッセージ",
leader: "Leader",
replicas: "Replicas",
isr: "ISR",
partitionClients: "パーティションクライアント",
inboundRate: "受信レート",
outboundRate: "送信レート",
producers: "プロデューサー",
subscriptions: "サブスクリプション",
consumers: "コンシューマー",
activeProducers: "アクティブ プロデューサー",
noActiveProducers: "アクティブ プロデューサーがありません",
name: "名前",
id: "ID",
address: "アドレス",
version: "バージョン",
rate: "レート",
throughput: "スループット",
rateValue: "{value} msg/s",
activeConsumers: "アクティブ コンシューマー",
noSubscriptions: "このトピックにはサブスクリプションがありません",
noConsumersOnPartition: "このパーティションのサブスクリプションにアクティブ コンシューマーがありません",
noConsumersOnSubscription: "このサブスクリプションにアクティブ コンシューマーがありません",
permits: "許可数",
},
mqPolicies: {
title: "ポリシー管理",
refresh: "更新",
refreshing: "更新中...",
selectTopicFirst: "先にトピックを選択してください",
selectNamespaceOrTopic: "名前空間またはトピックを選択してください",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
readonlyHint: "この接続は読み取り専用のため、ポリシー編集は無効です。",
publishRate: "Publish レート制限",
msgsPerSecond: "メッセージ / 秒",
bytesPerSecond: "Bytes / 秒",
save: "保存",
dispatchRate: "Dispatch レート制限",
msgsPerPeriod: "メッセージ / 期間",
bytesPerPeriod: "Bytes / 期間",
periodSeconds: "期間 (秒)",
subscribeRate: "Subscribe レート制限",
msgsPerConsumerPerPeriod: "コンシューマーあたりメッセージ / 期間",
backlogQuota: "バックログクォータ",
sizeLimitBytes: "サイズ上限 (bytes)",
timeLimitSeconds: "時間上限 (秒)",
policy: "ポリシー",
type: "タイプ",
backlogPolicyProducerRequestHold: "プロデューサー リクエストを保留",
backlogPolicyProducerException: "例外で拒否",
backlogPolicyConsumerBacklogEviction: "コンシューマー バックログを退避",
retention: "メッセージ保持",
retentionTimeMinutes: "保持時間 (分)",
retentionSizeMb: "保持サイズ (MB)",
effectivePolicies: "有効なポリシー",
savedNotice: "{policy} を保存しました",
},
mqPermissions: {
title: "権限",
refresh: "更新",
refreshing: "更新中...",
selectNamespaceOrTopic: "先に名前空間またはトピックを選択してください",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
readonlyHint: "この接続は読み取り専用のため、付与と取り消しは無効です。",
grantRole: "ロールを付与",
roleName: "ロール名",
roleNamePlaceholder: "例: app-producer",
actions: "操作",
grant: "付与",
currentPermissions: "現在の権限",
role: "ロール",
operations: "操作",
token: "Token",
revoke: "取り消し",
noPermissions: "権限レコードがありません",
clientTokenTitle: "クライアント Token: {role}",
missingSigningKeyTitle: "Token 署名キーが未設定です",
missingSigningKeyMessage: "この MQ 接続には Broker Token 署名キーが設定されていないため、クライアント Token を発行できません。",
missingSigningKeyDetail: "接続を編集し、「Broker Token 署名」を HS256 SECRET または RS256 PRIVATE に設定し、Token 生成前に署名キーを入力してください。",
tokenShowOnceWarning: "Token は一度だけ表示されます。すぐにコピーして保存してください。",
copyToken: "Token をコピー",
issueNewToken: "新しい Token を発行",
neverExpires: "無期限",
expiryDays: "有効期限 (日)",
note: "メモ",
notePlaceholder: "例: rt-erp-server 用",
issuing: "発行中...",
generateToken: "Token を生成",
tokenRevokeHint: "ロール権限の取り消しは、発行済み JWT を即座に無効化しません。有効期限を待つか、Broker 署名キーをローテーションしてください。",
issueRecords: "発行記録",
time: "時刻",
algorithm: "アルゴリズム",
expires: "有効期限",
fingerprint: "フィンガープリント",
noteColumn: "メモ",
unlimited: "無期限",
loading: "読み込み中...",
noIssueRecords: "発行記録がありません",
close: "閉じる",
kafkaAuthorizerNotConfigured: "この Kafka クラスターには Authorizer が設定されていません。ACL サポートには broker 設定で authorizer.class.name を追加してください。",
kafkaAuthorizerDisabled: "この Kafka クラスターでは Authorizer が有効になっていません。権限付与には broker 設定で Authorizer を有効にしてください。",
roleNameRequired: "ロール名は必須です",
selectAtLeastOneAction: "操作を 1 つ以上選択してください",
grantedRole: "{role} を付与しました",
confirmRevoke: "ロール「{role}」の権限を取り消しますか?",
revokedRole: "{role} を取り消しました",
roleNameEmpty: "ロール名を空にすることはできません",
expiryMustBePositive: "有効期限は 0 日より大きい必要があります",
},
mqBroker: {
title: "Broker クラスター",
autoRefresh: "自動更新",
refreshInterval5s: "5秒",
refreshInterval10s: "10秒",
refreshInterval30s: "30秒",
refreshInterval60s: "60秒",
refreshing: "更新中...",
refreshNow: "今すぐ更新",
loading: "読み込み中...",
clusterOverview: "クラスター概要",
clusterId: "Cluster ID",
unknown: "不明",
brokerCount: "Broker",
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker ノード",
nodeId: "Node ID",
host: "Host",
port: "Port",
rack: "Rack",
role: "ロール",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Broker ノード情報がありません",
},
mqRaw: {
title: "Raw API",
sending: "送信中...",
sendRequest: "リクエストを送信",
readOnlyHint: "この接続は読み取り専用のため、GET、HEAD、OPTIONS リクエストのみ許可されています。",
commonEndpoints: "よく使うエンドポイント",
fillFromSelection: "現在の選択からリクエストを入力",
expand: "展開",
collapse: "折りたたむ",
method: "Method",
path: "Path",
pathPlaceholder: "/admin/v2/...",
queryParams: "クエリパラメータ",
queryPlaceholder: "key=value (1行1件)",
jsonBody: "JSON Body",
format: "整形",
bodyPlaceholder: "例: {'{'}\"key\":\"value\"{'}'}",
response: "レスポンス",
textResponse: "テキストレスポンス",
noRequestYet: "まだリクエストが送信されていません",
readOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
pathRequired: "リクエスト Path は必須です",
jsonBodyInvalid: "JSON Body が無効です: {error}",
presetBrokerVersion: "Broker バージョン",
presetBrokerVersionDesc: "Broker バージョン文字列",
presetClusters: "クラスター一覧",
presetClustersDesc: "設定済み Pulsar クラスター一覧",
presetTenant: "テナント詳細",
presetTenantDesc: "adminRoles / allowedClusters を表示",
presetNamespacePolicies: "名前空間ポリシー",
presetNamespacePoliciesDesc: "名前空間ポリシーの生構造",
presetBundles: "Bundle 一覧",
presetBundlesDesc: "名前空間 Bundle 配分",
presetTopicInternalStats: "トピック内部統計",
presetTopicInternalStatsDesc: "Ledger / cursor / バックログ詳細",
presetPartitionedStats: "パーティションメトリクス",
presetPartitionedStatsDesc: "パーティションごとのレート、プロデューサー、コンシューマー",
presetSchema: "最新スキーマ",
presetSchemaDesc: "現在のトピックスキーマ定義",
},
production: {
title: "本番環境",
connection: "本番接続",

View File

@ -449,6 +449,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opcional, caminho na máquina do DBX Agent, por exemplo /etc/krb5.conf",
kafkaKerberosPathHint: "Os caminhos do keytab e do krb5.conf são lidos pelo DBX Agent e devem existir na máquina onde o DBX Agent é executado; os arquivos não são enviados do navegador atual.",
kafkaKerberosAuthHint: "Faça login usando GSSAPI + keytab. Se o servidor exigir transmissão criptografada, defina Security como SASL_SSL; caso contrário, você pode usar Auto ou SASL_PLAINTEXT.",
mqSystem: "Sistema",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Bootstrap Servers do Kafka são obrigatórios",
mqBootstrapServersInvalid: "Bootstrap Servers do Kafka são inválidos",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "Admin URL do MQ é obrigatório",
mqAdminUrlInvalid: "Admin URL do MQ é inválido",
mqAuth: "Autenticação",
mqAuthNone: "Nenhum",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Valor",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "A autenticação OAuth2 exige um Issuer URL",
mqOauthClientIdRequired: "A autenticação OAuth2 exige um Client ID",
mqOauthClientSecretRequired: "A autenticação OAuth2 exige um Client Secret",
mqTls: "TLS",
mqTlsSkipVerify: "Ignorar verificação de certificado",
mqPinnedVersion: "Versão fixada",
mqTokenSigning: "Assinatura de token do Broker",
mqTokenSigningNone: "Não configurado",
mqTokenSigningKey: "Chave de assinatura",
mqTokenSigningKeyRequired: "A chave de assinatura de token do Broker é obrigatória",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Escolha com base no jwt.broker.token.mode do Broker: SECRET usa HS256, PRIVATE usa RS256. A chave é armazenada nos segredos da conexão.",
databaseInfo: {
title: "Informações do Banco de Dados",
open: "Abrir informações do banco de dados {database}",
@ -3907,6 +3948,472 @@ export default withEnglishFallback({
partitionMustIncrease: "O novo número de partições deve ser maior que o atual.",
confirmDelete: 'Tem certeza de que deseja excluir o tópico "{name}"? Esta operação é irreversível.',
},
mqAdmin: {
viewTenant: "Ver locatário",
viewNamespace: "Ver namespace",
viewTopic: "Ver tópico",
readOnly: "Somente leitura",
tabTenants: "Locatários",
tabNamespaces: "Namespaces",
tabTopics: "Tópicos",
tabSubscriptions: "Assinaturas",
tabMonitoring: "Monitoramento",
tabClients: "Clientes",
tabMessages: "Mensagens",
tabBroker: "Broker",
tabPolicies: "Políticas",
tabPermissions: "Permissões",
tabRawApi: "Raw API",
},
mqMessages: {
title: "Enviar mensagem",
clear: "Limpar",
selectNamespaceOrTopicFirst: "Selecione primeiro um namespace ou tópico",
readOnlyCannotSend: "Esta conexão é somente leitura e não pode enviar mensagens",
selectTargetTopic: "Selecione um tópico de destino",
messageContentRequired: "O conteúdo da mensagem é obrigatório",
sendSuccess: "Mensagem enviada — partição: {partition}, offset: {offset}",
targetTopic: "Tópico de destino",
topicLoading: "Carregando...",
topicSearchPlaceholder: "Digite ou pesquise tópicos...",
refreshTopicList: "Atualizar lista de tópicos",
topicOptionWithPartitions: "{label} ({partitions} partições)",
noTopicsAvailable: "Nenhum tópico disponível",
topicSearchHint: "Pesquise por palavra-chave ou cole o nome de um tópico.",
messageKey: "Chave da mensagem",
optional: "Opcional",
messageContent: "Conteúdo da mensagem",
formatJson: "Formatar JSON",
messageHeaders: "Cabeçalhos",
headersPlaceholder: "chave: valor (um por linha)",
sending: "Enviando...",
sendMessage: "Enviar mensagem",
messageList: "Mensagens",
loadMessages: "Carregar mensagens",
loading: "Carregando...",
peekDefaultHint: "Sem partição definida, lê todas as partições desde Earliest, até {count} mensagens.",
count: "Quantidade",
advancedFilter: "Avançado (partição / offset)",
partition: "Partição",
partitionPlaceholderAll: "Vazio = todas",
offset: "Offset",
offsetPlaceholderEarliest: "Vazio = Earliest",
messagesLoading: "Carregando mensagens...",
noMessages: "Nenhuma mensagem",
selectTopicBeforeLoad: "Selecione um tópico antes de carregar mensagens",
partitionMustBeNonNegativeInt: "A partição deve ser um inteiro ≥ 0; deixe vazio para todas as partições",
offsetMustBeNonNegativeInt: "O Offset deve ser um inteiro ≥ 0; deixe vazio para Earliest",
metaPartition: "partição {partition}",
metaOffset: "offset {offset}",
metaKey: "chave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "Monitoramento",
autoRefresh: "Atualização automática",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Atualizando...",
refreshNow: "Atualizar",
selectTopicFirst: "Selecione um tópico primeiro",
loadingStats: "Carregando dados de monitoramento...",
kafkaTopicOverview: "Visão geral do tópico Kafka",
partitionCount: "Partições",
replicationFactor: "Fator de replicação",
messageCount: "Mensagens",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offsets e réplicas",
beginOffset: "Begin offset",
leaderCount: "Leaders",
isrHealthyPartitions: "ISR saudável",
noLeaderPartitions: "Sem leader",
kafkaPartitionDetails: "Partições Kafka",
noKafkaPartitionMetrics: "Nenhuma métrica de partição na resposta do Kafka",
tablePartition: "Partição",
tableBeginOffset: "Begin offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "Mensagens",
tableLeader: "Leader",
tableReplicas: "Réplicas",
tableIsr: "ISR",
tableStatus: "Status",
statusNoLeader: "Sem leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Saudável",
kafkaMessageQuery: "Consulta de mensagens Kafka",
querying: "Consultando...",
queryMessages: "Consultar",
queryHint: "Omita PARTITION / OFFSET para ler todas as partições (máx. 100). Adicione-os para restringir o intervalo.",
sqlSyntaxError: 'Apenas SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] é suportado',
messagesLoading: "Carregando mensagens...",
noMessages: "Nenhuma mensagem",
messageRate: "Taxa de mensagens",
inboundRate: "Taxa de entrada",
outboundRate: "Taxa de saída",
inboundThroughput: "Throughput de entrada",
outboundThroughput: "Throughput de saída",
rateTrend: "Tendência de taxa",
backlogTrend: "Tendência de backlog",
consumerLag: "Atraso do consumidor",
storageAndBacklog: "Armazenamento e backlog",
storageSize: "Tamanho do armazenamento",
backlogSize: "Tamanho do backlog",
backlogMessageCount: "Mensagens em backlog",
messageCounters: "Contadores de mensagens",
publishedMessages: "Publicadas",
consumedMessages: "Consumidas",
connectionStats: "Conexões",
subscriptionCount: "Assinaturas",
producerCount: "Produtores",
partitionDetails: "Partições",
tableInbound: "Entrada",
tableOutbound: "Saída",
tableInboundThroughput: "Throughput de entrada",
tableOutboundThroughput: "Throughput de saída",
tableBacklogMessages: "Msgs em backlog",
tableBacklogSize: "Tamanho do backlog",
tableProducers: "Produtores",
tableSubscriptions: "Assinaturas",
producers: "Produtores",
subscriptions: "Assinaturas",
tableName: "Nome",
tableRate: "Taxa",
tableAddress: "Endereço",
tableType: "Tipo",
tableBacklog: "Backlog",
tableConsumers: "Consumidores",
noProducers: "Nenhum produtor",
noSubscriptions: "Nenhuma assinatura",
noPartitionMetricsFromBroker: "Nenhuma métrica de partição do broker",
nonPartitionedTopicNoDetails: "Tópicos não particionados não têm detalhes de partição",
healthIndicators: "Saúde",
messageFlow: "Fluxo de mensagens",
backlogStatus: "Backlog",
producersLabel: "Produtores",
subscriptionsLabel: "Assinaturas",
flowActive: "Ativo",
flowIdle: "Ocioso",
backlogNormal: "Normal",
backlogHigh: "Alto",
producerConnected: "Conectado",
producerDisconnected: "Nenhum",
subscriptionActive: "Ativa",
subscriptionNone: "Nenhuma",
chartLegendIn: "Entrada",
chartLegendOut: "Saída",
chartLegendMessages: "Mensagens",
chartLegendBytes: "Bytes",
chartLegendConsumerLag: "Atraso do consumidor",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "msg",
chartAxisBytes: "bytes",
chartAxisMs: "ms",
metaPartition: "partição {partition}",
metaOffset: "offset {offset}",
metaKey: "chave {key}",
},
mqTenants: {
title: "Gerenciamento de locatários",
createTenant: "Criar locatário",
notSupported: "O gerenciamento de locatários não é suportado para este sistema de mensageria",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
loading: "Carregando...",
name: "Nome",
adminRoles: "Funções de administrador",
allowedClusters: "Clusters permitidos*",
actions: "Ações",
none: "Nenhum",
edit: "Editar",
delete: "Excluir",
createDialogTitle: "Criar locatário",
editDialogTitle: "Editar locatário: {name}",
tenantName: "Nome do locatário*",
tenantNamePlaceholder: "por exemplo: my-tenant",
addRolePlaceholder: "Adicionar função",
add: "Adicionar",
selectClustersPlaceholder: "Selecione os clusters permitidos",
noClustersDetectedPlaceholder: "Nenhum cluster detectado; adicione manualmente",
clustersNotReturned: "A conexão não retornou uma lista de clusters",
addClusterPlaceholder: "Adicionar cluster manualmente",
clustersRequiredHint: "Selecione ou adicione pelo menos um cluster permitido",
cancel: "Cancelar",
create: "Criar",
save: "Salvar",
confirmDelete: 'Excluir o locatário "{name}"? Esta ação não pode ser desfeita.',
tenantNameRequired: "O nome do locatário é obrigatório",
allowedClustersRequired: "Os clusters permitidos são obrigatórios",
},
mqNamespaces: {
title: "Gerenciamento de namespaces",
createNamespace: "Criar namespace",
notSupported: "O gerenciamento de namespaces não é suportado para este sistema de mensageria",
selectTenantFirst: "Selecione um locatário primeiro",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
loading: "Carregando...",
name: "Nome",
tenant: "Locatário",
adminRoles: "Funções de administrador",
actions: "Ações",
none: "Nenhum",
editRoles: "Editar funções",
delete: "Excluir",
createDialogTitle: "Criar namespace",
namespaceName: "Nome do namespace*",
namespaceNamePlaceholder: "por exemplo: my-namespace",
cancel: "Cancelar",
create: "Criar",
confirmDelete: 'Excluir o namespace "{name}"? Esta ação não pode ser desfeita.',
namespaceNameRequired: "O nome do namespace é obrigatório",
},
mqSubscriptions: {
title: "Gerenciamento de assinaturas",
createSubscription: "Criar assinatura",
selectTopicFirst: "Selecione um tópico primeiro",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
loading: "Carregando...",
noSubscriptions: "Nenhuma assinatura para este tópico",
subscriptionName: "Nome da assinatura",
type: "Tipo",
backlog: "Backlog",
consumeRate: "Taxa de consumo",
consumers: "Consumidores",
actions: "Ações",
consumerCount: "{count} consumidores",
msgRate: "{rate} msg/s",
resetCursor: "Redefinir cursor",
skipMessages: "Pular mensagens",
clearBacklog: "Limpar backlog",
peek: "Peek",
expireMessages: "Expirar mensagens",
delete: "Excluir",
createDialogTitle: "Criar assinatura",
topic: "Tópico",
subscriptionNameLabel: "Nome da assinatura*",
subscriptionNamePlaceholder: "por exemplo: my-subscription",
startPosition: "Posição inicial",
startFromEarliest: "Da mensagem mais antiga (Earliest)",
startFromLatest: "Da mensagem mais recente (Latest)",
resetDialogTitle: "Redefinir cursor: {name}",
resetTo: "Redefinir para",
earliest: "Mensagem mais antiga (Earliest)",
latest: "Mensagem mais recente (Latest)",
timestamp: "Timestamp específico",
timestampMs: "Timestamp (ms)",
currentTime: "Hora atual: {time}",
reset: "Redefinir",
skipDialogTitle: "Pular mensagens: {name}",
skipMode: "Modo de pulo",
skipCount: "Pular quantidade especificada",
skipAll: "Pular todo o backlog",
skipCountLabel: "Quantidade a pular",
skip: "Pular",
peekDialogTitle: "Peek de mensagens: {name}",
count: "Quantidade",
refresh: "Atualizar",
noPeekMessages: "Nenhuma mensagem para visualizar",
close: "Fechar",
expireDialogTitle: "Expirar mensagens: {name}",
expireSeconds: "Tempo de expiração (segundos)",
expireHint: "Excluirá todas as mensagens mais antigas que {seconds} segundos",
expire: "Expirar",
cancel: "Cancelar",
create: "Criar",
confirmDelete: 'Excluir a assinatura "{name}"? Esta ação não pode ser desfeita.',
confirmClearBacklog: 'Limpar todas as mensagens em backlog da assinatura "{name}"?',
subscriptionNameRequired: "O nome da assinatura é obrigatório",
peekMessageKey: "chave={key}",
},
mqClients: {
title: "Produtores / Consumidores",
unloadTopic: "Descarregar tópico",
unloading: "Descarregando...",
refresh: "Atualizar",
refreshing: "Atualizando...",
selectTopicFirst: "Selecione um tópico primeiro",
confirmUnload: "Descarregar este tópico? Produtores e consumidores ativos serão reconectados.",
aggregateTopic: "Tópico agregado",
kafkaPartitionStatus: "Status das partições Kafka",
partitionCount: "{count} partições",
partition: "Partição",
beginOffset: "Begin offset",
latestOffset: "Latest offset",
messageCount: "Mensagens",
leader: "Leader",
replicas: "Réplicas",
isr: "ISR",
partitionClients: "Clientes da partição",
inboundRate: "Taxa de entrada",
outboundRate: "Taxa de saída",
producers: "Produtores",
subscriptions: "Assinaturas",
consumers: "Consumidores",
activeProducers: "Produtores ativos",
noActiveProducers: "Nenhum produtor ativo",
name: "Nome",
id: "ID",
address: "Endereço",
version: "Versão",
rate: "Taxa",
throughput: "Throughput",
rateValue: "{value} msg/s",
activeConsumers: "Consumidores ativos",
noSubscriptions: "Este tópico não tem assinaturas",
noConsumersOnPartition: "Nenhum consumidor ativo nesta assinatura de partição",
noConsumersOnSubscription: "Nenhum consumidor ativo nesta assinatura",
permits: "Permissões",
},
mqPolicies: {
title: "Gerenciamento de políticas",
refresh: "Atualizar",
refreshing: "Atualizando...",
selectTopicFirst: "Selecione um tópico primeiro",
selectNamespaceOrTopic: "Selecione um namespace ou tópico",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
readonlyHint: "Esta conexão é somente leitura; a edição de políticas está desativada.",
publishRate: "Limite de taxa de publicação",
msgsPerSecond: "Mensagens / segundo",
bytesPerSecond: "Bytes / segundo",
save: "Salvar",
dispatchRate: "Limite de taxa de despacho",
msgsPerPeriod: "Mensagens / período",
bytesPerPeriod: "Bytes / período",
periodSeconds: "Período (segundos)",
subscribeRate: "Limite de taxa de assinatura",
msgsPerConsumerPerPeriod: "Mensagens por consumidor / período",
backlogQuota: "Cota de backlog",
sizeLimitBytes: "Limite de tamanho (bytes)",
timeLimitSeconds: "Limite de tempo (segundos)",
policy: "Política",
type: "Tipo",
backlogPolicyProducerRequestHold: "Reter solicitações do produtor",
backlogPolicyProducerException: "Rejeitar com exceção",
backlogPolicyConsumerBacklogEviction: "Remover backlog do consumidor",
retention: "Retenção de mensagens",
retentionTimeMinutes: "Tempo de retenção (minutos)",
retentionSizeMb: "Tamanho de retenção (MB)",
effectivePolicies: "Políticas efetivas",
savedNotice: "{policy} salva",
},
mqPermissions: {
title: "Permissões",
refresh: "Atualizar",
refreshing: "Atualizando...",
selectNamespaceOrTopic: "Selecione um namespace ou tópico primeiro",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
readonlyHint: "Esta conexão é somente leitura; conceder e revogar estão desativados.",
grantRole: "Conceder função",
roleName: "Nome da função",
roleNamePlaceholder: "por exemplo: app-producer",
actions: "Ações",
grant: "Conceder",
currentPermissions: "Permissões atuais",
role: "Função",
operations: "Operações",
token: "Token",
revoke: "Revogar",
noPermissions: "Nenhum registro de permissão",
clientTokenTitle: "Token do cliente: {role}",
missingSigningKeyTitle: "Chave de assinatura de token não configurada",
missingSigningKeyMessage: "Esta conexão MQ não tem chave de assinatura de token do Broker configurada, portanto tokens de cliente não podem ser emitidos.",
missingSigningKeyDetail: 'Edite a conexão e defina "Assinatura de token do Broker" como HS256 SECRET ou RS256 PRIVATE, depois forneça a chave de assinatura antes de gerar tokens.',
tokenShowOnceWarning: "O token é exibido apenas uma vez. Copie e armazene-o imediatamente.",
copyToken: "Copiar token",
issueNewToken: "Emitir novo token",
neverExpires: "Nunca expira",
expiryDays: "Expiração (dias)",
note: "Nota",
notePlaceholder: "por exemplo: para rt-erp-server",
issuing: "Emitindo...",
generateToken: "Gerar token",
tokenRevokeHint: "Revogar permissões de função não invalida JWTs emitidos imediatamente; aguarde a expiração ou rotacione a chave de assinatura do Broker.",
issueRecords: "Registros de emissão",
time: "Hora",
algorithm: "Algoritmo",
expires: "Expira",
fingerprint: "Fingerprint",
noteColumn: "Nota",
unlimited: "Permanente",
loading: "Carregando...",
noIssueRecords: "Nenhum registro de emissão",
close: "Fechar",
kafkaAuthorizerNotConfigured: "Este cluster Kafka não tem authorizer configurado. Adicione authorizer.class.name na configuração do broker para suporte a ACL.",
kafkaAuthorizerDisabled: "Este cluster Kafka não tem authorizer habilitado. Habilite um authorizer na configuração do broker para conceder permissões.",
roleNameRequired: "O nome da função é obrigatório",
selectAtLeastOneAction: "Selecione pelo menos uma ação",
grantedRole: "Função {role} concedida",
confirmRevoke: 'Revogar permissões da função "{role}"?',
revokedRole: "Função {role} revogada",
roleNameEmpty: "O nome da função não pode estar vazio",
expiryMustBePositive: "A expiração deve ser maior que 0 dias",
},
mqBroker: {
title: "Cluster Broker",
autoRefresh: "Atualização automática",
refreshInterval5s: "5s",
refreshInterval10s: "10s",
refreshInterval30s: "30s",
refreshInterval60s: "60s",
refreshing: "Atualizando...",
refreshNow: "Atualizar agora",
loading: "Carregando...",
clusterOverview: "Visão geral do cluster",
clusterId: "ID do cluster",
unknown: "Desconhecido",
brokerCount: "Brokers",
controller: "Controller",
controllerNode: "Nó {id} · {host}",
brokerNodes: "Nós do broker",
nodeId: "ID do nó",
host: "Host",
port: "Porta",
rack: "Rack",
role: "Função",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Nenhuma informação de nó do broker",
},
mqRaw: {
title: "Raw API",
sending: "Enviando...",
sendRequest: "Enviar solicitação",
readOnlyHint: "Esta conexão é somente leitura; apenas solicitações GET, HEAD e OPTIONS são permitidas.",
commonEndpoints: "Endpoints comuns",
fillFromSelection: "Preencher solicitação com a seleção atual",
expand: "Expandir",
collapse: "Recolher",
method: "Método",
path: "Caminho",
pathPlaceholder: "/admin/v2/...",
queryParams: "Parâmetros de consulta",
queryPlaceholder: "chave=valor, um por linha",
jsonBody: "JSON Body",
format: "Formatar",
bodyPlaceholder: "por exemplo: {'{'}\"key\":\"value\"{'}'}",
response: "Resposta",
textResponse: "Resposta em texto",
noRequestYet: "Nenhuma solicitação enviada ainda",
readOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
pathRequired: "O caminho da solicitação é obrigatório",
jsonBodyInvalid: "Corpo JSON inválido: {error}",
presetBrokerVersion: "Versão do Broker",
presetBrokerVersionDesc: "String de versão do Broker",
presetClusters: "Lista de clusters",
presetClustersDesc: "Listar clusters Pulsar configurados",
presetTenant: "Detalhes do locatário",
presetTenantDesc: "Ver adminRoles / allowedClusters",
presetNamespacePolicies: "Políticas do namespace",
presetNamespacePoliciesDesc: "Estrutura bruta de políticas do namespace",
presetBundles: "Lista de bundles",
presetBundlesDesc: "Distribuição de bundles do namespace",
presetTopicInternalStats: "Estatísticas internas do tópico",
presetTopicInternalStatsDesc: "Detalhes de ledger / cursor / backlog",
presetPartitionedStats: "Métricas de partição",
presetPartitionedStatsDesc: "Taxas, produtores e consumidores por partição",
presetSchema: "Schema mais recente",
presetSchemaDesc: "Definição atual do schema do tópico",
},
production: {
title: "Ambiente de Produção",
connection: "Conexão de Produção",

View File

@ -324,6 +324,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "可选DBX Agent 所在机器路径,例如 /etc/krb5.conf",
kafkaKerberosPathHint: "keytab 和 krb5.conf 路径由 DBX Agent 读取,必须存在于运行 DBX Agent 的机器上;不会从当前浏览器上传文件。",
kafkaKerberosAuthHint: "使用 GSSAPI + keytab 登录。若服务端要求加密传输,请将 Security 设为 SASL_SSL否则可使用 Auto 或 SASL_PLAINTEXT。",
mqSystem: "系统",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers 不能为空",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers 无效",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "MQ Admin URL 不能为空",
mqAdminUrlInvalid: "MQ Admin URL 无效",
mqAuth: "认证",
mqAuthNone: "无",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "OAuth2 需要填写 Issuer URL",
mqOauthClientIdRequired: "OAuth2 需要填写 Client ID",
mqOauthClientSecretRequired: "OAuth2 需要填写 Client Secret",
mqTls: "TLS",
mqTlsSkipVerify: "跳过证书校验",
mqPinnedVersion: "固定版本",
mqTokenSigning: "Broker Token 签发",
mqTokenSigningNone: "不配置",
mqTokenSigningKey: "签发密钥",
mqTokenSigningKeyRequired: "需要填写 Broker Token 签发密钥",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "按 Broker 的 jwt.broker.token.mode 选择SECRET 使用 HS256PRIVATE 使用 RS256。密钥会走连接 secret 存储。",
searchDatabasePlaceholder: "搜索数据库类型",
jdbcConnection: "JDBC 连接",
iconView: "图标视图",
@ -3886,7 +3927,7 @@ export default withEnglishFallback({
},
mqTopics: {
title: "主题管理",
searchPlaceholder: "搜索 topic",
searchPlaceholder: "搜索主题",
includeNonPersistent: "包含非持久化主题",
refresh: "刷新",
refreshing: "刷新中...",
@ -3922,11 +3963,477 @@ export default withEnglishFallback({
partitionMinHint: "分区数只能增加,不能减少。最小值:{min}",
update: "更新",
readOnly: "当前连接为只读模式,不能执行写操作。",
topicNameRequired: "Topic name 不能为空",
topicNameRequired: "主题名称不能为空",
currentPartitionsUnknown: "当前分区数未知,无法安全调整分区。",
partitionMustIncrease: "新分区数必须大于当前分区数。",
confirmDelete: "确定要删除主题「{name}」吗?此操作不可逆。",
},
mqAdmin: {
viewTenant: "查看租户",
viewNamespace: "查看命名空间",
viewTopic: "查看主题",
readOnly: "只读",
tabTenants: "租户",
tabNamespaces: "命名空间",
tabTopics: "主题",
tabSubscriptions: "订阅",
tabMonitoring: "监控",
tabClients: "客户端",
tabMessages: "消息",
tabBroker: "Broker",
tabPolicies: "策略",
tabPermissions: "权限",
tabRawApi: "Raw API",
},
mqMessages: {
title: "发送消息",
clear: "清空",
selectNamespaceOrTopicFirst: "请先选择命名空间或主题",
readOnlyCannotSend: "当前连接为只读模式,不能发送消息",
selectTargetTopic: "请选择目标主题",
messageContentRequired: "消息内容不能为空",
sendSuccess: "消息发送成功 — 分区: {partition},偏移: {offset}",
targetTopic: "目标主题",
topicLoading: "加载中...",
topicSearchPlaceholder: "输入或搜索主题...",
refreshTopicList: "刷新主题列表",
topicOptionWithPartitions: "{label} ({partitions} 分区)",
noTopicsAvailable: "暂无可用主题",
topicSearchHint: "可输入关键词搜索,也可以直接粘贴主题名称。",
messageKey: "消息键 (Key)",
optional: "可选",
messageContent: "消息内容",
formatJson: "格式化 JSON",
messageHeaders: "消息头 (Headers)",
headersPlaceholder: "key: value每行一个",
sending: "发送中...",
sendMessage: "发送消息",
messageList: "消息列表",
loadMessages: "加载消息",
loading: "加载中...",
peekDefaultHint: "未指定分区时读取全部区,从各区 earliest 起,最多 {count} 条。",
count: "数量",
advancedFilter: "高级筛选(分区 / Offset",
partition: "分区",
partitionPlaceholderAll: "留空=全部",
offset: "Offset",
offsetPlaceholderEarliest: "留空=最早",
messagesLoading: "消息加载中...",
noMessages: "暂无消息",
selectTopicBeforeLoad: "请先选择主题再加载消息",
partitionMustBeNonNegativeInt: "分区必须是大于等于 0 的整数;留空表示查询全部分区",
offsetMustBeNonNegativeInt: "Offset 必须是大于等于 0 的整数;留空表示从各分区最早可读位置开始",
metaPartition: "分区 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "键 {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "监控统计",
autoRefresh: "自动刷新",
refreshInterval5s: "5秒",
refreshInterval10s: "10秒",
refreshInterval30s: "30秒",
refreshInterval60s: "60秒",
refreshing: "刷新中...",
refreshNow: "立即刷新",
selectTopicFirst: "请先选择一个主题",
loadingStats: "加载监控数据...",
kafkaTopicOverview: "Kafka Topic 概览",
partitionCount: "分区数",
replicationFactor: "副本因子",
messageCount: "消息数",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offset 与副本状态",
beginOffset: "起始 offset",
leaderCount: "Leader 数",
isrHealthyPartitions: "ISR 健康分区",
noLeaderPartitions: "无 leader 分区",
kafkaPartitionDetails: "Kafka 分区明细",
noKafkaPartitionMetrics: "当前 Kafka 响应未返回分区指标",
tablePartition: "分区",
tableBeginOffset: "起始 offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "消息数",
tableLeader: "Leader",
tableReplicas: "Replicas",
tableIsr: "ISR",
tableStatus: "状态",
statusNoLeader: "无 leader",
statusIsrIncomplete: "ISR 不完整",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka 消息查询",
querying: "查询中...",
queryMessages: "查询消息",
queryHint: "省略 PARTITION / OFFSET 时读取全部区,最多 100 条;指定分区或 offset 时仅读对应范围。",
sqlSyntaxError: '仅支持 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "消息加载中...",
noMessages: "暂无消息",
messageRate: "消息速率",
inboundRate: "入站速率",
outboundRate: "出站速率",
inboundThroughput: "入站吞吐量",
outboundThroughput: "出站吞吐量",
rateTrend: "速率趋势",
backlogTrend: "积压趋势",
consumerLag: "消费延迟",
storageAndBacklog: "存储与积压",
storageSize: "存储大小",
backlogSize: "积压大小",
backlogMessageCount: "积压消息数",
messageCounters: "消息计数器",
publishedMessages: "已发布消息",
consumedMessages: "已消费消息",
connectionStats: "连接统计",
subscriptionCount: "订阅数量",
producerCount: "生产者数量",
partitionDetails: "分区明细",
tableInbound: "入站",
tableOutbound: "出站",
tableInboundThroughput: "入站吞吐",
tableOutboundThroughput: "出站吞吐",
tableBacklogMessages: "积压消息",
tableBacklogSize: "积压大小",
tableProducers: "生产者",
tableSubscriptions: "订阅",
producers: "生产者",
subscriptions: "订阅",
tableName: "名称",
tableRate: "速率",
tableAddress: "地址",
tableType: "类型",
tableBacklog: "积压",
tableConsumers: "消费者",
noProducers: "暂无生产者",
noSubscriptions: "暂无订阅",
noPartitionMetricsFromBroker: "当前 Broker 响应未返回分区指标",
nonPartitionedTopicNoDetails: "非分区主题没有分区明细",
healthIndicators: "健康指标",
messageFlow: "消息流动",
backlogStatus: "积压状态",
producersLabel: "生产者",
subscriptionsLabel: "订阅",
flowActive: "活跃",
flowIdle: "空闲",
backlogNormal: "正常",
backlogHigh: "偏高",
producerConnected: "已连接",
producerDisconnected: "无连接",
subscriptionActive: "活跃",
subscriptionNone: "无订阅",
chartLegendIn: "入站",
chartLegendOut: "出站",
chartLegendMessages: "消息数",
chartLegendBytes: "字节数",
chartLegendConsumerLag: "消费延迟",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "消息",
chartAxisBytes: "字节",
chartAxisMs: "毫秒",
metaPartition: "分区 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "键 {key}",
},
mqTenants: {
title: "租户管理",
createTenant: "创建租户",
notSupported: "当前消息队列系统不支持租户管理",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
name: "名称",
adminRoles: "管理角色",
allowedClusters: "允许集群*",
actions: "操作",
none: "无",
edit: "编辑",
delete: "删除",
createDialogTitle: "创建租户",
editDialogTitle: "编辑租户: {name}",
tenantName: "租户名称*",
tenantNamePlaceholder: "例如: my-tenant",
addRolePlaceholder: "添加角色",
add: "添加",
selectClustersPlaceholder: "请选择允许集群",
noClustersDetectedPlaceholder: "未探测到集群,可手动添加",
clustersNotReturned: "当前连接未返回集群列表",
addClusterPlaceholder: "手动添加集群",
clustersRequiredHint: "至少选择或添加一个允许集群",
cancel: "取消",
create: "创建",
save: "保存",
confirmDelete: '确定要删除租户 "{name}" 吗?此操作不可逆。',
tenantNameRequired: "租户名称不能为空",
allowedClustersRequired: "至少选择一个允许集群",
},
mqNamespaces: {
title: "命名空间管理",
createNamespace: "创建命名空间",
notSupported: "当前消息队列系统不支持命名空间管理",
selectTenantFirst: "请先选择一个租户",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
name: "名称",
tenant: "租户",
adminRoles: "管理角色",
actions: "操作",
none: "无",
editRoles: "编辑角色",
delete: "删除",
createDialogTitle: "创建命名空间",
namespaceName: "命名空间名称*",
namespaceNamePlaceholder: "例如: my-namespace",
cancel: "取消",
create: "创建",
confirmDelete: '确定要删除命名空间 "{name}" 吗?此操作不可逆。',
namespaceNameRequired: "命名空间名称不能为空",
},
mqSubscriptions: {
title: "订阅管理",
createSubscription: "创建订阅",
selectTopicFirst: "请先选择一个主题",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
noSubscriptions: "该主题暂无订阅",
subscriptionName: "订阅名称",
type: "类型",
backlog: "积压消息",
consumeRate: "消费速率",
consumers: "消费者",
actions: "操作",
consumerCount: "{count} 个",
msgRate: "{rate} msg/s",
resetCursor: "重置游标",
skipMessages: "跳过消息",
clearBacklog: "清空积压",
peek: "Peek",
expireMessages: "过期消息",
delete: "删除",
createDialogTitle: "创建订阅",
topic: "主题",
subscriptionNameLabel: "订阅名称*",
subscriptionNamePlaceholder: "例如: my-subscription",
startPosition: "起始位置",
startFromEarliest: "从最早消息开始Earliest",
startFromLatest: "从最新消息开始Latest",
resetDialogTitle: "重置游标: {name}",
resetTo: "重置到",
earliest: "最早消息Earliest",
latest: "最新消息Latest",
timestamp: "指定时间戳",
timestampMs: "时间戳(毫秒)",
currentTime: "当前时间: {time}",
reset: "重置",
skipDialogTitle: "跳过消息: {name}",
skipMode: "跳过模式",
skipCount: "跳过指定数量",
skipAll: "跳过全部积压",
skipCountLabel: "跳过数量",
skip: "跳过",
peekDialogTitle: "Peek 消息: {name}",
count: "数量",
refresh: "刷新",
noPeekMessages: "没有可查看的消息",
close: "关闭",
expireDialogTitle: "过期消息: {name}",
expireSeconds: "过期时间(秒)",
expireHint: "将删除所有早于 {seconds} 秒的消息",
expire: "过期",
cancel: "取消",
create: "创建",
confirmDelete: '确定要删除订阅 "{name}" 吗?此操作不可逆。',
confirmClearBacklog: '确定要清空订阅 "{name}" 的所有积压消息吗?',
subscriptionNameRequired: "订阅名称不能为空",
peekMessageKey: "key={key}",
},
mqClients: {
title: "生产者 / 消费者",
unloadTopic: "卸载主题",
unloading: "卸载中...",
refresh: "刷新",
refreshing: "刷新中...",
selectTopicFirst: "请先选择一个主题",
confirmUnload: "确认卸载当前主题?活跃生产者和消费者会重新连接。",
aggregateTopic: "聚合主题",
kafkaPartitionStatus: "Kafka 分区状态",
partitionCount: "{count} 个分区",
partition: "分区",
beginOffset: "起始 offset",
latestOffset: "最新 offset",
messageCount: "消息数",
leader: "Leader",
replicas: "Replicas",
isr: "ISR",
partitionClients: "分区客户端",
inboundRate: "入站速率",
outboundRate: "出站速率",
producers: "生产者",
subscriptions: "订阅",
consumers: "消费者",
activeProducers: "活跃生产者",
noActiveProducers: "当前没有活跃生产者",
name: "名称",
id: "ID",
address: "地址",
version: "版本",
rate: "速率",
throughput: "吞吐",
rateValue: "{value} msg/s",
activeConsumers: "活跃消费者",
noSubscriptions: "当前主题没有订阅",
noConsumersOnPartition: "当前分区订阅没有活跃消费者",
noConsumersOnSubscription: "当前订阅没有活跃消费者",
permits: "许可数",
},
mqPolicies: {
title: "策略管理",
refresh: "刷新",
refreshing: "刷新中...",
selectTopicFirst: "请先选择主题",
selectNamespaceOrTopic: "请选择命名空间或主题",
readOnly: "当前连接为只读模式,不能执行写操作。",
readonlyHint: "当前连接为只读模式,策略编辑已禁用。",
publishRate: "发布限速",
msgsPerSecond: "消息数 / 秒",
bytesPerSecond: "字节数 / 秒",
save: "保存",
dispatchRate: "派发限速",
msgsPerPeriod: "消息数 / 周期",
bytesPerPeriod: "字节数 / 周期",
periodSeconds: "周期(秒)",
subscribeRate: "订阅限速",
msgsPerConsumerPerPeriod: "每消费者消息数 / 周期",
backlogQuota: "积压配额",
sizeLimitBytes: "大小限制(字节)",
timeLimitSeconds: "时间限制(秒)",
policy: "策略",
type: "类型",
backlogPolicyProducerRequestHold: "暂停生产者请求",
backlogPolicyProducerException: "抛出异常",
backlogPolicyConsumerBacklogEviction: "驱逐消费者积压",
retention: "消息保留",
retentionTimeMinutes: "保留时间(分钟)",
retentionSizeMb: "保留大小MB",
effectivePolicies: "当前有效策略",
savedNotice: "{policy}已保存",
},
mqPermissions: {
title: "权限管理",
refresh: "刷新",
refreshing: "刷新中...",
selectNamespaceOrTopic: "请先选择命名空间或主题",
readOnly: "当前连接为只读模式,不能执行写操作。",
readonlyHint: "当前连接为只读模式,授权和撤销已禁用。",
grantRole: "授权角色",
roleName: "角色名",
roleNamePlaceholder: "例如: app-producer",
actions: "权限动作",
grant: "授权",
currentPermissions: "当前权限",
role: "角色",
operations: "操作",
token: "Token",
revoke: "撤销",
noPermissions: "暂无权限记录",
clientTokenTitle: "客户端 Token: {role}",
missingSigningKeyTitle: "未配置 Token 签发密钥",
missingSigningKeyMessage: "当前 MQ 连接还没有配置 Broker Token 签发密钥,无法生成客户端 Token。",
missingSigningKeyDetail: "请编辑该连接,在 MQ 配置中设置「Broker Token 签发」为 HS256 SECRET 或 RS256 PRIVATE并填写签发密钥后再生成。",
tokenShowOnceWarning: "Token 仅显示一次,请立即复制并保存好。",
copyToken: "复制 Token",
issueNewToken: "签发新 Token",
neverExpires: "永不过期",
expiryDays: "有效期(天)",
note: "备注",
notePlaceholder: "例如: 发给 rt-erp-server",
issuing: "签发中...",
generateToken: "生成 Token",
tokenRevokeHint: "撤销角色权限不会让已签发 JWT 立即失效;需要等待过期,或轮换 Broker 签发密钥。",
issueRecords: "签发记录",
time: "时间",
algorithm: "算法",
expires: "过期",
fingerprint: "指纹",
noteColumn: "备注",
unlimited: "长期",
loading: "加载中...",
noIssueRecords: "暂无签发记录",
close: "关闭",
kafkaAuthorizerNotConfigured: "当前 Kafka 集群未启用权限管理Authorizer 未配置)。如需 ACL 功能,请在 Broker 配置中添加 authorizer.class.name。",
kafkaAuthorizerDisabled: "当前 Kafka 集群未启用权限管理,无法执行授权操作。请在 Broker 配置中启用 Authorizer。",
roleNameRequired: "请输入角色名",
selectAtLeastOneAction: "请至少选择一个权限动作",
grantedRole: "已授权 {role}",
confirmRevoke: "确定要撤销角色「{role}」的权限吗?",
revokedRole: "已撤销 {role}",
roleNameEmpty: "角色名不能为空",
expiryMustBePositive: "有效期必须大于 0 天",
},
mqBroker: {
title: "Broker 集群",
autoRefresh: "自动刷新",
refreshInterval5s: "5秒",
refreshInterval10s: "10秒",
refreshInterval30s: "30秒",
refreshInterval60s: "60秒",
refreshing: "刷新中...",
refreshNow: "立即刷新",
loading: "加载中...",
clusterOverview: "集群概览",
clusterId: "集群 ID",
unknown: "未知",
brokerCount: "Broker 数量",
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker 节点",
nodeId: "Node ID",
host: "Host",
port: "Port",
rack: "Rack",
role: "角色",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "暂无 Broker 节点信息",
},
mqRaw: {
title: "Raw API",
sending: "请求中...",
sendRequest: "发送请求",
readOnlyHint: "当前连接为只读模式,仅允许 GET、HEAD 和 OPTIONS 请求。",
commonEndpoints: "常用端点",
fillFromSelection: "按当前选择填充请求",
expand: "展开",
collapse: "收起",
method: "方法",
path: "路径",
pathPlaceholder: "/admin/v2/...",
queryParams: "查询参数",
queryPlaceholder: "key=value每行一个",
jsonBody: "JSON Body",
format: "格式化",
bodyPlaceholder: "例如: {'{'}\"key\":\"value\"{'}'}",
response: "响应",
textResponse: "文本响应",
noRequestYet: "尚未发送请求",
readOnly: "当前连接为只读模式,不能执行写操作。",
pathRequired: "请输入请求路径",
jsonBodyInvalid: "JSON Body 格式错误: {error}",
presetBrokerVersion: "Broker 版本",
presetBrokerVersionDesc: "查看 broker 暴露的版本字符串",
presetClusters: "集群列表",
presetClustersDesc: "列出 Pulsar 已配置的 clusters",
presetTenant: "租户详情",
presetTenantDesc: "查看 adminRoles / allowedClusters",
presetNamespacePolicies: "命名空间策略",
presetNamespacePoliciesDesc: "查看 namespace policies 原始结构",
presetBundles: "Bundle 列表",
presetBundlesDesc: "查看 namespace bundle 分布",
presetTopicInternalStats: "Topic 内部状态",
presetTopicInternalStatsDesc: "查看 ledger / cursor / backlog 细节",
presetPartitionedStats: "分区明细指标",
presetPartitionedStatsDesc: "按分区返回速率、生产者和消费者指标",
presetSchema: "Schema 最新版本",
presetSchemaDesc: "查看主题当前 schema 定义",
},
nacos: {
configs: "配置",
services: "服务",

View File

@ -449,6 +449,47 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "可選DBX Agent 所在機器路徑,例如 /etc/krb5.conf",
kafkaKerberosPathHint: "keytab 和 krb5.conf 路徑由 DBX Agent 讀取,必須存在於執行 DBX Agent 的機器上;不會從當前瀏覽器上傳檔案。",
kafkaKerberosAuthHint: "使用 GSSAPI + keytab 登入。若伺服器端要求加密傳輸,請將 Security 設為 SASL_SSL否則可使用 Auto 或 SASL_PLAINTEXT。",
mqSystem: "系統",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers 不能為空",
mqBootstrapServersInvalid: "Kafka Bootstrap Servers 無效",
mqSecurity: "Security",
mqSecurityAuto: "Auto",
mqAdminUrl: "Admin URL",
mqAdminUrlRequired: "MQ Admin URL 不能為空",
mqAdminUrlInvalid: "MQ Admin URL 無效",
mqAuth: "認證",
mqAuthNone: "無",
mqAuthToken: "Token",
mqAuthBasic: "Basic",
mqAuthKerberos: "Kerberos",
mqAuthApiKey: "API Key",
mqAuthOauth2: "OAuth2",
mqToken: "Token",
mqSaslMechanism: "SASL Mechanism",
mqApiKeyHeader: "Header",
mqApiKeyValue: "Value",
mqOauthIssuerUrl: "Issuer URL",
mqOauthClientId: "Client ID",
mqOauthClientSecret: "Client Secret",
mqOauthAudience: "Audience",
mqOauthScope: "Scope",
mqOauthIssuerRequired: "OAuth2 需要填寫 Issuer URL",
mqOauthClientIdRequired: "OAuth2 需要填寫 Client ID",
mqOauthClientSecretRequired: "OAuth2 需要填寫 Client Secret",
mqTls: "TLS",
mqTlsSkipVerify: "跳過憑證驗證",
mqPinnedVersion: "固定版本",
mqTokenSigning: "Broker Token 簽發",
mqTokenSigningNone: "不設定",
mqTokenSigningKey: "簽發金鑰",
mqTokenSigningKeyRequired: "需要填寫 Broker Token 簽發金鑰",
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "依 Broker 的 jwt.broker.token.mode 選擇SECRET 使用 HS256PRIVATE 使用 RS256。金鑰會透過連線 secret 儲存。",
databaseInfo: {
title: "資料庫資訊",
open: "開啟 {database} 資料庫資訊",
@ -3866,7 +3907,7 @@ export default withEnglishFallback({
},
mqTopics: {
title: "主題管理",
searchPlaceholder: "搜尋 topic",
searchPlaceholder: "搜尋主題",
includeNonPersistent: "包含非持久化主題",
refresh: "重新整理",
refreshing: "重新整理中...",
@ -3902,11 +3943,477 @@ export default withEnglishFallback({
partitionMinHint: "分區數只能增加,不能減少。最小值:{min}",
update: "更新",
readOnly: "目前連線為唯讀模式,無法執行寫入操作。",
topicNameRequired: "Topic name 不能為空",
topicNameRequired: "主題名稱不能為空",
currentPartitionsUnknown: "目前分區數未知,無法安全調整分區。",
partitionMustIncrease: "新分區數必須大於目前分區數。",
confirmDelete: "確定要刪除主題「{name}」嗎?此操作無法復原。",
},
mqAdmin: {
viewTenant: "檢視租用戶",
viewNamespace: "檢視命名空間",
viewTopic: "檢視主題",
readOnly: "唯讀",
tabTenants: "租用戶",
tabNamespaces: "命名空間",
tabTopics: "主題",
tabSubscriptions: "訂閱",
tabMonitoring: "監控",
tabClients: "用戶端",
tabMessages: "訊息",
tabBroker: "Broker",
tabPolicies: "策略",
tabPermissions: "權限",
tabRawApi: "Raw API",
},
mqMessages: {
title: "傳送訊息",
clear: "清空",
selectNamespaceOrTopicFirst: "請先選取命名空間或主題",
readOnlyCannotSend: "目前連線為唯讀模式,無法傳送訊息",
selectTargetTopic: "請選取目標主題",
messageContentRequired: "訊息內容不能為空",
sendSuccess: "訊息傳送成功 — 分區: {partition},偏移: {offset}",
targetTopic: "目標主題",
topicLoading: "載入中...",
topicSearchPlaceholder: "輸入或搜尋主題...",
refreshTopicList: "重新整理主題清單",
topicOptionWithPartitions: "{label} ({partitions} 分區)",
noTopicsAvailable: "暫無可用主題",
topicSearchHint: "可輸入關鍵字搜尋,也可以直接貼上主題名稱。",
messageKey: "訊息鍵 (Key)",
optional: "可選",
messageContent: "訊息內容",
formatJson: "格式化 JSON",
messageHeaders: "訊息標頭 (Headers)",
headersPlaceholder: "key: value每行一個",
sending: "傳送中...",
sendMessage: "傳送訊息",
messageList: "訊息清單",
loadMessages: "載入訊息",
loading: "載入中...",
peekDefaultHint: "未指定分區時讀取全部分區,從各分區 earliest 起,最多 {count} 則。",
count: "數量",
advancedFilter: "進階篩選(分區 / Offset",
partition: "分區",
partitionPlaceholderAll: "留空=全部",
offset: "Offset",
offsetPlaceholderEarliest: "留空=最早",
messagesLoading: "訊息載入中...",
noMessages: "暫無訊息",
selectTopicBeforeLoad: "請先選取主題再載入訊息",
partitionMustBeNonNegativeInt: "分區必須是大於等於 0 的整數;留空表示查詢全部分區",
offsetMustBeNonNegativeInt: "Offset 必須是大於等於 0 的整數;留空表示從各分區最早可讀位置開始",
metaPartition: "分區 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "鍵 {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
},
mqMonitoring: {
title: "監控統計",
autoRefresh: "自動重新整理",
refreshInterval5s: "5 秒",
refreshInterval10s: "10 秒",
refreshInterval30s: "30 秒",
refreshInterval60s: "60 秒",
refreshing: "重新整理中...",
refreshNow: "立即重新整理",
selectTopicFirst: "請先選取一個主題",
loadingStats: "載入監控資料...",
kafkaTopicOverview: "Kafka Topic 概覽",
partitionCount: "分區數",
replicationFactor: "複本因子",
messageCount: "訊息數",
logEndOffset: "Log end offset",
offsetAndReplicaStatus: "Offset 與複本狀態",
beginOffset: "起始 offset",
leaderCount: "Leader 數",
isrHealthyPartitions: "ISR 健康分區",
noLeaderPartitions: "無 leader 分區",
kafkaPartitionDetails: "Kafka 分區明細",
noKafkaPartitionMetrics: "目前 Kafka 回應未回傳分區指標",
tablePartition: "分區",
tableBeginOffset: "起始 offset",
tableLogEndOffset: "Log end offset",
tableMessageCount: "訊息數",
tableLeader: "Leader",
tableReplicas: "Replicas",
tableIsr: "ISR",
tableStatus: "狀態",
statusNoLeader: "無 leader",
statusIsrIncomplete: "ISR 不完整",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka 訊息查詢",
querying: "查詢中...",
queryMessages: "查詢訊息",
queryHint: "省略 PARTITION / OFFSET 時讀取全部分區,最多 100 則;指定分區或 offset 時僅讀取對應範圍。",
sqlSyntaxError: '僅支援 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "訊息載入中...",
noMessages: "暫無訊息",
messageRate: "訊息速率",
inboundRate: "入站速率",
outboundRate: "出站速率",
inboundThroughput: "入站吞吐量",
outboundThroughput: "出站吞吐量",
rateTrend: "速率趨勢",
backlogTrend: "積壓趨勢",
consumerLag: "消費延遲",
storageAndBacklog: "儲存與積壓",
storageSize: "儲存大小",
backlogSize: "積壓大小",
backlogMessageCount: "積壓訊息數",
messageCounters: "訊息計數器",
publishedMessages: "已發布訊息",
consumedMessages: "已消費訊息",
connectionStats: "連線統計",
subscriptionCount: "訂閱數量",
producerCount: "生產者數量",
partitionDetails: "分區明細",
tableInbound: "入站",
tableOutbound: "出站",
tableInboundThroughput: "入站吞吐",
tableOutboundThroughput: "出站吞吐",
tableBacklogMessages: "積壓訊息",
tableBacklogSize: "積壓大小",
tableProducers: "生產者",
tableSubscriptions: "訂閱",
producers: "生產者",
subscriptions: "訂閱",
tableName: "名稱",
tableRate: "速率",
tableAddress: "位址",
tableType: "類型",
tableBacklog: "積壓",
tableConsumers: "消費者",
noProducers: "暫無生產者",
noSubscriptions: "暫無訂閱",
noPartitionMetricsFromBroker: "目前 Broker 回應未回傳分區指標",
nonPartitionedTopicNoDetails: "非分區主題沒有分區明細",
healthIndicators: "健康指標",
messageFlow: "訊息流動",
backlogStatus: "積壓狀態",
producersLabel: "生產者",
subscriptionsLabel: "訂閱",
flowActive: "活躍",
flowIdle: "閒置",
backlogNormal: "正常",
backlogHigh: "偏高",
producerConnected: "已連線",
producerDisconnected: "無連線",
subscriptionActive: "活躍",
subscriptionNone: "無訂閱",
chartLegendIn: "入站",
chartLegendOut: "出站",
chartLegendMessages: "訊息數",
chartLegendBytes: "位元組數",
chartLegendConsumerLag: "消費延遲",
chartAxisMsgPerSec: "msg/s",
chartAxisMsg: "訊息",
chartAxisBytes: "位元組",
chartAxisMs: "毫秒",
metaPartition: "分區 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "鍵 {key}",
},
mqTenants: {
title: "租用戶管理",
createTenant: "建立租用戶",
notSupported: "目前訊息佇列系統不支援租用戶管理",
readOnly: "目前連線為唯讀模式,無法執行寫入操作",
loading: "載入中...",
name: "名稱",
adminRoles: "管理角色",
allowedClusters: "允許叢集*",
actions: "操作",
none: "無",
edit: "編輯",
delete: "刪除",
createDialogTitle: "建立租用戶",
editDialogTitle: "編輯租用戶: {name}",
tenantName: "租用戶名稱*",
tenantNamePlaceholder: "例如: my-tenant",
addRolePlaceholder: "新增角色",
add: "新增",
selectClustersPlaceholder: "請選取允許叢集",
noClustersDetectedPlaceholder: "未偵測到叢集,可手動新增",
clustersNotReturned: "目前連線未回傳叢集清單",
addClusterPlaceholder: "手動新增叢集",
clustersRequiredHint: "至少選取或新增一個允許叢集",
cancel: "取消",
create: "建立",
save: "儲存",
confirmDelete: '確定要刪除租用戶 "{name}" 嗎?此操作無法復原。',
tenantNameRequired: "租用戶名稱不能為空",
allowedClustersRequired: "至少選取一個允許叢集",
},
mqNamespaces: {
title: "命名空間管理",
createNamespace: "建立命名空間",
notSupported: "目前訊息佇列系統不支援命名空間管理",
selectTenantFirst: "請先選取一個租用戶",
readOnly: "目前連線為唯讀模式,無法執行寫入操作",
loading: "載入中...",
name: "名稱",
tenant: "租用戶",
adminRoles: "管理角色",
actions: "操作",
none: "無",
editRoles: "編輯角色",
delete: "刪除",
createDialogTitle: "建立命名空間",
namespaceName: "命名空間名稱*",
namespaceNamePlaceholder: "例如: my-namespace",
cancel: "取消",
create: "建立",
confirmDelete: '確定要刪除命名空間 "{name}" 嗎?此操作無法復原。',
namespaceNameRequired: "命名空間名稱不能為空",
},
mqSubscriptions: {
title: "訂閱管理",
createSubscription: "建立訂閱",
selectTopicFirst: "請先選取一個主題",
readOnly: "目前連線為唯讀模式,無法執行寫入操作",
loading: "載入中...",
noSubscriptions: "該主題暫無訂閱",
subscriptionName: "訂閱名稱",
type: "類型",
backlog: "積壓訊息",
consumeRate: "消費速率",
consumers: "消費者",
actions: "操作",
consumerCount: "{count} 個",
msgRate: "{rate} msg/s",
resetCursor: "重設游標",
skipMessages: "跳過訊息",
clearBacklog: "清空積壓",
peek: "Peek",
expireMessages: "過期訊息",
delete: "刪除",
createDialogTitle: "建立訂閱",
topic: "主題",
subscriptionNameLabel: "訂閱名稱*",
subscriptionNamePlaceholder: "例如: my-subscription",
startPosition: "起始位置",
startFromEarliest: "從最早訊息開始Earliest",
startFromLatest: "從最新訊息開始Latest",
resetDialogTitle: "重設游標: {name}",
resetTo: "重設至",
earliest: "最早訊息Earliest",
latest: "最新訊息Latest",
timestamp: "指定時間戳記",
timestampMs: "時間戳記(毫秒)",
currentTime: "目前時間: {time}",
reset: "重設",
skipDialogTitle: "跳過訊息: {name}",
skipMode: "跳過模式",
skipCount: "跳過指定數量",
skipAll: "跳過全部積壓",
skipCountLabel: "跳過數量",
skip: "跳過",
peekDialogTitle: "Peek 訊息: {name}",
count: "數量",
refresh: "重新整理",
noPeekMessages: "沒有可檢視的訊息",
close: "關閉",
expireDialogTitle: "過期訊息: {name}",
expireSeconds: "過期時間(秒)",
expireHint: "將刪除所有早於 {seconds} 秒的訊息",
expire: "過期",
cancel: "取消",
create: "建立",
confirmDelete: '確定要刪除訂閱 "{name}" 嗎?此操作無法復原。',
confirmClearBacklog: '確定要清空訂閱 "{name}" 的所有積壓訊息嗎?',
subscriptionNameRequired: "訂閱名稱不能為空",
peekMessageKey: "key={key}",
},
mqClients: {
title: "生產者 / 消費者",
unloadTopic: "卸載主題",
unloading: "卸載中...",
refresh: "重新整理",
refreshing: "重新整理中...",
selectTopicFirst: "請先選取一個主題",
confirmUnload: "確認卸載目前主題?活躍生產者和消費者會重新連線。",
aggregateTopic: "彙總主題",
kafkaPartitionStatus: "Kafka 分區狀態",
partitionCount: "{count} 個分區",
partition: "分區",
beginOffset: "起始 offset",
latestOffset: "最新 offset",
messageCount: "訊息數",
leader: "Leader",
replicas: "Replicas",
isr: "ISR",
partitionClients: "分區用戶端",
inboundRate: "入站速率",
outboundRate: "出站速率",
producers: "生產者",
subscriptions: "訂閱",
consumers: "消費者",
activeProducers: "活躍生產者",
noActiveProducers: "目前沒有活躍生產者",
name: "名稱",
id: "ID",
address: "位址",
version: "版本",
rate: "速率",
throughput: "吞吐",
rateValue: "{value} msg/s",
activeConsumers: "活躍消費者",
noSubscriptions: "目前主題沒有訂閱",
noConsumersOnPartition: "目前分區訂閱沒有活躍消費者",
noConsumersOnSubscription: "目前訂閱沒有活躍消費者",
permits: "許可數",
},
mqPolicies: {
title: "策略管理",
refresh: "重新整理",
refreshing: "重新整理中...",
selectTopicFirst: "請先選取主題",
selectNamespaceOrTopic: "請選取命名空間或主題",
readOnly: "目前連線為唯讀模式,無法執行寫入操作。",
readonlyHint: "目前連線為唯讀模式,策略編輯已停用。",
publishRate: "發布限速",
msgsPerSecond: "訊息數 / 秒",
bytesPerSecond: "位元組數 / 秒",
save: "儲存",
dispatchRate: "派發限速",
msgsPerPeriod: "訊息數 / 週期",
bytesPerPeriod: "位元組數 / 週期",
periodSeconds: "週期(秒)",
subscribeRate: "訂閱限速",
msgsPerConsumerPerPeriod: "每消費者訊息數 / 週期",
backlogQuota: "積壓配額",
sizeLimitBytes: "大小限制(位元組)",
timeLimitSeconds: "時間限制(秒)",
policy: "策略",
type: "類型",
backlogPolicyProducerRequestHold: "暫停生產者請求",
backlogPolicyProducerException: "拋出例外",
backlogPolicyConsumerBacklogEviction: "驅逐消費者積壓",
retention: "訊息保留",
retentionTimeMinutes: "保留時間(分鐘)",
retentionSizeMb: "保留大小MB",
effectivePolicies: "目前有效策略",
savedNotice: "{policy}已儲存",
},
mqPermissions: {
title: "權限管理",
refresh: "重新整理",
refreshing: "重新整理中...",
selectNamespaceOrTopic: "請先選取命名空間或主題",
readOnly: "目前連線為唯讀模式,無法執行寫入操作。",
readonlyHint: "目前連線為唯讀模式,授權和撤銷已停用。",
grantRole: "授權角色",
roleName: "角色名稱",
roleNamePlaceholder: "例如: app-producer",
actions: "權限動作",
grant: "授權",
currentPermissions: "目前權限",
role: "角色",
operations: "操作",
token: "Token",
revoke: "撤銷",
noPermissions: "暫無權限記錄",
clientTokenTitle: "用戶端 Token: {role}",
missingSigningKeyTitle: "未設定 Token 簽發金鑰",
missingSigningKeyMessage: "目前 MQ 連線尚未設定 Broker Token 簽發金鑰,無法產生用戶端 Token。",
missingSigningKeyDetail: "請編輯該連線,在 MQ 設定中將「Broker Token 簽發」設為 HS256 SECRET 或 RS256 PRIVATE並填寫簽發金鑰後再產生。",
tokenShowOnceWarning: "Token 僅顯示一次,請立即複製並妥善儲存。",
copyToken: "複製 Token",
issueNewToken: "簽發新 Token",
neverExpires: "永不過期",
expiryDays: "有效期(天)",
note: "備註",
notePlaceholder: "例如: 發給 rt-erp-server",
issuing: "簽發中...",
generateToken: "產生 Token",
tokenRevokeHint: "撤銷角色權限不會讓已簽發 JWT 立即失效;需要等待過期,或輪換 Broker 簽發金鑰。",
issueRecords: "簽發記錄",
time: "時間",
algorithm: "演算法",
expires: "過期",
fingerprint: "指紋",
noteColumn: "備註",
unlimited: "長期",
loading: "載入中...",
noIssueRecords: "暫無簽發記錄",
close: "關閉",
kafkaAuthorizerNotConfigured: "目前 Kafka 叢集未啟用權限管理Authorizer 未設定)。如需 ACL 功能,請在 Broker 設定中新增 authorizer.class.name。",
kafkaAuthorizerDisabled: "目前 Kafka 叢集未啟用權限管理,無法執行授權操作。請在 Broker 設定中啟用 Authorizer。",
roleNameRequired: "請輸入角色名稱",
selectAtLeastOneAction: "請至少選取一個權限動作",
grantedRole: "已授權 {role}",
confirmRevoke: "確定要撤銷角色「{role}」的權限嗎?",
revokedRole: "已撤銷 {role}",
roleNameEmpty: "角色名稱不能為空",
expiryMustBePositive: "有效期必須大於 0 天",
},
mqBroker: {
title: "Broker 叢集",
autoRefresh: "自動重新整理",
refreshInterval5s: "5 秒",
refreshInterval10s: "10 秒",
refreshInterval30s: "30 秒",
refreshInterval60s: "60 秒",
refreshing: "重新整理中...",
refreshNow: "立即重新整理",
loading: "載入中...",
clusterOverview: "叢集概覽",
clusterId: "叢集 ID",
unknown: "未知",
brokerCount: "Broker 數量",
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker 節點",
nodeId: "Node ID",
host: "Host",
port: "Port",
rack: "Rack",
role: "角色",
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "暫無 Broker 節點資訊",
},
mqRaw: {
title: "Raw API",
sending: "請求中...",
sendRequest: "傳送請求",
readOnlyHint: "目前連線為唯讀模式,僅允許 GET、HEAD 和 OPTIONS 請求。",
commonEndpoints: "常用端點",
fillFromSelection: "依目前選取填入請求",
expand: "展開",
collapse: "收合",
method: "方法",
path: "路徑",
pathPlaceholder: "/admin/v2/...",
queryParams: "查詢參數",
queryPlaceholder: "key=value每行一個",
jsonBody: "JSON Body",
format: "格式化",
bodyPlaceholder: "例如: {'{'}\"key\":\"value\"{'}'}",
response: "回應",
textResponse: "文字回應",
noRequestYet: "尚未傳送請求",
readOnly: "目前連線為唯讀模式,無法執行寫入操作。",
pathRequired: "請輸入請求路徑",
jsonBodyInvalid: "JSON Body 格式錯誤: {error}",
presetBrokerVersion: "Broker 版本",
presetBrokerVersionDesc: "檢視 broker 暴露的版本字串",
presetClusters: "叢集清單",
presetClustersDesc: "列出 Pulsar 已設定的 clusters",
presetTenant: "租用戶詳情",
presetTenantDesc: "檢視 adminRoles / allowedClusters",
presetNamespacePolicies: "命名空間策略",
presetNamespacePoliciesDesc: "檢視 namespace policies 原始結構",
presetBundles: "Bundle 清單",
presetBundlesDesc: "檢視 namespace bundle 分佈",
presetTopicInternalStats: "Topic 內部狀態",
presetTopicInternalStatsDesc: "檢視 ledger / cursor / backlog 細節",
presetPartitionedStats: "分區明細指標",
presetPartitionedStatsDesc: "依分區回傳速率、生產者和消費者指標",
presetSchema: "Schema 最新版本",
presetSchemaDesc: "檢視主題目前 schema 定義",
},
production: {
title: "生產環境",
connection: "生產連線",

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
describe("parseNonNegativeSafeInteger", () => {
it("accepts non-negative safe integers", () => {
expect(parseNonNegativeSafeInteger("0")).toBe(0);
expect(parseNonNegativeSafeInteger("20")).toBe(20);
expect(parseNonNegativeSafeInteger(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER);
});
it("rejects decimals and non-integers", () => {
expect(parseNonNegativeSafeInteger("1.9")).toBeNull();
expect(parseNonNegativeSafeInteger("0.1")).toBeNull();
expect(parseNonNegativeSafeInteger("abc")).toBeNull();
});
it("rejects negatives and values outside the safe integer range", () => {
expect(parseNonNegativeSafeInteger("-1")).toBeNull();
expect(parseNonNegativeSafeInteger(String(Number.MAX_SAFE_INTEGER + 1))).toBeNull();
});
it("rejects empty input", () => {
expect(parseNonNegativeSafeInteger("")).toBeNull();
expect(parseNonNegativeSafeInteger(" ")).toBeNull();
});
});

View File

@ -18,8 +18,8 @@ describe("mqTenantForm", () => {
});
it("requires both a tenant name and at least one allowed cluster", () => {
expect(validateTenantForm("", { adminRoles: [], allowedClusters: ["standalone"] })).toBe("Tenant name is required");
expect(validateTenantForm("tenant-a", { adminRoles: [], allowedClusters: [] })).toBe("Allowed clusters are required");
expect(validateTenantForm("", { adminRoles: [], allowedClusters: ["standalone"] })).toBe("mqTenants.tenantNameRequired");
expect(validateTenantForm("tenant-a", { adminRoles: [], allowedClusters: [] })).toBe("mqTenants.allowedClustersRequired");
expect(validateTenantForm("tenant-a", { adminRoles: [], allowedClusters: ["standalone"] })).toBeUndefined();
});
});

View File

@ -2,20 +2,23 @@ import { describe, expect, it } from "vitest";
import { formatMqTokenIssueError } from "@/lib/mq/mqTokenErrors";
describe("MQ token errors", () => {
it("turns missing signing configuration backend errors into actionable Chinese copy", () => {
it("turns missing signing configuration backend errors into locale keys", () => {
const result = formatMqTokenIssueError("/api/mq/tokens/issue returned 500: Token signing is not configured for this MQ connection");
expect(result.kind).toBe("missingSigningKey");
expect(result.title).toBe("未配置 Token 签发密钥");
expect(result.message).toContain("无法生成客户端 Token");
expect(result.detail).toContain("Broker Token 签发");
if (result.kind === "missingSigningKey") {
expect(result.titleKey).toBe("mqPermissions.missingSigningKeyTitle");
expect(result.messageKey).toBe("mqPermissions.missingSigningKeyMessage");
expect(result.detailKey).toBe("mqPermissions.missingSigningKeyDetail");
}
});
it("keeps unrelated errors as ordinary messages", () => {
const result = formatMqTokenIssueError("network failed");
expect(result.kind).toBe("generic");
expect(result.title).toBeUndefined();
expect(result.message).toBe("network failed");
if (result.kind === "generic") {
expect(result.message).toBe("network failed");
}
});
});

View File

@ -0,0 +1,12 @@
/** Parse a non-negative safe integer from user input; rejects decimals and unsafe magnitudes. */
export function parseNonNegativeSafeInteger(text: string): number | null {
const trimmed = text.trim();
if (trimmed === "") {
return null;
}
const value = Number(trimmed);
if (!Number.isSafeInteger(value) || value < 0) {
return null;
}
return value;
}

View File

@ -33,10 +33,10 @@ export function defaultTenantConfig(clusterOptions: readonly string[]): TenantCo
export function validateTenantForm(name: string | undefined, config: TenantConfig): string | undefined {
if (!name?.trim()) {
return "Tenant name is required";
return "mqTenants.tenantNameRequired";
}
if (!normalizeClusterOptions(config.allowedClusters).length) {
return "Allowed clusters are required";
return "mqTenants.allowedClustersRequired";
}
return undefined;
}

View File

@ -1,15 +1,16 @@
export type MqTokenIssueErrorView =
| {
kind: "missingSigningKey";
title: string;
message: string;
detail: string;
titleKey: "mqPermissions.missingSigningKeyTitle";
messageKey: "mqPermissions.missingSigningKeyMessage";
detailKey: "mqPermissions.missingSigningKeyDetail";
}
| {
kind: "generic";
title?: undefined;
titleKey?: undefined;
messageKey?: undefined;
detailKey?: undefined;
message: string;
detail?: undefined;
};
export function formatMqTokenIssueError(error: unknown): MqTokenIssueErrorView {
@ -17,9 +18,9 @@ export function formatMqTokenIssueError(error: unknown): MqTokenIssueErrorView {
if (isMissingTokenSigningConfig(message)) {
return {
kind: "missingSigningKey",
title: "未配置 Token 签发密钥",
message: "当前 MQ 连接还没有配置 Broker Token 签发密钥,无法生成客户端 Token。",
detail: "请编辑该连接,在 MQ 配置中设置“Broker Token 签发”为 HS256 SECRET 或 RS256 PRIVATE并填写签发密钥后再生成。",
titleKey: "mqPermissions.missingSigningKeyTitle",
messageKey: "mqPermissions.missingSigningKeyMessage",
detailKey: "mqPermissions.missingSigningKeyDetail",
};
}
return { kind: "generic", message };

View File

@ -308,39 +308,47 @@ impl MessageQueueAdmin for KafkaAdmin {
options: PeekMessagesOptions,
) -> Result<Vec<PeekedMessage>, String> {
let conn_params = build_connection_params(&self.config);
let result: serde_json::Value = self
.call(
"mq_peek_messages",
serde_json::json!({
"topic": topic.topic,
"partition": options.partition.unwrap_or(0),
"offset": options.offset.unwrap_or(0),
"count": count,
"connection": conn_params,
}),
)
.await?;
let mut params = serde_json::json!({
"topic": topic.topic,
"count": count,
"connection": conn_params,
});
// Omit partition/offset so the agent defaults to all partitions + earliest.
// Do not coerce missing values to 0 — that forced PARTITION 0 OFFSET 0 UX.
if let Some(partition) = options.partition {
params["partition"] = serde_json::json!(partition);
}
if let Some(offset) = options.offset {
params["offset"] = serde_json::json!(offset);
}
let result: serde_json::Value = self.call("mq_peek_messages", params).await?;
let messages = result.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default();
Ok(messages
.into_iter()
.enumerate()
.map(|(idx, m)| PeekedMessage {
position: (idx + 1) as u32,
message_id: m.get("offset").and_then(|v| v.as_i64()).map(|v| v.to_string()),
key: m.get("key").and_then(|v| v.as_str()).map(String::from),
publish_time: m.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
event_time: None,
properties: HashMap::new(),
headers: m
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect()
})
.unwrap_or_default(),
payload_base64: m.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
payload_text: m.get("payloadText").and_then(|v| v.as_str()).map(String::from),
.map(|(idx, m)| {
let mut properties = HashMap::new();
if let Some(partition) = m.get("partition").and_then(|v| v.as_i64()) {
properties.insert("partition".to_string(), partition.to_string());
}
PeekedMessage {
position: (idx + 1) as u32,
message_id: m.get("offset").and_then(|v| v.as_i64()).map(|v| v.to_string()),
key: m.get("key").and_then(|v| v.as_str()).map(String::from),
publish_time: m.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
event_time: None,
properties,
headers: m
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect()
})
.unwrap_or_default(),
payload_base64: m.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
payload_text: m.get("payloadText").and_then(|v| v.as_str()).map(String::from),
}
})
.collect())
}

View File

@ -440,7 +440,9 @@ pub struct PeekedMessage {
}
/// Optional hints for reading messages. Pulsar ignores these today; Kafka uses
/// them to select a partition and starting offset for a non-committing peek.
/// them to optionally narrow a non-committing peek to one partition / offset.
/// When omitted, Kafka peeks across all partitions from each partition's earliest
/// readable offset (still capped by `count`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PeekMessagesOptions {