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