feat(etcd): add cluster operations and access control

This commit is contained in:
二丫讲梵 2026-07-31 01:06:02 +08:00 committed by GitHub
parent 774b1612dc
commit 99b1b73967
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 7502 additions and 750 deletions

View File

@ -70,6 +70,29 @@ public final class AgentProtocol {
public static final String KV_METHOD_RENAME = "kv_rename";
public static final String KV_METHOD_HISTORY = "kv_history";
public static final String KV_METHOD_STATUS = "kv_status";
public static final String ETCD_METHOD_COMPACT = "etcd_compact";
public static final String ETCD_METHOD_DEFRAG = "etcd_defrag";
public static final String ETCD_METHOD_WATCH_START = "etcd_watch_start";
public static final String ETCD_METHOD_WATCH_POLL = "etcd_watch_poll";
public static final String ETCD_METHOD_WATCH_STOP = "etcd_watch_stop";
public static final String ETCD_METHOD_LEASE_LIST = "etcd_lease_list";
public static final String ETCD_METHOD_LEASE_GET = "etcd_lease_get";
public static final String ETCD_METHOD_LEASE_GRANT = "etcd_lease_grant";
public static final String ETCD_METHOD_LEASE_KEEPALIVE = "etcd_lease_keepalive_once";
public static final String ETCD_METHOD_LEASE_REVOKE = "etcd_lease_revoke";
public static final String ETCD_METHOD_AUTH_USER_LIST = "etcd_auth_user_list";
public static final String ETCD_METHOD_AUTH_USER_GET = "etcd_auth_user_get";
public static final String ETCD_METHOD_AUTH_USER_ADD = "etcd_auth_user_add";
public static final String ETCD_METHOD_AUTH_USER_DELETE = "etcd_auth_user_delete";
public static final String ETCD_METHOD_AUTH_USER_CHANGE_PASSWORD = "etcd_auth_user_change_password";
public static final String ETCD_METHOD_AUTH_USER_GRANT_ROLE = "etcd_auth_user_grant_role";
public static final String ETCD_METHOD_AUTH_USER_REVOKE_ROLE = "etcd_auth_user_revoke_role";
public static final String ETCD_METHOD_AUTH_ROLE_LIST = "etcd_auth_role_list";
public static final String ETCD_METHOD_AUTH_ROLE_GET = "etcd_auth_role_get";
public static final String ETCD_METHOD_AUTH_ROLE_ADD = "etcd_auth_role_add";
public static final String ETCD_METHOD_AUTH_ROLE_DELETE = "etcd_auth_role_delete";
public static final String ETCD_METHOD_AUTH_ROLE_GRANT_PERMISSION = "etcd_auth_role_grant_permission";
public static final String ETCD_METHOD_AUTH_ROLE_REVOKE_PERMISSION = "etcd_auth_role_revoke_permission";
public static final String CAPABILITY_CONNECT = "connect";
public static final String CAPABILITY_TEST_CONNECTION = "test_connection";
@ -84,6 +107,11 @@ public final class AgentProtocol {
public static final String CAPABILITY_KV_LIST_VALUES = "kv_list_values";
public static final String CAPABILITY_KV_STATUS = "kv_status";
public static final String CAPABILITY_KV_HISTORY = "kv_history";
public static final String CAPABILITY_ETCD_COMPACTION = "etcd_compaction";
public static final String CAPABILITY_ETCD_DEFRAG = "etcd_defrag";
public static final String CAPABILITY_ETCD_WATCH = "etcd_watch";
public static final String CAPABILITY_ETCD_LEASE = "etcd_lease";
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
@ -109,9 +137,17 @@ public final class AgentProtocol {
CAPABILITY_KV_CAS,
CAPABILITY_KV_LIST_VALUES,
CAPABILITY_KV_STATUS,
CAPABILITY_KV_HISTORY
CAPABILITY_KV_HISTORY,
CAPABILITY_ETCD_COMPACTION,
CAPABILITY_ETCD_DEFRAG,
CAPABILITY_ETCD_WATCH,
CAPABILITY_ETCD_LEASE,
CAPABILITY_ETCD_AUTH
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_ALL_CAPABILITIES;
public static final List<String> COMMON_METHODS = Collections.unmodifiableList(Arrays.asList(
METHOD_HANDSHAKE,
METHOD_CONNECT,
@ -150,6 +186,14 @@ public final class AgentProtocol {
public static final List<String> MULTI_SESSION_METHODS;
static {
List<String> capabilities = new java.util.ArrayList<>(CAPABILITIES);
capabilities.add(CAPABILITY_MULTI_SESSION);
MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(capabilities);
List<String> allCapabilities = new java.util.ArrayList<>(ALL_CAPABILITIES);
allCapabilities.add(CAPABILITY_MULTI_SESSION);
MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities);
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
methods.addAll(insertAt, Arrays.asList(
@ -185,7 +229,30 @@ public final class AgentProtocol {
KV_METHOD_DELETE,
KV_METHOD_RENAME,
KV_METHOD_HISTORY,
KV_METHOD_STATUS
KV_METHOD_STATUS,
ETCD_METHOD_COMPACT,
ETCD_METHOD_DEFRAG,
ETCD_METHOD_WATCH_START,
ETCD_METHOD_WATCH_POLL,
ETCD_METHOD_WATCH_STOP,
ETCD_METHOD_LEASE_LIST,
ETCD_METHOD_LEASE_GET,
ETCD_METHOD_LEASE_GRANT,
ETCD_METHOD_LEASE_KEEPALIVE,
ETCD_METHOD_LEASE_REVOKE,
ETCD_METHOD_AUTH_USER_LIST,
ETCD_METHOD_AUTH_USER_GET,
ETCD_METHOD_AUTH_USER_ADD,
ETCD_METHOD_AUTH_USER_DELETE,
ETCD_METHOD_AUTH_USER_CHANGE_PASSWORD,
ETCD_METHOD_AUTH_USER_GRANT_ROLE,
ETCD_METHOD_AUTH_USER_REVOKE_ROLE,
ETCD_METHOD_AUTH_ROLE_LIST,
ETCD_METHOD_AUTH_ROLE_GET,
ETCD_METHOD_AUTH_ROLE_ADD,
ETCD_METHOD_AUTH_ROLE_DELETE,
ETCD_METHOD_AUTH_ROLE_GRANT_PERMISSION,
ETCD_METHOD_AUTH_ROLE_REVOKE_PERMISSION
));
private AgentProtocol() {
@ -196,9 +263,11 @@ public final class AgentProtocol {
}
public static HandshakeResult multiSessionHandshakeResult() {
List<String> capabilities = new java.util.ArrayList<>(CAPABILITIES);
capabilities.add(CAPABILITY_MULTI_SESSION);
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_CAPABILITIES
);
}
public static final class HandshakeResult {

View File

@ -26,6 +26,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L;
private final Supplier<? extends DatabaseAgent> agentFactory;
private final Supplier<? extends SessionRpcHandler> sessionHandlerFactory;
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final ExecutorService requests = Executors.newCachedThreadPool();
private final JdbcConnectionPoolRegistry poolRegistry;
@ -52,9 +53,21 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
JdbcConnectionPoolRegistry poolRegistry
) {
this.agentFactory = agentFactory;
this.sessionHandlerFactory = null;
this.poolRegistry = poolRegistry;
}
private MultiSessionJsonRpcServer(Supplier<? extends SessionRpcHandler> sessionHandlerFactory, boolean customHandler) {
this.agentFactory = null;
this.sessionHandlerFactory = sessionHandlerFactory;
this.poolRegistry = new JdbcConnectionPoolRegistry();
}
/** Creates a protocol v2 server for a non-JDBC, session-scoped agent. */
public static MultiSessionJsonRpcServer forSessionHandlers(Supplier<? extends SessionRpcHandler> sessionHandlerFactory) {
return new MultiSessionJsonRpcServer(sessionHandlerFactory, true);
}
public void run() {
synchronized (outputLock) {
protocolOutput.println("{\"ready\":true}");
@ -94,7 +107,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = AgentProtocol.multiSessionHandshakeResult();
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionHandshakeResult() : customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@ -105,7 +118,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
session(requiredSessionId(params)).cancel();
result = Collections.singletonMap("ok", true);
} else if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
result = new JsonRpcServer(agentFactory.get()).dispatchForRuntime(method, params);
result = sessionHandlerFactory == null
? new JsonRpcServer(agentFactory.get()).dispatchForRuntime(method, params)
: testSession(params);
} else if (AgentProtocol.METHOD_CONNECT.equals(method)) {
closeSession(LEGACY_SESSION_ID);
result = openSession(LEGACY_SESSION_ID, params);
@ -131,18 +146,23 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
}
DatabaseAgent agent = agentFactory.get();
if (poolRegistry.isEnabled() && agent instanceof AbstractJdbcAgent jdbcAgent) {
jdbcAgent.attachConnectionPoolRegistry(poolRegistry);
ensureMaintenanceStarted();
Session session;
if (sessionHandlerFactory != null) {
session = new Session(sessionHandlerFactory.get());
} else {
DatabaseAgent agent = agentFactory.get();
if (poolRegistry.isEnabled() && agent instanceof AbstractJdbcAgent jdbcAgent) {
jdbcAgent.attachConnectionPoolRegistry(poolRegistry);
ensureMaintenanceStarted();
}
session = new Session(new JsonRpcServer(agent));
}
Session session = new Session(new JsonRpcServer(agent));
Session existing = sessions.putIfAbsent(sessionId, session);
if (existing != null) {
throw new IllegalStateException("Agent session already exists: " + sessionId);
}
try {
return session.handle(AgentProtocol.METHOD_CONNECT, params);
return session.connect(params);
} catch (Exception error) {
sessions.remove(sessionId, session);
session.close();
@ -158,6 +178,24 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
return Collections.singletonMap("ok", true);
}
private Object testSession(JsonObject params) throws Exception {
Session session = new Session(sessionHandlerFactory.get());
try {
return session.connect(params);
} finally {
session.close();
}
}
private Object customHandshake() {
SessionRpcHandler handler = sessionHandlerFactory.get();
try {
return handler.handshake();
} finally {
handler.close();
}
}
private Session session(String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
@ -253,16 +291,34 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private static final class Session {
private final JsonRpcServer server;
private final SessionRpcHandler handler;
private final ReentrantLock lock = new ReentrantLock();
private Session(JsonRpcServer server) {
this.server = server;
this.handler = null;
}
private Session(SessionRpcHandler handler) {
this.server = null;
this.handler = handler;
}
private Object handle(String method, JsonObject params) throws Exception {
lock.lock();
try {
return server.dispatchForRuntime(method, params);
return handler == null ? server.dispatchForRuntime(method, params) : handler.handle(method, params);
} finally {
lock.unlock();
}
}
private Object connect(JsonObject params) throws Exception {
lock.lock();
try {
return handler == null
? server.dispatchForRuntime(AgentProtocol.METHOD_CONNECT, params)
: handler.connect(params);
} finally {
lock.unlock();
}
@ -271,7 +327,11 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private void close() {
lock.lock();
try {
server.dispatchForRuntime(AgentProtocol.METHOD_DISCONNECT, new JsonObject());
if (handler != null) {
handler.close();
} else {
server.dispatchForRuntime(AgentProtocol.METHOD_DISCONNECT, new JsonObject());
}
} catch (Exception ignored) {
} finally {
lock.unlock();
@ -279,10 +339,17 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
}
private void cancel() {
server.cancelActiveStatements();
if (handler == null) {
server.cancelActiveStatements();
} else {
handler.cancel();
}
}
private void expireIdleResources() {
if (handler != null) {
return;
}
if (!lock.tryLock()) {
return;
}
@ -294,6 +361,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
}
private void expireIdleResources(long nowMillis, long idleTimeoutMillis) {
if (handler != null) {
return;
}
if (!lock.tryLock()) {
return;
}

View File

@ -0,0 +1,23 @@
package com.dbx.agent;
import com.google.gson.JsonObject;
/**
* Per-session handler for protocol v2 agents that do not implement the JDBC
* {@link DatabaseAgent} contract. Implementations own every resource created
* for a logical DBX connection and must release it from {@link #close()}.
*/
public interface SessionRpcHandler {
default Object handshake() {
return AgentProtocol.multiSessionHandshakeResult();
}
Object connect(JsonObject params) throws Exception;
Object handle(String method, JsonObject params) throws Exception;
default void cancel() {
}
void close();
}

View File

@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@ -40,5 +40,5 @@
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status"]
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"]
}

View File

@ -2,7 +2,7 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "multi_session"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"commonMethods": [
@ -44,7 +44,7 @@
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
}

View File

@ -66,7 +66,26 @@ class CommonJavaCompatibilityTest {
JsonObject contract = protocolContract("/agent-protocol-v2.json");
assertEquals(AgentProtocol.MULTI_SESSION_PROTOCOL_VERSION, contract.get("protocolVersion").getAsInt());
assertEquals(AgentProtocol.METHOD_HANDSHAKE, contract.get("handshakeMethod").getAsString());
assertEquals(
Arrays.asList("protocolVersion", "agentProtocolVersion", "capabilities"),
strings(contract.getAsJsonArray("handshakeResponseFields"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_ALL_CAPABILITIES,
strings(contract.getAsJsonArray("allCapabilities"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_CAPABILITIES,
strings(contract.getAsJsonArray("capabilities"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_CAPABILITIES,
strings(contract.getAsJsonArray("defaultSqlCapabilities"))
);
assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods")));
assertEquals(AgentProtocol.MONGO_LEGACY_METHODS, strings(contract.getAsJsonArray("mongoLegacyMethods")));
assertEquals(AgentProtocol.KV_METHODS, strings(contract.getAsJsonArray("kvMethods")));
}
@Test

View File

@ -15,9 +15,11 @@ import io.etcd.jetcd.kv.TxnResponse;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
@ -34,13 +36,19 @@ final class EtcdAgentTest {
JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result");
Assertions.assertEquals(1, result.get("protocolVersion").getAsInt());
Assertions.assertEquals(2, result.get("protocolVersion").getAsInt());
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_ttl\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_cas\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_list_values\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_status\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_history\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_compaction\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_defrag\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_watch\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_lease\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_auth\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"multi_session\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\"")));
}
@ -65,6 +73,34 @@ final class EtcdAgentTest {
Assertions.assertEquals(List.of("http://127.0.0.1:2379"), EtcdAgent.endpoints(connection));
}
@Test
void connectTimeoutUsesConfiguredValueAndSafeBounds() {
Assertions.assertEquals(
45,
EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":45}").getAsJsonObject())
);
Assertions.assertEquals(
1,
EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":0}").getAsJsonObject())
);
Assertions.assertEquals(
300,
EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":999}").getAsJsonObject())
);
}
@Test
void validateConnectionRequiresAnActiveSession() {
String response = EtcdAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"validate_connection\",\"params\":{}}"
);
JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error");
Assertions.assertEquals(-1, error.get("code").getAsInt());
Assertions.assertEquals("Not connected", error.get("message").getAsString());
}
@Test
void kvMethodDispatchReturnsJsonRpcErrorWhenDisconnected() {
String response = EtcdAgent.handleRequest(
@ -210,6 +246,118 @@ final class EtcdAgentTest {
Assertions.assertEquals(123, revokedLeaseId.get());
}
@Test
void defragFailureMarksEveryRemainingEndpointAsNotExecuted() {
List<Map<String, Object>> members = new ArrayList<>();
EtcdAgent.appendUnexecutedDefragMembers(
members,
List.of("http://follower-2:2379", "http://follower-3:2379", "http://leader:2379"),
"http://follower-2:2379"
);
Assertions.assertEquals(
List.of("http://follower-3:2379", "http://leader:2379"),
members.stream().map(member -> member.get("endpoint")).toList()
);
Assertions.assertTrue(members.stream().allMatch(member -> "not_executed".equals(member.get("status"))));
}
@Test
void leaseListResponseParserReadsEveryLeaseId() throws Exception {
byte[] response = new byte[] {
0x12, 0x02, 0x08, 0x7b,
0x12, 0x03, 0x08, (byte) 0xc8, 0x03
};
Assertions.assertEquals(List.of(123L, 456L), EtcdAgent.leaseIdsFromResponse(response));
}
@Test
void leaseListPageUsesAStableCursorAndBoundsTheResult() {
List<Long> leaseIds = new ArrayList<>();
for (long id = 1000; id >= 1; id--) leaseIds.add(id);
List<Long> first = EtcdAgent.leasePageIds(leaseIds, null, 101);
List<Long> second = EtcdAgent.leasePageIds(leaseIds, first.get(99), 101);
Assertions.assertEquals(101, first.size());
Assertions.assertEquals(1L, first.get(0));
Assertions.assertEquals(101L, first.get(100));
Assertions.assertEquals(101L, second.get(0));
Assertions.assertEquals(201L, second.get(100));
}
@Test
void watchBufferRejectsLargeValueAndPreviousValuePayloads() {
long payloadBytes = EtcdAgent.estimatedBufferedBytes(2 * 1024 * 1024)
+ EtcdAgent.estimatedBufferedBytes(2 * 1024 * 1024);
Assertions.assertTrue(payloadBytes > EtcdAgent.MAX_WATCH_BUFFER_BYTES);
}
@Test
void watchOverflowPreservesBufferedPayloadAndReportsTerminalState() {
EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState();
EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session);
List<Map<String, Object>> events = List.of(Map.of("eventType", "put"));
watch.append(1, events, 4L * 1024 * 1024);
Assertions.assertEquals(4L * 1024 * 1024, session.watchBufferedBytes());
watch.append(2, events, 5L * 1024 * 1024);
Map<String, Object> result = watch.poll();
Assertions.assertEquals(0, session.watchBufferedBytes());
List<?> batches = (List<?>) result.get("batches");
Assertions.assertEquals(1, batches.size());
Assertions.assertEquals("1", ((Map<?, ?>) batches.get(0)).get("revision"));
Assertions.assertEquals("overflow", ((Map<?, ?>) result.get("terminal")).get("reason"));
}
@Test
void watchPollReleasesTheSessionByteBudget() {
EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState();
EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session);
watch.append(1, List.of(Map.of("eventType", "put")), 1024);
Assertions.assertEquals(1024, session.watchBufferedBytes());
watch.poll();
Assertions.assertEquals(0, session.watchBufferedBytes());
}
@Test
void aggregateSessionBudgetTerminatesOnlyTheWatchThatExceedsIt() {
EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState();
List<Map<String, Object>> events = List.of(Map.of("eventType", "put"));
EtcdAgent.EtcdWatchState first = new EtcdAgent.EtcdWatchState("watch-1", session);
EtcdAgent.EtcdWatchState second = new EtcdAgent.EtcdWatchState("watch-2", session);
EtcdAgent.EtcdWatchState third = new EtcdAgent.EtcdWatchState("watch-3", session);
first.append(1, events, EtcdAgent.MAX_WATCH_BUFFER_BYTES);
second.append(1, events, EtcdAgent.MAX_WATCH_BUFFER_BYTES);
third.append(1, events, 1);
Assertions.assertEquals(EtcdAgent.MAX_SESSION_WATCH_BUFFER_BYTES, session.watchBufferedBytes());
Assertions.assertEquals("overflow", ((Map<?, ?>) third.poll().get("terminal")).get("reason"));
Assertions.assertEquals(EtcdAgent.MAX_SESSION_WATCH_BUFFER_BYTES, session.watchBufferedBytes());
}
@Test
void terminalPollRemovesTheWatchSlot() {
EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState();
EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session);
session.addWatch("watch-1", watch);
watch.overflow();
Map<String, Object> result = EtcdAgent.pollWatchState(session, "watch-1");
Assertions.assertEquals("overflow", ((Map<?, ?>) result.get("terminal")).get("reason"));
Assertions.assertEquals(0, session.watchCount());
}
private static KV scriptedKv(
AtomicInteger getCalls,
AtomicInteger txnCalls,

View File

@ -0,0 +1,768 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { KeyRound, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus, UsersRound } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
type AccessView = "users" | "roles";
type PermissionAccess = "read" | "write" | "readwrite";
type PermissionResource = "all" | "key" | "prefix";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const view = ref<AccessView>("users");
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const notice = ref("");
const users = ref<string[]>([]);
const roles = ref<string[]>([]);
const selectedUser = ref("");
const selectedRole = ref("");
const userDetail = ref<api.EtcdAuthUserDetail | null>(null);
const roleDetail = ref<api.EtcdAuthRoleDetail | null>(null);
const detailLoading = ref(false);
const createUserOpen = ref(false);
const createRoleOpen = ref(false);
const passwordOpen = ref(false);
const permissionOpen = ref(false);
const approvalOpen = ref(false);
const newUser = ref("");
const newUserPassword = ref("");
const newUserRoles = ref<string[]>([]);
const newRole = ref("");
const newRolePermissionKey = ref("");
const newRolePermissionResource = ref<PermissionResource>("prefix");
const newRolePermissionAccess = ref<PermissionAccess>("readwrite");
const newPassword = ref("");
const selectedGrantRole = ref("");
const permissionKey = ref("");
const permissionResource = ref<PermissionResource>("prefix");
const permissionAccess = ref<PermissionAccess>("readwrite");
const editingPermission = ref<api.EtcdAuthPermission | null>(null);
const permissionError = ref("");
const approvalText = ref("");
const approvalExpected = ref("");
let pendingApproval: (() => Promise<void>) | null = null;
let detailRequest = 0;
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
const initialUserRoleLabel = computed(() => (newUserRoles.value.length ? t("etcd.access.createAndAssignRoles", { count: newUserRoles.value.length }) : t("etcd.access.createUserAction")));
const hasInitialRolePermission = computed(() => newRolePermissionResource.value === "all" || !!newRolePermissionKey.value);
const hasPermissionTarget = computed(() => permissionResource.value === "all" || !!permissionKey.value);
function reset(message = "") {
error.value = "";
notice.value = message;
}
function displayValue(value: api.KvValue): string {
if (value.encoding === "utf8") return value.data;
try {
const bytes = Uint8Array.from(atob(value.data), (character) => character.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return `base64:${value.data}`;
}
}
function permissionResourceOf(permission: api.EtcdAuthPermission): PermissionResource {
if (permission.resource) return permission.resource;
if (permission.key.data === "" && permission.rangeEnd.encoding === "base64" && permission.rangeEnd.data === "AA==") return "all";
return permission.rangeEnd.data ? "prefix" : "key";
}
function permissionLabel(access: PermissionAccess): string {
return access === "readwrite" ? t("etcd.access.readWrite") : access === "read" ? t("etcd.access.read") : t("etcd.access.write");
}
function permissionParams(permission: api.EtcdAuthPermission) {
return {
role: selectedRole.value,
key: displayValue(permission.key),
keyBytes: permission.key,
resource: permissionResourceOf(permission),
access: permission.access,
};
}
function errorMessage(caught: unknown): string {
const message = caught instanceof Error ? caught.message : String(caught);
if (message.includes("ETCD_PREFLIGHT_REQUIRED")) return t("etcd.access.preflightRequired");
if (message.includes("ETCD_PREFLIGHT_EXPIRED")) return t("etcd.access.preflightExpired");
if (message.includes("ETCD_PREFLIGHT_MISMATCH")) return t("etcd.access.preflightMismatch");
return message;
}
async function run(action: () => Promise<void>) {
busy.value = true;
reset();
try {
await action();
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
busy.value = false;
}
}
async function requestApproval(action: string, params: Record<string, unknown>, execute: (approval: api.EtcdDangerousApproval) => Promise<void>) {
if (readOnly.value) return;
try {
const preflight = await api.etcdPreflight(props.connectionId, action, params);
approvalExpected.value = preflight.confirmationText;
approvalText.value = "";
pendingApproval = () => execute({ preflightToken: preflight.token, confirmationText: preflight.confirmationText });
approvalOpen.value = true;
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
}
}
async function confirmApproval() {
if (approvalText.value !== approvalExpected.value || !pendingApproval) return;
const execute = pendingApproval;
pendingApproval = null;
approvalOpen.value = false;
await run(execute);
}
function closeApproval() {
approvalOpen.value = false;
pendingApproval = null;
approvalText.value = "";
}
async function loadDirectory() {
loading.value = true;
reset();
try {
const [userResponse, roleResponse] = await Promise.all([api.etcdAuthCall<api.EtcdAuthUserListResponse>(props.connectionId, "user_list", {}), api.etcdAuthCall<api.EtcdAuthRoleListResponse>(props.connectionId, "role_list", {})]);
users.value = userResponse.users ?? [];
roles.value = roleResponse.roles ?? [];
if (selectedUser.value && !users.value.includes(selectedUser.value)) {
selectedUser.value = "";
userDetail.value = null;
}
if (selectedRole.value && !roles.value.includes(selectedRole.value)) {
selectedRole.value = "";
roleDetail.value = null;
}
await ensureSelectedForView();
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
loading.value = false;
}
}
async function selectUser(user: string) {
const request = ++detailRequest;
selectedUser.value = user;
userDetail.value = null;
detailLoading.value = true;
try {
const detail = await api.etcdAuthCall<api.EtcdAuthUserDetail>(props.connectionId, "user_get", { user });
if (request !== detailRequest || view.value !== "users" || selectedUser.value !== user) return;
userDetail.value = detail;
} catch (caught) {
if (request !== detailRequest || view.value !== "users" || selectedUser.value !== user) return;
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
if (request === detailRequest) detailLoading.value = false;
}
}
async function selectRole(role: string) {
const request = ++detailRequest;
selectedRole.value = role;
roleDetail.value = null;
detailLoading.value = true;
try {
const detail = await api.etcdAuthCall<api.EtcdAuthRoleDetail>(props.connectionId, "role_get", { role });
if (request !== detailRequest || view.value !== "roles" || selectedRole.value !== role) return;
roleDetail.value = detail;
} catch (caught) {
if (request !== detailRequest || view.value !== "roles" || selectedRole.value !== role) return;
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
if (request === detailRequest) detailLoading.value = false;
}
}
async function ensureSelectedForView(targetView = view.value) {
if (targetView === "users") {
if (selectedUser.value && users.value.includes(selectedUser.value)) {
if (userDetail.value?.user === selectedUser.value) return;
await selectUser(selectedUser.value);
return;
}
const firstUser = users.value[0];
if (firstUser) await selectUser(firstUser);
return;
}
if (selectedRole.value && roles.value.includes(selectedRole.value)) {
if (roleDetail.value?.role === selectedRole.value) return;
await selectRole(selectedRole.value);
return;
}
const firstRole = roles.value[0];
if (firstRole) await selectRole(firstRole);
}
async function selectView(targetView: AccessView) {
if (view.value !== targetView) {
detailRequest++;
detailLoading.value = false;
}
view.value = targetView;
await ensureSelectedForView(targetView);
}
async function createUser() {
if (!newUser.value.trim() || !newUserPassword.value || readOnly.value) return;
const user = newUser.value.trim();
const password = newUserPassword.value;
const initialRoles = [...newUserRoles.value];
const create = async (approvals: api.EtcdDangerousApproval[]) => {
await api.etcdAuthCall(props.connectionId, "user_add", { user, password }, approvals[0]);
try {
for (const [index, role] of initialRoles.entries()) {
await api.etcdAuthCall(props.connectionId, "user_grant_role", { user, role }, approvals[index + 1]);
}
} catch (caught) {
await loadDirectory();
await selectUser(user);
createUserOpen.value = false;
throw new Error(t("etcd.access.createdUserRolesFailed", { error: errorMessage(caught) }));
}
newUser.value = "";
newUserPassword.value = "";
newUserRoles.value = [];
createUserOpen.value = false;
await loadDirectory();
await selectUser(user);
notice.value = initialRoles.length ? t("etcd.access.createdUserWithRoles", { user, count: initialRoles.length }) : t("etcd.access.createdUser", { user });
};
busy.value = true;
reset();
try {
const preflights = await Promise.all([api.etcdPreflight(props.connectionId, "auth_user_add", { user, password }), ...initialRoles.map((role) => api.etcdPreflight(props.connectionId, "auth_user_grant_role", { user, role }))]);
if (preflights.some((preflight) => preflight.confirmationText !== preflights[0].confirmationText)) {
throw new Error(t("etcd.access.confirmationMismatch"));
}
approvalExpected.value = preflights[0].confirmationText;
approvalText.value = "";
pendingApproval = () => create(preflights.map((preflight) => ({ preflightToken: preflight.token, confirmationText: preflight.confirmationText })));
approvalOpen.value = true;
} catch (caught) {
error.value = errorMessage(caught);
} finally {
busy.value = false;
}
}
function openCreateUser() {
newUser.value = "";
newUserPassword.value = "";
newUserRoles.value = [];
createUserOpen.value = true;
}
async function createRole() {
if (!newRole.value.trim() || readOnly.value) return;
const role = newRole.value.trim();
const initialPermission = hasInitialRolePermission.value ? { role, key: newRolePermissionResource.value === "all" ? "" : newRolePermissionKey.value, resource: newRolePermissionResource.value, access: newRolePermissionAccess.value } : null;
const create = async (approvals: api.EtcdDangerousApproval[]) => {
await api.etcdAuthCall(props.connectionId, "role_add", { role }, approvals[0]);
if (initialPermission) {
try {
await api.etcdAuthCall(props.connectionId, "role_grant_permission", initialPermission, approvals[1]);
} catch (caught) {
await loadDirectory();
await selectRole(role);
createRoleOpen.value = false;
throw new Error(t("etcd.access.createdRolePermissionFailed", { error: errorMessage(caught) }));
}
}
newRole.value = "";
newRolePermissionKey.value = "";
newRolePermissionResource.value = "prefix";
newRolePermissionAccess.value = "readwrite";
createRoleOpen.value = false;
await loadDirectory();
await selectRole(role);
notice.value = initialPermission ? t("etcd.access.createdRoleWithPermission", { role }) : t("etcd.access.createdRole", { role });
};
busy.value = true;
reset();
try {
const preflights = await Promise.all([api.etcdPreflight(props.connectionId, "auth_role_add", { role }), ...(initialPermission ? [api.etcdPreflight(props.connectionId, "auth_role_grant_permission", initialPermission)] : [])]);
if (preflights.some((preflight) => preflight.confirmationText !== preflights[0].confirmationText)) {
throw new Error(t("etcd.access.confirmationMismatch"));
}
approvalExpected.value = preflights[0].confirmationText;
approvalText.value = "";
pendingApproval = () => create(preflights.map((preflight) => ({ preflightToken: preflight.token, confirmationText: preflight.confirmationText })));
approvalOpen.value = true;
} catch (caught) {
error.value = errorMessage(caught);
} finally {
busy.value = false;
}
}
function deleteUser(user: string) {
const params = { user };
void requestApproval("auth_user_delete", params, async (approval) => {
await api.etcdAuthCall(props.connectionId, "user_delete", params, approval);
await loadDirectory();
notice.value = t("etcd.access.deletedUser", { user });
});
}
function deleteRole(role: string) {
const params = { role };
void requestApproval("auth_role_delete", params, async (approval) => {
await api.etcdAuthCall(props.connectionId, "role_delete", params, approval);
await loadDirectory();
notice.value = t("etcd.access.deletedRole", { role });
});
}
function changePassword() {
if (!selectedUser.value || !newPassword.value || readOnly.value) return;
const params = { user: selectedUser.value, password: newPassword.value };
void requestApproval("auth_user_change_password", params, async (approval) => {
await api.etcdAuthCall(props.connectionId, "user_change_password", params, approval);
newPassword.value = "";
passwordOpen.value = false;
notice.value = t("etcd.access.passwordUpdated", { user: selectedUser.value });
});
}
function updateUserRole(grant: boolean, role: string) {
if (!selectedUser.value || !role || readOnly.value) return;
const params = { user: selectedUser.value, role };
const operation = grant ? "user_grant_role" : "user_revoke_role";
const action = grant ? "auth_user_grant_role" : "auth_user_revoke_role";
void requestApproval(action, params, async (approval) => {
await api.etcdAuthCall(props.connectionId, operation, params, approval);
await selectUser(selectedUser.value);
selectedGrantRole.value = "";
notice.value = grant ? t("etcd.access.roleGranted", { role, user: params.user }) : t("etcd.access.roleRevoked", { role, user: params.user });
});
}
function openGrantPermission() {
editingPermission.value = null;
permissionError.value = "";
permissionKey.value = "";
permissionResource.value = "prefix";
permissionAccess.value = "readwrite";
permissionOpen.value = true;
}
function openEditPermission(permission: api.EtcdAuthPermission) {
editingPermission.value = permission;
permissionError.value = "";
permissionResource.value = permissionResourceOf(permission);
permissionKey.value = permissionResource.value === "all" ? "" : displayValue(permission.key);
permissionAccess.value = permission.access;
permissionOpen.value = true;
}
function grantPermission() {
if (!selectedRole.value || !hasPermissionTarget.value || readOnly.value) return;
const params = { role: selectedRole.value, key: permissionResource.value === "all" ? "" : permissionKey.value, resource: permissionResource.value, access: permissionAccess.value };
void requestApproval("auth_role_grant_permission", params, async (approval) => {
await api.etcdAuthCall(props.connectionId, "role_grant_permission", params, approval);
permissionKey.value = "";
permissionOpen.value = false;
await selectRole(selectedRole.value);
notice.value = t("etcd.access.permissionGranted");
});
}
function editPermission() {
const previous = editingPermission.value;
if (!previous || !selectedRole.value || !hasPermissionTarget.value || readOnly.value) return;
const oldParams = permissionParams(previous);
const newParams = { role: selectedRole.value, key: permissionResource.value === "all" ? "" : permissionKey.value, resource: permissionResource.value, access: permissionAccess.value };
if (oldParams.key === newParams.key && oldParams.resource === newParams.resource && oldParams.access === newParams.access) {
permissionOpen.value = false;
return;
}
void requestPermissionReplacementApproval(oldParams, newParams);
}
async function requestPermissionReplacementApproval(oldParams: Record<string, unknown>, newParams: Record<string, unknown>) {
permissionError.value = "";
busy.value = true;
try {
const [revokePreflight, grantPreflight] = await Promise.all([api.etcdPreflight(props.connectionId, "auth_role_revoke_permission", oldParams), api.etcdPreflight(props.connectionId, "auth_role_grant_permission", newParams)]);
if (revokePreflight.confirmationText !== grantPreflight.confirmationText) {
throw new Error(t("etcd.access.confirmationMismatch"));
}
approvalExpected.value = revokePreflight.confirmationText;
approvalText.value = "";
pendingApproval = async () => {
let revoked = false;
try {
await api.etcdAuthCall(props.connectionId, "role_revoke_permission", oldParams, {
preflightToken: revokePreflight.token,
confirmationText: revokePreflight.confirmationText,
});
revoked = true;
await api.etcdAuthCall(props.connectionId, "role_grant_permission", newParams, {
preflightToken: grantPreflight.token,
confirmationText: grantPreflight.confirmationText,
});
} catch (caught) {
permissionError.value = errorMessage(caught);
if (revoked) {
try {
const restorePreflight = await api.etcdPreflight(props.connectionId, "auth_role_grant_permission", oldParams);
await api.etcdAuthCall(props.connectionId, "role_grant_permission", oldParams, {
preflightToken: restorePreflight.token,
confirmationText: restorePreflight.confirmationText,
});
} catch {
// Keep the actionable error. The next refresh exposes the actual
// etcd permission state if a best-effort recovery also fails.
}
}
throw caught;
}
editingPermission.value = null;
permissionOpen.value = false;
await selectRole(selectedRole.value);
notice.value = t("etcd.access.permissionUpdated");
};
approvalOpen.value = true;
} catch (caught) {
permissionError.value = errorMessage(caught);
} finally {
busy.value = false;
}
}
function savePermission() {
if (editingPermission.value) editPermission();
else grantPermission();
}
function revokePermission(permission: api.EtcdAuthPermission) {
if (!selectedRole.value || readOnly.value) return;
const params = permissionParams(permission);
void requestApproval("auth_role_revoke_permission", params, async (approval) => {
await api.etcdAuthCall(props.connectionId, "role_revoke_permission", params, approval);
await selectRole(selectedRole.value);
notice.value = t("etcd.access.permissionRevoked");
});
}
watch(
() => props.connectionId,
() => {
detailRequest++;
detailLoading.value = false;
selectedUser.value = "";
selectedRole.value = "";
userDetail.value = null;
roleDetail.value = null;
void loadDirectory();
},
);
watch(permissionResource, (resource) => {
if (resource === "all") permissionKey.value = "";
});
watch(newRolePermissionResource, (resource) => {
if (resource === "all") newRolePermissionKey.value = "";
});
onMounted(() => void loadDirectory());
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<header class="flex h-14 shrink-0 flex-wrap items-center gap-3 border-b px-4">
<div class="flex rounded-md border p-0.5 shadow-sm">
<Button size="sm" class="h-8 gap-1.5 px-3 text-sm" :variant="view === 'users' ? 'secondary' : 'ghost'" @click="void selectView('users')"><UsersRound class="h-4 w-4" />{{ t("etcd.access.users") }}</Button>
<Button size="sm" class="h-8 gap-1.5 px-3 text-sm" :variant="view === 'roles' ? 'secondary' : 'ghost'" @click="void selectView('roles')"><KeyRound class="h-4 w-4" />{{ t("etcd.access.roles") }}</Button>
</div>
<div class="hidden h-5 w-px bg-border sm:block" />
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck class="h-3.5 w-3.5 text-sky-600" />
<span class="font-medium text-foreground/75">{{ t("etcd.access.title") }}</span>
<span>{{ t("etcd.access.description") }}</span>
</div>
<div class="flex-1" />
<Badge v-if="readOnly" variant="outline">{{ t("etcd.access.readOnly") }}</Badge>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadDirectory"><RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />{{ t("etcd.access.refresh") }}</Button>
</header>
<div v-if="error" class="mx-4 mt-3 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ error }}</div>
<div v-if="notice" class="mx-4 mt-3 rounded border bg-muted/40 px-3 py-2 text-sm">{{ notice }}</div>
<div v-if="view === 'users'" class="grid min-h-0 flex-1 md:grid-cols-[16rem_minmax(0,1fr)]">
<aside class="min-h-0 border-b md:border-b-0 md:border-r">
<div class="flex items-center justify-between border-b px-3 py-2">
<span class="text-sm font-medium">{{ t("etcd.access.userCount", { count: users.length }) }}</span>
<Button size="icon-xs" :disabled="readOnly" :title="t('etcd.access.createUser')" @click="openCreateUser"><UserPlus class="h-3.5 w-3.5" /></Button>
</div>
<div class="max-h-52 overflow-auto p-1 md:max-h-none md:h-[calc(100%-41px)]">
<button v-for="user in users" :key="user" type="button" class="flex w-full items-center rounded px-2 py-2 text-left text-sm hover:bg-accent" :class="selectedUser === user ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground'" @click="selectUser(user)">{{ user }}</button>
<p v-if="!loading && users.length === 0" class="p-3 text-xs text-muted-foreground">{{ t("etcd.access.noUsers") }}</p>
</div>
</aside>
<main class="min-h-0 overflow-auto p-4">
<div v-if="!selectedUser" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground">{{ t("etcd.access.selectUser") }}</div>
<div v-else-if="detailLoading" class="flex h-32 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.access.loadingUser") }}</div>
<template v-else-if="userDetail">
<div class="mb-5 flex flex-wrap items-center gap-2 border-b pb-4">
<div class="mr-auto">
<h3 class="font-mono text-base font-semibold">{{ userDetail.user }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.access.userRolesSummary", { count: userDetail.roles.length }) }}</p>
</div>
<Button size="sm" variant="outline" class="gap-1.5" :disabled="readOnly" @click="passwordOpen = true"><LockKeyhole class="h-3.5 w-3.5" />{{ t("etcd.access.changePassword") }}</Button>
<Button size="sm" variant="destructive" class="gap-1.5" :disabled="readOnly" @click="deleteUser(userDetail.user)"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.access.deleteUser") }}</Button>
</div>
<section class="max-w-3xl space-y-5">
<div>
<div class="mb-3 flex items-center gap-2">
<h4 class="text-sm font-medium">{{ t("etcd.access.roleAssignments") }}</h4>
<Badge variant="secondary">{{ userDetail.roles.length }}</Badge>
</div>
<p class="mb-3 text-xs text-muted-foreground">{{ t("etcd.access.roleAssignmentHint") }}</p>
<div v-if="userDetail.roles.length" class="divide-y rounded border">
<div v-for="role in userDetail.roles" :key="role" class="flex items-center gap-3 px-3 py-2.5">
<KeyRound class="h-4 w-4 text-muted-foreground" /><code class="flex-1 text-sm">{{ role }}</code
><Button size="sm" variant="ghost" class="text-destructive hover:text-destructive" :disabled="readOnly || busy" @click="updateUserRole(false, role)">{{ t("etcd.access.revokeAssociation") }}</Button>
</div>
</div>
<p v-else class="rounded border border-dashed px-3 py-5 text-sm text-muted-foreground">{{ t("etcd.access.noAssignedRoles") }}</p>
</div>
<div class="border-t pt-4">
<h5 class="text-sm font-medium">{{ t("etcd.access.assignRole") }}</h5>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.access.assignRoleHint") }}</p>
<div class="mt-3 flex max-w-xl flex-wrap gap-2">
<select v-model="selectedGrantRole" class="h-9 min-w-52 rounded-md border bg-background px-3 text-sm" :disabled="readOnly || grantableRoles.length === 0">
<option value="">{{ grantableRoles.length ? t("etcd.access.selectRoleOption") : t("etcd.access.noAssignableRoles") }}</option>
<option v-for="role in grantableRoles" :key="role" :value="role">{{ role }}</option>
</select>
<Button size="sm" class="h-9 gap-1.5" :disabled="readOnly || busy || !selectedGrantRole" @click="updateUserRole(true, selectedGrantRole)"><Plus class="h-3.5 w-3.5" />{{ t("etcd.access.grantRole") }}</Button>
</div>
</div>
</section>
</template>
</main>
</div>
<div v-else class="grid min-h-0 flex-1 md:grid-cols-[16rem_minmax(0,1fr)]">
<aside class="min-h-0 border-b md:border-b-0 md:border-r">
<div class="flex items-center justify-between border-b px-3 py-2">
<span class="text-sm font-medium">{{ t("etcd.access.roleCount", { count: roles.length }) }}</span>
<Button size="icon-xs" :disabled="readOnly" :title="t('etcd.access.createRole')" @click="createRoleOpen = true"><Plus class="h-3.5 w-3.5" /></Button>
</div>
<div class="max-h-52 overflow-auto p-1 md:max-h-none md:h-[calc(100%-41px)]">
<button v-for="role in roles" :key="role" type="button" class="flex w-full items-center rounded px-2 py-2 text-left text-sm hover:bg-accent" :class="selectedRole === role ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground'" @click="selectRole(role)">{{ role }}</button>
<p v-if="!loading && roles.length === 0" class="p-3 text-xs text-muted-foreground">{{ t("etcd.access.noRoles") }}</p>
</div>
</aside>
<main class="min-h-0 overflow-auto p-4">
<div v-if="!selectedRole" class="flex h-full min-h-48 items-center justify-center text-sm text-muted-foreground">{{ t("etcd.access.selectRole") }}</div>
<div v-else-if="detailLoading" class="flex h-32 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.access.loadingRole") }}</div>
<template v-else-if="roleDetail">
<div class="mb-5 flex flex-wrap items-center gap-2 border-b pb-4">
<div class="mr-auto">
<h3 class="font-mono text-base font-semibold">{{ roleDetail.role }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.access.rolePermissionHint") }}</p>
</div>
<Button size="sm" class="gap-1.5" :disabled="readOnly" @click="openGrantPermission"><Plus class="h-3.5 w-3.5" />{{ t("etcd.access.grantPermission") }}</Button>
<Button size="sm" variant="destructive" class="gap-1.5" :disabled="readOnly" @click="deleteRole(roleDetail.role)"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.access.deleteRole") }}</Button>
</div>
<div class="overflow-auto rounded border">
<table class="w-full min-w-[580px] text-left text-sm">
<thead class="bg-muted/60 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2 font-medium">{{ t("etcd.access.resource") }}</th>
<th class="px-3 py-2 font-medium">Key / Prefix</th>
<th class="px-3 py-2 font-medium">{{ t("etcd.access.permission") }}</th>
<th class="w-32 px-3 py-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="permission in roleDetail.permissions" :key="`${permission.key.encoding}:${permission.key.data}:${permission.rangeEnd.data}`" class="border-t">
<td class="px-3 py-2">
<Badge variant="outline">{{ permissionResourceOf(permission) === "all" ? t("etcd.access.allKeys") : permissionResourceOf(permission) === "prefix" ? t("etcd.admin.prefix") : t("etcd.access.exactKey") }}</Badge>
</td>
<td class="max-w-md truncate px-3 py-2 font-mono text-xs">{{ permissionResourceOf(permission) === "all" ? t("etcd.access.allKeyspace") : displayValue(permission.key) }}</td>
<td class="px-3 py-2">
<Badge variant="secondary">{{ permissionLabel(permission.access) }}</Badge>
</td>
<td class="px-3 py-2">
<div class="flex justify-end gap-1">
<Button size="sm" variant="ghost" class="gap-1" :disabled="readOnly || busy" @click="openEditPermission(permission)"><Pencil class="h-3.5 w-3.5" />{{ t("etcd.access.edit") }}</Button
><Button size="sm" variant="ghost" class="text-destructive hover:text-destructive" :disabled="readOnly || busy" @click="revokePermission(permission)">{{ t("etcd.access.revoke") }}</Button>
</div>
</td>
</tr>
<tr v-if="roleDetail.permissions.length === 0">
<td colspan="4" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("etcd.access.noPermissions") }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</main>
</div>
<Dialog v-model:open="createUserOpen"
><DialogContent class="sm:max-w-lg"
><DialogHeader
><DialogTitle>{{ t("etcd.access.createEtcdUser") }}</DialogTitle></DialogHeader
>
<div class="space-y-5 py-2">
<div class="grid gap-3 sm:grid-cols-2">
<label class="space-y-1.5"
><span class="text-sm font-medium">{{ t("etcd.access.username") }}</span
><Input v-model="newUser" :placeholder="t('etcd.access.username')" autocomplete="off" /></label
><label class="space-y-1.5"
><span class="text-sm font-medium">{{ t("etcd.access.password") }}</span
><Input v-model="newUserPassword" type="password" :placeholder="t('etcd.access.password')" autocomplete="new-password"
/></label>
</div>
<div class="border-t pt-4">
<div class="flex items-baseline justify-between gap-3">
<div>
<h4 class="text-sm font-medium">
{{ t("etcd.access.initialRoles") }} <span class="text-xs font-normal text-muted-foreground">{{ t("etcd.access.optional") }}</span>
</h4>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.access.initialRolesHint") }}</p>
</div>
<Badge v-if="newUserRoles.length" variant="secondary">{{ t("etcd.access.selectedCount", { count: newUserRoles.length }) }}</Badge>
</div>
<div v-if="roles.length" class="mt-3 max-h-44 divide-y overflow-auto rounded border">
<label v-for="role in roles" :key="role" class="flex cursor-pointer items-center gap-3 px-3 py-2.5 hover:bg-muted/40"
><input v-model="newUserRoles" type="checkbox" :value="role" class="h-4 w-4 accent-primary" /><KeyRound class="h-4 w-4 text-muted-foreground" /><code class="text-sm">{{ role }}</code></label
>
</div>
<p v-else class="mt-3 rounded border border-dashed px-3 py-4 text-sm text-muted-foreground">{{ t("etcd.access.noRolesForNewUser") }}</p>
</div>
</div>
<DialogFooter
><Button variant="outline" @click="createUserOpen = false">{{ t("etcd.access.cancel") }}</Button
><Button :disabled="busy || readOnly || !newUser.trim() || !newUserPassword" @click="createUser">{{ initialUserRoleLabel }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="createRoleOpen"
><DialogContent class="sm:max-w-lg"
><DialogHeader
><DialogTitle>{{ t("etcd.access.createEtcdRole") }}</DialogTitle></DialogHeader
>
<div class="space-y-4 py-2">
<Input v-model="newRole" :placeholder="t('etcd.access.roleName')" autocomplete="off" />
<div class="space-y-3 rounded-md border bg-muted/20 p-3">
<div>
<div class="text-sm font-medium">
{{ t("etcd.access.initialPermission") }} <span class="text-xs font-normal text-muted-foreground">{{ t("etcd.access.optional") }}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.access.initialPermissionHint") }}</p>
</div>
<Input v-if="newRolePermissionResource !== 'all'" v-model="newRolePermissionKey" :placeholder="t('etcd.access.keyOrPrefixPlaceholder')" autocomplete="off" />
<p v-else class="rounded border border-dashed bg-background px-3 py-2 text-xs text-muted-foreground">{{ t("etcd.access.allKeysHint") }}</p>
<div class="grid grid-cols-2 gap-3">
<select v-model="newRolePermissionResource" class="h-9 rounded-md border bg-background px-3 text-sm">
<option value="all">{{ t("etcd.access.allKeys") }}</option>
<option value="prefix">{{ t("etcd.admin.prefix") }}</option>
<option value="key">{{ t("etcd.access.exactKey") }}</option></select
><select v-model="newRolePermissionAccess" class="h-9 rounded-md border bg-background px-3 text-sm">
<option value="read">{{ t("etcd.access.read") }}</option>
<option value="write">{{ t("etcd.access.write") }}</option>
<option value="readwrite">{{ t("etcd.access.readWrite") }}</option>
</select>
</div>
</div>
</div>
<DialogFooter
><Button variant="outline" @click="createRoleOpen = false">{{ t("etcd.access.cancel") }}</Button
><Button :disabled="busy || readOnly || !newRole.trim()" @click="createRole">{{ hasInitialRolePermission ? t("etcd.access.createAndGrantPermission") : t("etcd.access.createRole") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="passwordOpen"
><DialogContent class="sm:max-w-md"
><DialogHeader
><DialogTitle>{{ t("etcd.access.changePassword") }}</DialogTitle></DialogHeader
>
<div class="space-y-2 py-2">
<p class="text-sm text-muted-foreground">{{ t("etcd.access.passwordHint") }}</p>
<Input v-model="newPassword" type="password" :placeholder="t('etcd.access.newPassword')" autocomplete="new-password" />
</div>
<DialogFooter
><Button variant="outline" @click="passwordOpen = false">{{ t("etcd.access.cancel") }}</Button
><Button :disabled="busy || readOnly || !newPassword" @click="changePassword">{{ t("etcd.access.continue") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="permissionOpen"
><DialogContent class="sm:max-w-lg"
><DialogHeader
><DialogTitle>{{ editingPermission ? t("etcd.access.editPermission") : t("etcd.access.grantPermission") }}</DialogTitle></DialogHeader
>
<div class="space-y-3 py-2">
<p v-if="editingPermission" class="text-xs leading-5 text-muted-foreground">{{ t("etcd.access.permissionUpdateHint") }}</p>
<div v-if="permissionError" class="rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ permissionError }}</div>
<Input v-if="permissionResource !== 'all'" v-model="permissionKey" :placeholder="t('etcd.access.keyOrPrefixPlaceholder')" autocomplete="off" />
<p v-else class="rounded border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground">{{ t("etcd.access.allKeysHint") }}</p>
<div class="grid grid-cols-2 gap-3">
<select v-model="permissionResource" class="h-9 rounded-md border bg-background px-3 text-sm">
<option value="all">{{ t("etcd.access.allKeys") }}</option>
<option value="prefix">{{ t("etcd.admin.prefix") }}</option>
<option value="key">{{ t("etcd.access.exactKey") }}</option></select
><select v-model="permissionAccess" class="h-9 rounded-md border bg-background px-3 text-sm">
<option value="read">{{ t("etcd.access.read") }}</option>
<option value="write">{{ t("etcd.access.write") }}</option>
<option value="readwrite">{{ t("etcd.access.readWrite") }}</option>
</select>
</div>
</div>
<DialogFooter
><Button variant="outline" @click="permissionOpen = false">{{ t("etcd.access.cancel") }}</Button
><Button :disabled="busy || readOnly || !hasPermissionTarget" @click="savePermission">{{ editingPermission ? t("etcd.access.saveChanges") : t("etcd.access.continue") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog :open="approvalOpen" @update:open="(open) => !open && closeApproval()"
><DialogContent class="sm:max-w-md"
><DialogHeader
><DialogTitle class="text-destructive">{{ t("etcd.access.dangerousTitle") }}</DialogTitle></DialogHeader
>
<div class="space-y-3 py-2">
<p class="text-sm text-muted-foreground">{{ t("etcd.access.dangerousHint") }}</p>
<code class="block rounded border bg-muted px-3 py-2 text-xs break-all">{{ approvalExpected }}</code
><Input v-model="approvalText" :placeholder="t('etcd.access.confirmationPlaceholder')" autocomplete="off" />
</div>
<DialogFooter
><Button variant="outline" :disabled="busy" @click="closeApproval">{{ t("etcd.access.cancel") }}</Button
><Button variant="destructive" :disabled="busy || approvalText !== approvalExpected" @click="confirmApproval">{{ t("etcd.access.confirmExecute") }}</Button></DialogFooter
></DialogContent
></Dialog
>
</div>
</template>

View File

@ -0,0 +1,915 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, HardDrive, History, KeyRound, Loader2, Pencil, Play, Plus, Search, Server, Square, Trash2, Wrench } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { releaseEtcdWatch, releaseEtcdWatchBestEffort, releaseEtcdWatchesBestEffort, replaceEtcdWatch } from "@/lib/etcd/watchLifecycle";
type EtcdAdminSection = "maintenance" | "watch" | "lease";
type WatchEventCategory = "create" | "update" | "delete";
interface WatchEventDisplay {
revision: string;
eventType: "put" | "delete";
category: WatchEventCategory;
key: string;
value?: api.KvValue | null;
previousValue?: api.KvValue | null;
}
interface WatchMonitor {
id: string;
key: string;
keyBytes: api.KvValue | null;
scope: "key" | "prefix";
startedRevision: string | null;
status: "running" | "stopped" | "error";
events: WatchEventDisplay[];
error?: string;
}
const props = withDefaults(
defineProps<{
connectionId: string;
status?: api.KvStatusResponse | null;
sections?: EtcdAdminSection[];
initialSection?: EtcdAdminSection;
watchPreset?: { key: string; keyBytes?: api.KvValue | null; scope: "key" | "prefix" } | null;
watchKeySuggestions?: Array<{ key: string; keyBytes: api.KvValue }>;
}>(),
{
status: null,
sections: () => ["maintenance", "watch", "lease"],
initialSection: "maintenance",
watchPreset: null,
watchKeySuggestions: () => [],
},
);
const emit = defineEmits<{
refresh: [];
watchCreated: [];
watchDialogDismissed: [];
}>();
const { t } = useI18n();
const active = ref<EtcdAdminSection>(props.initialSection);
const busy = ref(false);
const error = ref("");
const notice = ref("");
const revision = ref(String(props.status?.revision || ""));
const watchDialogOpen = ref(false);
const watchEditingId = ref<string | null>(null);
const watchFormKey = ref("");
const watchFormKeyBytes = ref<api.KvValue | null>(null);
const watchFormScope = ref<"key" | "prefix">("prefix");
const watchSuggestionOpen = ref(false);
const watchSuggestionIndex = ref(-1);
const watchMonitors = ref<WatchMonitor[]>([]);
const selectedWatchId = ref<string | null>(null);
const watchSearch = ref("");
const watchPolling = ref(false);
const watchEventFilters = ref<Record<WatchEventCategory, boolean>>({ create: true, update: true, delete: true });
const watchEventFilterOptions: Array<{ value: WatchEventCategory; label: string }> = [
{ value: "create", label: t("etcd.admin.create") },
{ value: "update", label: t("etcd.admin.update") },
{ value: "delete", label: t("etcd.admin.delete") },
];
const leaseGrantDialogOpen = ref(false);
const leaseGrantTtl = ref("60");
const leaseGrantId = ref("");
const leaseGrantBusy = ref(false);
const leases = ref<api.EtcdLeaseListResponse | null>(null);
const leaseContinuation = ref<string | null>(null);
const leasePreviousContinuations = ref<Array<string | null>>([]);
const selectedLease = ref<api.EtcdLeaseDetail | null>(null);
const leaseDetailLoading = ref(false);
const autoKeepalive = ref(false);
const leaseTtlSnapshots = ref<Record<string, { ttl: number; observedAt: number }>>({});
const leaseClock = ref(Date.now());
const maintenanceApprovalOpen = ref(false);
const maintenanceApprovalLoading = ref(false);
const maintenanceApprovalTitle = ref("");
const maintenanceApprovalDetails = ref("");
const maintenanceApprovalExpected = ref("");
const maintenanceApprovalText = ref("");
let watchPoller: ReturnType<typeof setInterval> | null = null;
let leaseKeepaliveTimer: ReturnType<typeof setTimeout> | null = null;
let leaseCountdownTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
leaseClock.value = Date.now();
}, 1000);
let pendingMaintenanceApproval: (() => Promise<void>) | null = null;
let leaseGrantRequest = 0;
watch(
() => props.initialSection,
(section) => {
active.value = section;
},
);
watch(
() => props.watchPreset,
(preset) => {
if (!preset) return;
openWatchDialog(preset);
},
{ immediate: true },
);
watch(active, (section, previousSection) => {
if (previousSection === "lease" && section !== "lease") stopLeaseKeepalive();
});
watch(autoKeepalive, (enabled) => {
if (enabled) scheduleLeaseKeepalive();
else stopLeaseKeepalive();
});
const endpoints = computed(() => {
const members = props.status?.members.filter((member) => member.reachable) ?? [];
const leader = props.status?.leaderId;
return [...members.filter((member) => member.memberId !== leader), ...members.filter((member) => member.memberId === leader)].map((member) => member.endpoint);
});
const currentRevision = computed(() => (props.status?.revision == null ? null : String(props.status.revision)));
const reachableMemberCount = computed(() => props.status?.members.filter((member) => member.reachable).length ?? 0);
const memberCount = computed(() => props.status?.members.length ?? 0);
const leaderEndpoint = computed(() => {
const leaderId = props.status?.leaderId;
return props.status?.members.find((member) => member.memberId != null && member.memberId === leaderId)?.endpoint ?? null;
});
const filteredWatchMonitors = computed(() => {
const query = watchSearch.value.trim().toLocaleLowerCase();
return query ? watchMonitors.value.filter((monitor) => monitor.key.toLocaleLowerCase().includes(query)) : watchMonitors.value;
});
const selectedWatchMonitor = computed(() => watchMonitors.value.find((monitor) => monitor.id === selectedWatchId.value) ?? null);
const visibleWatchEvents = computed(() => (selectedWatchMonitor.value?.events ?? []).filter((event) => watchEventFilters.value[event.category]));
const runningWatchCount = computed(() => watchMonitors.value.filter((monitor) => monitor.status === "running").length);
const filteredWatchKeySuggestions = computed(() => {
const query = watchFormKey.value.trim();
if (!query) return [];
const seen = new Set<string>();
const matches: Array<{ key: string; keyBytes: api.KvValue }> = [];
for (const suggestion of props.watchKeySuggestions) {
const identity = `${suggestion.keyBytes.encoding}:${suggestion.keyBytes.data}`;
if (suggestion.key === query || !suggestion.key.startsWith(query) || seen.has(identity)) continue;
seen.add(identity);
matches.push(suggestion);
if (matches.length === 8) break;
}
return matches;
});
const showWatchKeySuggestions = computed(() => watchSuggestionOpen.value && filteredWatchKeySuggestions.value.length > 0);
function reset(message = "") {
error.value = "";
notice.value = message;
}
async function dangerousApproval(action: string, params: Record<string, unknown>) {
const preflight = await api.etcdPreflight(props.connectionId, action, params);
const entered = window.prompt(t("etcd.admin.confirmationPrompt", { confirmationText: preflight.confirmationText }), "");
if (entered !== preflight.confirmationText) return null;
return { preflightToken: preflight.token, confirmationText: entered };
}
async function run(action: () => Promise<void>) {
busy.value = true;
reset();
try {
await action();
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
busy.value = false;
}
}
async function requestMaintenanceApproval(action: "compact" | "defrag", params: Record<string, unknown>, title: string, details: string, execute: (approval: api.EtcdDangerousApproval) => Promise<void>) {
maintenanceApprovalLoading.value = true;
reset();
try {
const preflight = await api.etcdPreflight(props.connectionId, action, params);
maintenanceApprovalTitle.value = title;
maintenanceApprovalDetails.value = details;
maintenanceApprovalExpected.value = preflight.confirmationText;
maintenanceApprovalText.value = "";
pendingMaintenanceApproval = () => execute({ preflightToken: preflight.token, confirmationText: preflight.confirmationText });
maintenanceApprovalOpen.value = true;
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
maintenanceApprovalLoading.value = false;
}
}
function closeMaintenanceApproval() {
if (maintenanceApprovalLoading.value) return;
maintenanceApprovalOpen.value = false;
maintenanceApprovalText.value = "";
pendingMaintenanceApproval = null;
}
async function confirmMaintenanceApproval() {
if (maintenanceApprovalText.value !== maintenanceApprovalExpected.value || !pendingMaintenanceApproval) return;
const execute = pendingMaintenanceApproval;
pendingMaintenanceApproval = null;
maintenanceApprovalOpen.value = false;
maintenanceApprovalText.value = "";
await run(execute);
}
function compact() {
if (!/^\d+$/.test(revision.value)) {
error.value = t("etcd.admin.compactRequired");
return;
}
const targetRevision = revision.value;
void requestMaintenanceApproval("compact", { revision: targetRevision }, t("etcd.admin.compactTitle"), t("etcd.admin.compactDescription"), async (approval) => {
await api.etcdCompact(props.connectionId, targetRevision, approval);
emit("refresh");
reset(t("etcd.admin.compactDone"));
});
}
function defrag() {
if (!endpoints.value.length) {
error.value = t("etcd.admin.noReachableMembers");
return;
}
const targetEndpoints = [...endpoints.value];
void requestMaintenanceApproval("defrag", { endpoints: targetEndpoints }, t("etcd.admin.defragTitle"), t("etcd.admin.defragDescription"), async (approval) => {
const result = await api.etcdDefrag(props.connectionId, targetEndpoints, approval);
emit("refresh");
const succeeded = result.members.filter((item) => item.status === "succeeded").length;
const failed = result.members.filter((item) => item.status === "failed").length;
const notExecuted = result.members.filter((item) => item.status === "not_executed").length;
reset(failed ? t("etcd.admin.defragStopped", { succeeded, failed, notExecuted }) : t("etcd.admin.defragCompleted", { succeeded, total: result.members.length }));
});
}
function openWatchDialog(preset?: { key: string; keyBytes?: api.KvValue | null; scope: "key" | "prefix" }) {
watchEditingId.value = null;
watchFormKey.value = preset?.key ?? "";
watchFormKeyBytes.value = preset?.keyBytes ?? null;
watchFormScope.value = preset?.scope ?? "prefix";
closeWatchKeySuggestions();
watchDialogOpen.value = true;
}
function closeWatchDialog() {
watchDialogOpen.value = false;
closeWatchKeySuggestions();
emit("watchDialogDismissed");
}
function updateWatchDialog(open: boolean) {
if (open) {
watchDialogOpen.value = true;
return;
}
closeWatchDialog();
}
function editWatchMonitor(monitor: WatchMonitor) {
watchEditingId.value = monitor.id;
watchFormKey.value = monitor.key;
watchFormKeyBytes.value = monitor.keyBytes;
watchFormScope.value = monitor.scope;
closeWatchKeySuggestions();
watchDialogOpen.value = true;
}
function closeWatchKeySuggestions() {
watchSuggestionOpen.value = false;
watchSuggestionIndex.value = -1;
}
function onWatchKeyInput(value: string | number) {
const key = String(value);
watchFormKeyBytes.value = null;
watchSuggestionOpen.value = Boolean(key.trim());
watchSuggestionIndex.value = -1;
}
function acceptWatchKeySuggestion(index: number) {
const suggestion = filteredWatchKeySuggestions.value[index];
if (!suggestion) return;
watchFormKey.value = suggestion.key;
watchFormKeyBytes.value = suggestion.keyBytes;
closeWatchKeySuggestions();
}
function moveWatchKeySuggestion(delta: number) {
if (!filteredWatchKeySuggestions.value.length) return;
watchSuggestionOpen.value = true;
watchSuggestionIndex.value = (watchSuggestionIndex.value + delta + filteredWatchKeySuggestions.value.length) % filteredWatchKeySuggestions.value.length;
}
function onWatchKeyKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === "Escape") {
closeWatchKeySuggestions();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
if (!filteredWatchKeySuggestions.value.length) return;
event.preventDefault();
moveWatchKeySuggestion(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Enter" && showWatchKeySuggestions.value && watchSuggestionIndex.value >= 0) {
event.preventDefault();
acceptWatchKeySuggestion(watchSuggestionIndex.value);
}
}
function watchEventCategory(event: { eventType: "put" | "delete"; previousValue?: api.KvValue | null }): WatchEventCategory {
if (event.eventType === "delete") return "delete";
return event.previousValue ? "update" : "create";
}
function watchEventCategoryLabel(category: WatchEventCategory): string {
return category === "create" ? t("etcd.admin.create") : category === "update" ? t("etcd.admin.update") : t("etcd.admin.delete");
}
function startWatchPoller() {
if (watchPoller || runningWatchCount.value === 0) return;
watchPoller = setInterval(() => void pollWatchMonitors(), 500);
}
function stopWatchPollerIfIdle() {
if (!watchPoller || runningWatchCount.value > 0) return;
clearInterval(watchPoller);
watchPoller = null;
}
async function startWatchMonitor(monitor: WatchMonitor) {
const result = await api.etcdWatchStart(props.connectionId, {
key: monitor.key,
keyBytes: monitor.keyBytes,
scope: monitor.scope,
includePrevKv: true,
});
monitor.id = result.watchId;
monitor.startedRevision = String(result.startedRevision);
monitor.status = "running";
monitor.error = undefined;
monitor.events = [];
selectedWatchId.value = monitor.id;
startWatchPoller();
}
async function saveWatchMonitor() {
if (!watchFormKey.value) return;
const editing = watchEditingId.value ? (watchMonitors.value.find((monitor) => monitor.id === watchEditingId.value) ?? null) : null;
await run(async () => {
if (editing) {
const shouldRestart = editing.status === "running";
if (editing.id) await stopWatchMonitor(editing, false);
editing.key = watchFormKey.value;
editing.keyBytes = watchFormKeyBytes.value;
editing.scope = watchFormScope.value;
editing.events = [];
editing.error = undefined;
if (!shouldRestart) editing.status = "stopped";
if (shouldRestart) await startWatchMonitor(editing);
selectedWatchId.value = editing.id;
watchDialogOpen.value = false;
reset(shouldRestart ? t("etcd.admin.saveWatch") : t("etcd.admin.saveWatch"));
return;
}
if (runningWatchCount.value >= 4) throw new Error(t("etcd.admin.watchSessionHint"));
const monitor: WatchMonitor = {
id: "",
key: watchFormKey.value,
keyBytes: watchFormKeyBytes.value,
scope: watchFormScope.value,
startedRevision: null,
status: "stopped",
events: [],
};
await startWatchMonitor(monitor);
watchMonitors.value.unshift(monitor);
startWatchPoller();
watchDialogOpen.value = false;
reset(t("etcd.admin.running"));
emit("watchCreated");
});
}
async function pollWatchMonitors() {
if (watchPolling.value) return;
const running = watchMonitors.value.filter((monitor) => monitor.status === "running");
if (!running.length) {
stopWatchPollerIfIdle();
return;
}
watchPolling.value = true;
try {
for (const monitor of running) {
try {
const response = await api.etcdWatchPoll(props.connectionId, monitor.id);
for (const batch of response.batches) {
for (const event of batch.events) {
monitor.events.unshift({
revision: String(event.revision),
eventType: event.eventType,
category: watchEventCategory(event),
key: event.key,
value: event.value,
previousValue: event.previousValue,
});
}
}
monitor.events.splice(500);
if (response.terminal) {
// A terminal poll removes the watch from the Agent. This extra stop is
// only a best-effort compatibility cleanup for older Agents.
await releaseEtcdWatchBestEffort(props.connectionId, monitor.id, api.etcdWatchStop);
monitor.status = "error";
monitor.error = response.terminal.message || `Watch ${response.terminal.reason}`;
}
} catch (caught) {
let message = caught instanceof Error ? caught.message : String(caught);
try {
await releaseEtcdWatch(props.connectionId, monitor.id, api.etcdWatchStop);
} catch (stopError) {
const stopMessage = stopError instanceof Error ? stopError.message : String(stopError);
message = `${message}; failed to stop watch: ${stopMessage}`;
}
monitor.status = "error";
monitor.error = message;
}
}
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
watchPolling.value = false;
stopWatchPollerIfIdle();
}
}
async function stopWatchMonitor(monitor: WatchMonitor, notify = true) {
await releaseEtcdWatch(props.connectionId, monitor.id, api.etcdWatchStop);
monitor.status = "stopped";
monitor.error = undefined;
stopWatchPollerIfIdle();
if (notify) reset(t("etcd.admin.stopped"));
}
function requestStopWatchMonitor(monitor: WatchMonitor) {
void run(() => stopWatchMonitor(monitor));
}
async function resumeWatchMonitor(monitor: WatchMonitor) {
if (runningWatchCount.value >= 4) {
error.value = t("etcd.admin.watchSessionHint");
return;
}
await run(async () => {
await replaceEtcdWatch(props.connectionId, monitor.id, api.etcdWatchStop, () => startWatchMonitor(monitor));
reset(t("etcd.admin.running"));
});
}
async function deleteWatchMonitor(monitor: WatchMonitor) {
await run(async () => {
await stopWatchMonitor(monitor, false);
const index = watchMonitors.value.indexOf(monitor);
if (index >= 0) watchMonitors.value.splice(index, 1);
if (selectedWatchId.value === monitor.id) selectedWatchId.value = watchMonitors.value[0]?.id ?? null;
reset(t("etcd.admin.deleteWatch"));
});
}
async function stopAllWatchMonitors() {
if (watchPoller) {
clearInterval(watchPoller);
watchPoller = null;
}
await releaseEtcdWatchesBestEffort(
props.connectionId,
watchMonitors.value.map((monitor) => monitor.id),
api.etcdWatchStop,
);
}
function rememberLeaseTtl(id: string, ttl: number) {
leaseTtlSnapshots.value = { ...leaseTtlSnapshots.value, [id]: { ttl, observedAt: Date.now() } };
}
function displayedLeaseTtl(id: string, fallbackTtl: number): number {
const snapshot = leaseTtlSnapshots.value[id];
if (!snapshot) return fallbackTtl;
const elapsedSeconds = Math.floor((leaseClock.value - snapshot.observedAt) / 1000);
return Math.max(0, snapshot.ttl - elapsedSeconds);
}
function applyLeases(response: api.EtcdLeaseListResponse) {
leases.value = response;
for (const lease of response.leases) rememberLeaseTtl(String(lease.id), lease.ttl);
}
async function fetchLeases(continuation = leaseContinuation.value) {
applyLeases(await api.etcdLeaseList(props.connectionId, 100, continuation));
}
async function loadLeases() {
await run(fetchLeases);
}
async function nextLeasePage() {
const next = leases.value?.nextContinuation;
if (!next) return;
const previous = leaseContinuation.value;
await run(async () => {
const response = await api.etcdLeaseList(props.connectionId, 100, next);
leasePreviousContinuations.value.push(previous);
leaseContinuation.value = next;
applyLeases(response);
});
}
async function previousLeasePage() {
if (!leasePreviousContinuations.value.length) return;
const previous = leasePreviousContinuations.value[leasePreviousContinuations.value.length - 1] ?? null;
await run(async () => {
const response = await api.etcdLeaseList(props.connectionId, 100, previous);
leasePreviousContinuations.value.pop();
leaseContinuation.value = previous;
applyLeases(response);
});
}
async function openLease(id: string) {
leaseDetailLoading.value = true;
selectedLease.value = null;
try {
selectedLease.value = await api.etcdLeaseCall<api.EtcdLeaseDetail>(props.connectionId, "get", { id, includeKeys: true });
rememberLeaseTtl(selectedLease.value.id, selectedLease.value.ttl);
if (autoKeepalive.value) scheduleLeaseKeepalive();
} catch (caught) {
error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
leaseDetailLoading.value = false;
}
}
function stopLeaseKeepalive() {
if (leaseKeepaliveTimer) {
clearTimeout(leaseKeepaliveTimer);
leaseKeepaliveTimer = null;
}
}
function scheduleLeaseKeepalive() {
stopLeaseKeepalive();
if (!autoKeepalive.value || !selectedLease.value || selectedLease.value.ttl <= 0) return;
leaseKeepaliveTimer = setTimeout(() => void renewLease(selectedLease.value!.id), Math.max(1000, Math.floor((selectedLease.value.ttl * 1000) / 3)));
}
async function renewLease(id: string) {
await run(async () => {
const result = await api.etcdLeaseCall<{ id: string; ttl: number }>(props.connectionId, "keepalive", { id });
if (selectedLease.value?.id === id) {
selectedLease.value = { ...selectedLease.value, ttl: result.ttl };
rememberLeaseTtl(id, result.ttl);
scheduleLeaseKeepalive();
}
await loadLeases();
reset(t("etcd.admin.leaseRenewed", { id }));
});
}
function openLeaseGrantDialog() {
leaseGrantTtl.value = "60";
leaseGrantId.value = "";
leaseGrantDialogOpen.value = true;
}
function closeLeaseGrantDialog() {
// The RPC cannot be aborted through Tauri today. Ignore its late response so
// closing the dialog immediately restores control to the user.
const wasPending = leaseGrantBusy.value;
leaseGrantRequest++;
leaseGrantBusy.value = false;
leaseGrantDialogOpen.value = false;
if (wasPending) reset(t("etcd.admin.leaseGrantCancelled"));
}
function updateLeaseGrantDialog(open: boolean) {
if (open) {
leaseGrantDialogOpen.value = true;
} else {
closeLeaseGrantDialog();
}
}
async function grantLease() {
const ttl = leaseGrantTtl.value.trim();
const id = leaseGrantId.value.trim();
if (!/^\d+$/.test(ttl) || Number(ttl) <= 0) {
error.value = t("etcd.admin.ttlHint");
return;
}
if (id && !/^\d+$/.test(id)) {
error.value = t("etcd.admin.customLeaseHint");
return;
}
const request = ++leaseGrantRequest;
leaseGrantBusy.value = true;
reset();
try {
const result = await api.etcdLeaseCall<{ id: string; ttl?: number }>(props.connectionId, "grant", { ttl, id: id || undefined });
if (request !== leaseGrantRequest) return;
// The grant has succeeded. Close first so list/detail follow-up failures
// do not leave a modal that appears to be frozen.
leaseGrantDialogOpen.value = false;
await fetchLeases();
if (request !== leaseGrantRequest) return;
await openLease(result.id);
if (request !== leaseGrantRequest) return;
reset(t("etcd.admin.leaseCreated", { id: result.id }));
} catch (caught) {
if (request === leaseGrantRequest) error.value = caught instanceof Error ? caught.message : String(caught);
} finally {
if (request === leaseGrantRequest) leaseGrantBusy.value = false;
}
}
async function revokeLease(id: string) {
await run(async () => {
const params = { id };
const approval = await dangerousApproval("lease_revoke", params);
if (!approval) return;
await api.etcdLeaseCall(props.connectionId, "revoke", params, approval);
if (selectedLease.value?.id === id) {
selectedLease.value = null;
autoKeepalive.value = false;
stopLeaseKeepalive();
}
await loadLeases();
});
}
onBeforeUnmount(() => {
void stopAllWatchMonitors();
stopLeaseKeepalive();
if (leaseCountdownTimer) clearInterval(leaseCountdownTimer);
leaseCountdownTimer = null;
});
</script>
<template>
<section class="overflow-hidden rounded-lg border">
<div v-if="sections.length > 1" class="flex flex-wrap items-center gap-1 border-b p-2">
<Button v-if="sections.includes('maintenance')" size="sm" :variant="active === 'maintenance' ? 'secondary' : 'ghost'" @click="active = 'maintenance'"><Wrench class="mr-1 h-3.5 w-3.5" />{{ t("etcd.admin.maintenance") }}</Button>
<Button v-if="sections.includes('watch')" size="sm" :variant="active === 'watch' ? 'secondary' : 'ghost'" @click="active = 'watch'"><Activity class="mr-1 h-3.5 w-3.5" />{{ t("etcd.admin.watch") }}</Button>
<Button
v-if="sections.includes('lease')"
size="sm"
:variant="active === 'lease' ? 'secondary' : 'ghost'"
@click="
active = 'lease';
void loadLeases();
"
><KeyRound class="mr-1 h-3.5 w-3.5" />{{ t("etcd.admin.lease") }}</Button
>
</div>
<div :class="active === 'maintenance' ? 'text-sm' : 'space-y-3 p-4 text-sm'">
<div v-if="error" :class="active === 'maintenance' ? 'border-b border-destructive/30 bg-destructive/5 px-5 py-3 text-destructive' : 'rounded border border-destructive/30 bg-destructive/5 p-2 text-destructive'">{{ error }}</div>
<div v-if="notice" :class="active === 'maintenance' ? 'border-b bg-muted/40 px-5 py-3' : active === 'watch' ? 'border-b pb-2 text-xs text-muted-foreground' : 'rounded border bg-muted/40 p-2'">{{ notice }}</div>
<template v-if="active === 'maintenance'">
<div class="flex flex-wrap items-start justify-between gap-4 border-b px-5 py-4">
<div class="min-w-0">
<h2 class="text-base font-semibold">{{ t("etcd.admin.maintenanceTitle") }}</h2>
<p class="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.maintenanceDescription") }}</p>
</div>
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span class="rounded-md border bg-muted/30 px-2.5 py-1.5">{{ t("etcd.admin.currentRevision", { revision: currentRevision ?? "-" }) }}</span>
<span class="rounded-md border bg-muted/30 px-2.5 py-1.5">{{ t("etcd.admin.reachableMembers", { reachable: reachableMemberCount, total: memberCount }) }}</span>
</div>
</div>
<div class="grid divide-y lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<section class="space-y-4 px-5 py-5">
<div class="flex items-start gap-3">
<History class="mt-0.5 h-5 w-5 shrink-0 text-sky-600" />
<div>
<h3 class="font-medium">{{ t("etcd.admin.compactTitle") }}</h3>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.compactDescription") }}</p>
</div>
</div>
<div class="border-l-2 border-amber-500/70 pl-3 text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.compactDiskHint") }}</div>
<label class="block space-y-1.5">
<span class="text-xs font-medium">{{ t("etcd.admin.compactRevision") }}</span>
<Input v-model="revision" inputmode="numeric" :placeholder="t('etcd.admin.compactPlaceholder', { revision: currentRevision ?? '' })" />
<span class="block text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.compactInputHint") }}</span>
</label>
<Button variant="destructive" :disabled="busy || maintenanceApprovalLoading || !/^\d+$/.test(revision)" @click="compact">{{ t("etcd.admin.compactAction") }}</Button>
</section>
<section class="space-y-4 px-5 py-5">
<div class="flex items-start gap-3">
<HardDrive class="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
<div>
<h3 class="font-medium">{{ t("etcd.admin.defragTitle") }}</h3>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.defragDescription") }}</p>
</div>
</div>
<div class="border-l-2 border-amber-500/70 pl-3 text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.defragWarning") }}</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2.5 py-1.5"><Server class="h-3.5 w-3.5" />{{ t("etcd.admin.defragReachableMembers", { count: endpoints.length }) }}</span>
<span v-if="leaderEndpoint" class="max-w-full truncate rounded-md border bg-muted/30 px-2.5 py-1.5" :title="leaderEndpoint">{{ t("etcd.admin.leaderLast", { endpoint: leaderEndpoint }) }}</span>
</div>
<Button variant="outline" :disabled="busy || maintenanceApprovalLoading || !endpoints.length" @click="defrag">{{ t("etcd.admin.defragAction", { count: endpoints.length }) }}</Button>
</section>
</div>
</template>
<template v-else-if="active === 'watch'">
<div class="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-base font-semibold">{{ t("etcd.admin.watch") }}</h2>
<span class="text-xs text-muted-foreground">{{ t("etcd.admin.watchCount", { count: watchMonitors.length }) }}</span
><span v-if="runningWatchCount" class="rounded-full border border-emerald-500/50 px-2 py-0.5 text-xs text-emerald-700 dark:text-emerald-300">{{ t("etcd.admin.runningWatchCount", { count: runningWatchCount }) }}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.admin.watchSessionHint") }}</p>
</div>
<div class="flex w-full items-center gap-2 sm:w-auto">
<div class="relative min-w-0 flex-1 sm:w-56 sm:flex-none"><Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input v-model="watchSearch" class="h-9 pl-8 text-sm" :placeholder="t('etcd.admin.watchSearch')" /></div>
<Button size="sm" class="h-9 shrink-0 gap-1.5" @click="openWatchDialog()"><Plus class="h-3.5 w-3.5" />{{ t("etcd.admin.newWatch") }}</Button>
</div>
</div>
<div class="grid min-h-[22rem] gap-5 xl:grid-cols-[minmax(22rem,0.9fr)_minmax(0,1.1fr)]">
<section class="min-w-0 space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("etcd.admin.watchList") }}</span
><span>{{ t("etcd.admin.itemCount", { count: filteredWatchMonitors.length }) }}</span>
</div>
<div class="overflow-hidden rounded-md border">
<div class="grid grid-cols-[minmax(9rem,1fr)_5rem_6.5rem_auto] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Key</span><span>{{ t("etcd.admin.scope") }}</span
><span>{{ t("etcd.admin.status") }}</span
><span class="text-right">{{ t("etcd.admin.actions") }}</span>
</div>
<div v-for="monitor in filteredWatchMonitors" :key="monitor.id" class="grid grid-cols-[minmax(9rem,1fr)_5rem_6.5rem_auto] items-center gap-2 border-b px-3 py-2.5 text-sm last:border-b-0" :class="selectedWatchId === monitor.id ? 'bg-accent/60' : ''">
<button type="button" class="min-w-0 text-left" @click="selectedWatchId = monitor.id">
<code class="block truncate" :title="monitor.key">{{ monitor.key }}</code
><span v-if="monitor.error" class="mt-1 block truncate text-xs text-destructive" :title="monitor.error">{{ monitor.error }}</span>
</button>
<span class="text-xs text-muted-foreground">{{ monitor.scope === "key" ? t("etcd.admin.exact") : t("etcd.admin.prefix") }}</span>
<span :class="monitor.status === 'running' ? 'border-emerald-500/50 text-emerald-700 dark:text-emerald-300' : monitor.status === 'error' ? 'border-destructive/50 text-destructive' : 'text-muted-foreground'" class="inline-flex w-fit rounded-full border px-2 py-0.5 text-xs">{{
monitor.status === "running" ? t("etcd.admin.running") : monitor.status === "error" ? t("etcd.admin.error") : t("etcd.admin.stopped")
}}</span>
<div class="flex justify-end gap-0.5">
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :title="t('etcd.admin.editWatchTitle')" @click="editWatchMonitor(monitor)"><Pencil class="h-3.5 w-3.5" /></Button
><Button v-if="monitor.status === 'running'" size="sm" variant="ghost" class="h-7 w-7 p-0 text-amber-700 hover:text-amber-700 dark:text-amber-300" :title="t('etcd.admin.stopWatch')" @click="requestStopWatchMonitor(monitor)"><Square class="h-3.5 w-3.5" /></Button
><Button v-else size="sm" variant="ghost" class="h-7 w-7 p-0 text-emerald-700 hover:text-emerald-700 dark:text-emerald-300" :title="t('etcd.admin.startWatch')" @click="void resumeWatchMonitor(monitor)"><Play class="h-3.5 w-3.5" /></Button
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :title="t('etcd.admin.deleteWatch')" @click="void deleteWatchMonitor(monitor)"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</div>
<div v-if="filteredWatchMonitors.length === 0" class="px-3 py-12 text-center text-sm text-muted-foreground">{{ watchMonitors.length ? t("etcd.admin.noMatchingWatches") : t("etcd.admin.noWatches") }}</div>
</div>
</section>
<section v-if="selectedWatchMonitor" class="min-w-0 space-y-3 border-t pt-4 xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-semibold">{{ t("etcd.admin.eventStream") }}</h3>
<code class="mt-1 block truncate text-xs text-muted-foreground" :title="selectedWatchMonitor.key">{{ selectedWatchMonitor.key }}</code>
</div>
<span class="text-xs text-muted-foreground">{{ t("etcd.admin.eventCount", { count: selectedWatchMonitor.events.length }) }}</span>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="mr-1 text-xs font-medium text-muted-foreground">{{ t("etcd.admin.filter") }}</span
><label v-for="item in watchEventFilterOptions" :key="item.value" class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"><input v-model="watchEventFilters[item.value]" type="checkbox" class="h-3.5 w-3.5 rounded border-input" />{{ item.label }}</label>
</div>
<div class="overflow-hidden rounded-md border">
<div class="grid grid-cols-[7rem_5.5rem_minmax(0,1fr)] gap-3 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Revision</span><span>{{ t("etcd.admin.event") }}</span
><span>Key</span>
</div>
<div class="max-h-72 overflow-auto">
<div v-for="event in visibleWatchEvents" :key="`${event.revision}:${event.key}:${event.eventType}`" class="grid grid-cols-[7rem_5.5rem_minmax(0,1fr)] gap-3 border-b px-3 py-2 text-xs last:border-b-0">
<span class="font-mono text-muted-foreground">{{ event.revision }}</span
><span :class="event.category === 'delete' ? 'text-destructive' : event.category === 'update' ? 'text-amber-700 dark:text-amber-300' : 'text-emerald-700 dark:text-emerald-300'">{{ watchEventCategoryLabel(event.category) }}</span
><code class="truncate" :title="event.key">{{ event.key }}</code>
</div>
<div v-if="!visibleWatchEvents.length" class="p-8 text-center text-xs text-muted-foreground">
{{ selectedWatchMonitor.status === "running" ? (selectedWatchMonitor.events.length ? t("etcd.admin.noFilteredEvents") : t("etcd.admin.waitingEvents")) : t("etcd.admin.watchNotRunning") }}
</div>
</div>
</div>
</section>
<div v-else class="flex items-center justify-center border-t pt-4 text-sm text-muted-foreground xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0">{{ t("etcd.admin.selectWatch") }}</div>
</div>
</template>
<template v-else-if="active === 'lease'">
<div class="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
<div>
<h2 class="text-base font-semibold">{{ t("etcd.admin.lease") }}</h2>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.admin.leaseDescription") }}</p>
</div>
<div class="flex gap-2">
<Button size="sm" @click="openLeaseGrantDialog">{{ t("etcd.admin.grantLease") }}</Button
><Button size="sm" variant="outline" :disabled="busy" @click="loadLeases">{{ t("etcd.admin.refresh") }}</Button>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-[minmax(18rem,0.85fr)_minmax(0,1.15fr)]">
<div class="space-y-2">
<div v-for="lease in leases?.leases || []" :key="lease.id" class="flex w-full cursor-pointer items-center gap-3 rounded border px-3 py-2 text-left hover:bg-accent" :class="selectedLease?.id === lease.id ? 'border-primary bg-accent' : ''" @click="openLease(lease.id)">
<code class="min-w-0 flex-1 truncate">{{ lease.id }}</code
><span class="shrink-0 text-xs text-muted-foreground">TTL {{ displayedLeaseTtl(lease.id, lease.ttl) }}s</span><Button size="sm" variant="ghost" class="shrink-0" :disabled="busy" @click.stop="renewLease(lease.id)">{{ t("etcd.admin.renew") }}</Button
><Button size="sm" variant="ghost" class="shrink-0 text-destructive hover:text-destructive" :disabled="busy" @click.stop="revokeLease(lease.id)">{{ t("etcd.admin.revoke") }}</Button>
</div>
<div v-if="!leases?.leases.length" class="rounded border border-dashed px-3 py-6 text-center text-xs text-muted-foreground">{{ t("etcd.admin.noKnownLeases") }}</div>
<div v-if="leases && (leasePreviousContinuations.length || leases.nextContinuation)" class="flex items-center justify-between gap-2 pt-1">
<Button size="sm" variant="outline" :disabled="busy || !leasePreviousContinuations.length" @click="previousLeasePage">{{ t("etcd.admin.previousPage") }}</Button>
<span class="text-xs text-muted-foreground">{{ t("etcd.admin.leasePageSize", { count: 100 }) }}</span>
<Button size="sm" variant="outline" :disabled="busy || !leases.nextContinuation" @click="nextLeasePage">{{ t("etcd.admin.nextPage") }}</Button>
</div>
</div>
<div class="rounded border p-3">
<div v-if="leaseDetailLoading" class="flex h-32 items-center justify-center text-xs text-muted-foreground">{{ t("etcd.admin.loadingLease") }}</div>
<div v-else-if="selectedLease" class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<code class="mr-auto text-sm">{{ selectedLease.id }}</code
><span class="text-xs text-muted-foreground">TTL {{ displayedLeaseTtl(selectedLease.id, selectedLease.ttl) }}s / {{ t("etcd.admin.grantedTtl", { ttl: selectedLease.grantedTtl }) }}</span>
</div>
<label class="flex items-center gap-2 rounded border bg-muted/20 px-3 py-2 text-xs"><input v-model="autoKeepalive" type="checkbox" class="h-3.5 w-3.5" />{{ t("etcd.admin.keepalive") }}</label>
<div>
<div class="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("etcd.admin.attachedKeys") }}</span
><span>{{ selectedLease.keys.length }}{{ selectedLease.truncated ? "+" : "" }}</span>
</div>
<div class="max-h-44 overflow-auto rounded border">
<code v-for="key in selectedLease.keys" :key="`${key.encoding}:${key.data}`" class="block truncate border-b px-2 py-1 text-xs last:border-b-0">{{ key.encoding === "utf8" ? key.data : `base64:${key.data}` }}</code>
<div v-if="selectedLease.keys.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">{{ t("etcd.admin.noAttachedKeys") }}</div>
</div>
<p v-if="selectedLease.truncated" class="mt-2 text-xs text-muted-foreground">{{ t("etcd.admin.attachedKeysTruncated") }}</p>
</div>
</div>
<div v-else class="flex h-32 items-center justify-center text-xs text-muted-foreground">{{ t("etcd.admin.selectLease") }}</div>
</div>
</div>
<div v-if="leases?.partial" class="text-xs text-muted-foreground">{{ t("etcd.admin.leasePartial") }}</div>
</template>
</div>
<Dialog :open="leaseGrantDialogOpen" @update:open="updateLeaseGrantDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader
><DialogTitle>{{ t("etcd.admin.grantNewLease") }}</DialogTitle></DialogHeader
>
<form class="space-y-5" @submit.prevent="grantLease">
<label class="grid gap-1.5"
><span class="text-sm font-medium">{{ t("etcd.admin.ttl") }}</span
><Input v-model="leaseGrantTtl" class="h-10" type="number" min="1" step="1" inputmode="numeric" :placeholder="t('etcd.admin.ttlPlaceholder')" /><span class="text-xs text-muted-foreground">{{ t("etcd.admin.ttlHint") }}</span></label
>
<label class="grid gap-1.5"
><span class="text-sm font-medium"
>{{ t("etcd.admin.customLeaseId") }} <span class="font-normal text-muted-foreground">({{ t("etcd.access.optional") }})</span></span
><Input v-model="leaseGrantId" class="h-10 font-mono" inputmode="numeric" :placeholder="t('etcd.admin.customLeasePlaceholder')" /><span class="text-xs text-muted-foreground">{{ t("etcd.admin.customLeaseHint") }}</span></label
>
<DialogFooter class="gap-2 sm:gap-2"
><Button type="button" variant="outline" @click="closeLeaseGrantDialog">{{ t("etcd.admin.cancel") }}</Button
><Button type="submit" :disabled="leaseGrantBusy || !leaseGrantTtl.trim()"><Loader2 v-if="leaseGrantBusy" class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.admin.grantLease") }}</Button></DialogFooter
>
</form>
</DialogContent>
</Dialog>
<Dialog :open="watchDialogOpen" @update:open="updateWatchDialog">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ watchEditingId ? t("etcd.admin.editWatch") : t("etcd.admin.newWatch") }}</DialogTitle>
</DialogHeader>
<form class="space-y-5" @submit.prevent="saveWatchMonitor">
<label class="grid gap-1.5">
<span class="text-sm font-medium">{{ t("etcd.keyOrPrefix") }}</span>
<div class="relative">
<Input
v-model="watchFormKey"
class="h-10 font-mono"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showWatchKeySuggestions"
aria-controls="etcd-watch-key-suggestions"
:aria-activedescendant="watchSuggestionIndex >= 0 ? `etcd-watch-key-suggestion-${watchSuggestionIndex}` : undefined"
:placeholder="t('etcd.admin.watchKeyPlaceholder')"
autofocus
@focus="watchSuggestionOpen = Boolean(watchFormKey.trim())"
@blur="closeWatchKeySuggestions"
@update:model-value="onWatchKeyInput"
@keydown="onWatchKeyKeydown"
/>
<div v-if="showWatchKeySuggestions" id="etcd-watch-key-suggestions" role="listbox" class="absolute z-50 mt-1 max-h-52 w-full overflow-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-lg">
<button
v-for="(suggestion, index) in filteredWatchKeySuggestions"
:id="`etcd-watch-key-suggestion-${index}`"
:key="`${suggestion.keyBytes.encoding}:${suggestion.keyBytes.data}`"
type="button"
role="option"
:aria-selected="watchSuggestionIndex === index"
class="flex w-full items-center gap-2 px-3 py-2 text-left font-mono text-xs"
:class="watchSuggestionIndex === index ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70'"
@mouseenter="watchSuggestionIndex = index"
@mousedown.prevent="acceptWatchKeySuggestion(index)"
>
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate">{{ suggestion.key }}</span>
</button>
</div>
</div>
</label>
<fieldset class="grid gap-2">
<legend class="text-sm font-medium">{{ t("etcd.admin.watchScope") }}</legend>
<div class="flex w-fit rounded-md border p-0.5">
<Button type="button" size="sm" :variant="watchFormScope === 'key' ? 'secondary' : 'ghost'" class="h-8 px-3" @click="watchFormScope = 'key'">{{ t("etcd.admin.exact") }}</Button>
<Button type="button" size="sm" :variant="watchFormScope === 'prefix' ? 'secondary' : 'ghost'" class="h-8 px-3" @click="watchFormScope = 'prefix'">{{ t("etcd.admin.prefix") }}</Button>
</div>
<p class="text-xs leading-5 text-muted-foreground">{{ watchFormScope === "key" ? t("etcd.admin.watchKeyHint") : t("etcd.admin.watchPrefixHint") }}</p>
</fieldset>
<div class="rounded-md border bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.watchSessionHint") }}</div>
<DialogFooter class="gap-2 sm:gap-2">
<Button type="button" variant="outline" @click="closeWatchDialog">{{ t("etcd.admin.cancel") }}</Button>
<Button type="submit" :disabled="busy || !watchFormKey">{{ watchEditingId ? t("etcd.admin.saveWatch") : t("etcd.admin.createWatch") }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog :open="maintenanceApprovalOpen" @update:open="(open) => !open && closeMaintenanceApproval()">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle class="text-destructive">{{ maintenanceApprovalTitle }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<p class="text-sm text-muted-foreground">{{ t("etcd.admin.confirmationHint") }}</p>
<div class="whitespace-pre-line border-l-2 border-destructive/70 pl-3 text-sm leading-6 text-foreground">{{ maintenanceApprovalDetails }}</div>
<div class="space-y-2 border-t pt-4">
<p class="text-xs leading-5 text-muted-foreground">{{ t("etcd.admin.confirmationCredentialHint") }}</p>
<code class="block break-all rounded border bg-muted px-3 py-2 text-xs">{{ maintenanceApprovalExpected }}</code>
<Input v-model="maintenanceApprovalText" :placeholder="t('etcd.admin.confirmationPlaceholder')" autocomplete="off" :disabled="busy" @keyup.enter="confirmMaintenanceApproval" />
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="busy" @click="closeMaintenanceApproval">{{ t("etcd.admin.cancel") }}</Button>
<Button variant="destructive" :disabled="busy || maintenanceApprovalText !== maintenanceApprovalExpected" @click="confirmMaintenanceApproval">{{ t("etcd.admin.confirmExecute") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>

View File

@ -216,12 +216,12 @@ defineExpose({ refresh });
</script>
<template>
<div class="h-full overflow-auto bg-background p-4">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">{{ t("etcd.dashboard.title") }}</h2>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.description") }}</p>
</div>
<div class="flex h-full min-h-0 flex-col bg-background">
<header class="flex h-14 shrink-0 items-center gap-3 border-b px-4">
<h2 class="shrink-0 text-sm font-semibold">{{ t("etcd.dashboard.title") }}</h2>
<div class="hidden h-5 w-px bg-border sm:block" />
<p class="hidden truncate text-xs text-muted-foreground sm:block">{{ t("etcd.dashboard.description") }}</p>
<div class="flex-1" />
<div class="flex items-center gap-2">
<select v-model.number="refreshSeconds" class="h-8 rounded-md border bg-background px-2 text-xs">
<option :value="0">{{ t("etcd.dashboard.autoRefreshOff") }}</option>
@ -236,349 +236,350 @@ defineExpose({ refresh });
{{ t("etcd.dashboard.refresh") }}
</Button>
</div>
</div>
</header>
<div v-if="unsupported" class="mx-auto mt-[12vh] max-w-2xl rounded-xl border bg-muted/20 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="rounded-full bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold">{{ t("etcd.dashboard.agentUpgradeTitle") }}</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ t("etcd.dashboard.agentUpgradeDescription") }}</p>
<div class="mt-4 rounded-md border bg-background px-3 py-2.5 text-xs leading-5 text-muted-foreground">
{{ t("etcd.dashboard.agentUpgradeSteps") }}
<div class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="unsupported" class="mx-auto mt-[12vh] max-w-2xl rounded-xl border bg-muted/20 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="rounded-full bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold">{{ t("etcd.dashboard.agentUpgradeTitle") }}</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ t("etcd.dashboard.agentUpgradeDescription") }}</p>
<div class="mt-4 rounded-md border bg-background px-3 py-2.5 text-xs leading-5 text-muted-foreground">
{{ t("etcd.dashboard.agentUpgradeSteps") }}
</div>
<Button class="mt-4" size="sm" variant="outline" :disabled="loading" @click="load">
<Loader2 v-if="loading" class="mr-2 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-2 h-3.5 w-3.5" />
{{ t("etcd.dashboard.retry") }}
</Button>
</div>
<Button class="mt-4" size="sm" variant="outline" :disabled="loading" @click="load">
<Loader2 v-if="loading" class="mr-2 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-2 h-3.5 w-3.5" />
{{ t("etcd.dashboard.retry") }}
</Button>
</div>
</div>
</div>
<div v-else-if="error" class="mb-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div class="flex items-start gap-3">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<div class="text-sm font-medium text-destructive">{{ t("etcd.dashboard.loadFailed") }}</div>
<div class="mt-1 break-words text-xs leading-5 text-muted-foreground">{{ error }}</div>
</div>
</div>
</div>
<div v-if="status" class="grid gap-3">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.observedHealth") }}</div>
<div class="mt-3 flex items-center gap-2 text-xl font-semibold">
<CheckCircle2 v-if="health === 'healthy'" class="h-5 w-5 text-emerald-500" />
<AlertTriangle v-else class="h-5 w-5 text-amber-500" />
{{ t(`etcd.dashboard.health.${health}`) }}
</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Server class="h-4 w-4" /> {{ t("etcd.dashboard.reachableMembers") }}</div>
<div class="mt-3 text-xl font-semibold">{{ reachable }} / {{ status.members.length }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><KeyRound class="h-4 w-4" /> {{ t("etcd.dashboard.keyCount") }}</div>
<div class="mt-3 text-xl font-semibold">{{ status.keyCount ?? "-" }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.backendSize") }}</div>
<div class="mt-3 text-xl font-semibold">{{ formatBytes(totalDbSize) }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.fragmentation") }}</div>
<div class="mt-3 text-xl font-semibold">{{ (fragmentation * 100).toFixed(1) }}%</div>
</div>
</div>
<div class="flex flex-wrap gap-2 rounded-lg border p-3 text-xs">
<Badge variant="outline">{{ t("etcd.dashboard.cluster") }} {{ status.clusterId ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.revision") }} {{ status.revision ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.leader") }} {{ status.leaderId ?? "-" }}</Badge>
<Badge v-for="alarm in status.alarms" :key="alarm" variant="destructive">{{ alarm }}</Badge>
<Badge v-if="status.alarms.length === 0" variant="secondary">{{ t("etcd.dashboard.noAlarms") }}</Badge>
</div>
<section v-if="metrics?.available" class="rounded-lg border">
<div class="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div>
<div class="flex items-center gap-2 text-sm font-medium">
<Activity class="h-4 w-4 text-emerald-500" />
{{ t("etcd.dashboard.prometheusMetrics") }}
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.prometheusDescription") }}</p>
</div>
<div class="min-w-0 text-right text-xs text-muted-foreground">
<div class="max-w-[420px] truncate font-mono">{{ metrics.sourceUrl }}</div>
<div>{{ t("etcd.dashboard.collectedAt") }} {{ formatCollectedAt(metrics.collectedAtMs) }}</div>
</div>
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 xl:grid-cols-4">
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Activity class="h-4 w-4" /> {{ t("etcd.dashboard.requests") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatRate(requestRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.requestFailureRate") }} {{ formatPercent(requestFailurePercent) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.proposals") }}</div>
<div class="mt-2 text-xl font-semibold">{{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalLag") }} · {{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.storageQuota") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatPercent(quotaPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatBytes(metrics.dbSizeMetricBytes ?? Number.NaN) }} / {{ formatBytes(metrics.quotaBackendBytes ?? Number.NaN) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Clock3 class="h-4 w-4" /> {{ t("etcd.dashboard.uptime") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatDuration(uptimeSeconds) }}</div>
<div class="mt-1 text-xs text-muted-foreground">etcd {{ metrics.serverVersion ?? "-" }} · {{ metrics.goVersion ?? "-" }}</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Gauge class="h-4 w-4 text-sky-500" /> {{ t("etcd.dashboard.consensusReliability") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.leadership") }}</div>
<div class="mt-2 text-base font-semibold">{{ metrics.isLeader === 1 ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.leaderChanges") }} {{ formatCount(metrics.leaderChangesTotal) }} · {{ t("etcd.dashboard.knownPeers") }} {{ formatCount(metrics.knownPeers) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalState") }}</div>
<div class="mt-2 text-base font-semibold">{{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }} · Lag {{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.failedProposals") }} {{ formatCount(metrics.proposalsFailedTotal) }} · {{ formatRate(proposalFailureRate) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.raftFailures") }}</div>
<div class="mt-2 text-base font-semibold">Heartbeat {{ formatCount(metrics.heartbeatSendFailuresTotal) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Read index {{ formatCount(metrics.readIndexesFailedTotal) }} · Slow {{ formatCount(metrics.slowReadIndexesTotal) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.revisionState") }}</div>
<div class="mt-2 text-base font-semibold">{{ formatCount(metrics.mvccCurrentRevision) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.compactRevision") }} {{ formatCount(metrics.mvccCompactRevision) }} · Slow apply {{ formatCount(metrics.slowApplyTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Activity class="h-4 w-4 text-violet-500" /> {{ t("etcd.dashboard.requestLoad") }}</div>
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
<div class="overflow-auto rounded-md border">
<table class="w-full min-w-[620px] text-left text-xs">
<thead class="bg-muted/60 text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.grpcMethod") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.total") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.rate") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.failures") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.avgLatency") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in grpcMethodRows" :key="row.method" class="border-t">
<td class="px-3 py-2 font-medium">{{ row.method }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatCount(row.total) }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatRate(row.rate) }}</td>
<td class="px-3 py-2 tabular-nums">
{{ formatCount(row.failures) }} <span v-if="row.failureRate != null" class="text-muted-foreground">({{ formatRate(row.failureRate) }})</span>
</td>
<td class="px-3 py-2 tabular-nums">{{ formatMilliseconds(row.latencyMs) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Range</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccRangeRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccRangeTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Put</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccPutRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccPutTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Delete</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccDeleteRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccDeleteTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Txn</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccTxnRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccTxnTotal) }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><HardDrive class="h-4 w-4 text-amber-500" /> {{ t("etcd.dashboard.storageDisk") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.diskLatency") }}</div>
<div class="mt-2 font-semibold">WAL fsync {{ formatMilliseconds(walFsyncMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">WAL write {{ formatMilliseconds(walWriteMs) }} · {{ formatByteRate(walWriteRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Backend</div>
<div class="mt-2 font-semibold">Commit {{ formatMilliseconds(backendCommitMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot {{ formatMilliseconds(backendSnapshotMs) }} · Defrag {{ formatMilliseconds(backendDefragMs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.databaseState") }}</div>
<div class="mt-2 font-semibold">{{ formatBytes(metrics.dbSizeInUseMetricBytes ?? Number.NaN) }} {{ t("etcd.dashboard.inUse") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.openReadTransactions") }} {{ formatCount(metrics.openReadTransactions) }} · Put bytes {{ formatBytes(metrics.mvccTotalPutSizeBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.backgroundTasks") }}</div>
<div class="mt-2 font-semibold">Defrag {{ formatCount(metrics.diskDefragInflight) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot apply {{ formatCount(metrics.snapshotApplyInProgress) }}</div>
</div>
</div>
</div>
<div class="grid border-t xl:grid-cols-2 xl:divide-x">
<div class="px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Eye class="h-4 w-4 text-cyan-500" /> {{ t("etcd.dashboard.watchState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchers") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatcherTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchStreams") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatchStreamTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.events") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(mvccEventRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.slowPending") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccSlowWatcherTotal) }} / {{ formatCount(metrics.mvccPendingEventsTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3 xl:border-t-0">
<div class="flex items-center gap-2 text-sm font-medium"><Radio class="h-4 w-4 text-emerald-500" /> {{ t("etcd.dashboard.leaseState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.granted") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseGrantedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.renewed") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRenewedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.revokedExpired") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRevokedRate) }} / {{ formatRate(leaseExpiredRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.averageTtl") }}</div>
<div class="mt-1 text-base font-semibold">{{ averageLeaseTtl == null ? "-" : `${averageLeaseTtl.toFixed(1)}s` }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Cpu class="h-4 w-4 text-rose-500" /> {{ t("etcd.dashboard.runtimeResources") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.memory") }}</div>
<div class="mt-2 font-semibold">RSS {{ formatBytes(metrics.residentMemoryBytes ?? Number.NaN) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Heap {{ formatBytes(metrics.goHeapAllocBytes ?? Number.NaN) }} / {{ formatBytes(metrics.goHeapSysBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.runtime") }}</div>
<div class="mt-2 font-semibold">{{ formatCount(metrics.goroutines) }} goroutines</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatCount(metrics.goThreads) }} threads · GOMAXPROCS {{ formatCount(metrics.goMaxProcs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processResources") }}</div>
<div class="mt-2 font-semibold">CPU {{ formatPercent(cpuPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">FD {{ formatCount(metrics.openFds) }} / {{ formatCount(metrics.maxFds) }} · {{ formatPercent(fdPercent) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Network class="h-3.5 w-3.5" /> {{ t("etcd.dashboard.networkTraffic") }}</div>
<div class="mt-2 font-semibold"> {{ formatByteRate(clientReceivedRate) }} · {{ formatByteRate(clientSentRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Peer {{ formatByteRate(peerReceivedRate) }} · {{ formatByteRate(peerSentRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processNetworkGc") }}</div>
<div class="mt-2 font-semibold"> {{ formatByteRate(processReceivedRate) }} · {{ formatByteRate(processTransmittedRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">GC {{ formatMilliseconds(goGcMs) }} · {{ formatCount(metrics.goHeapObjects) }} objects</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 border-t px-4 py-3 text-xs">
<Badge variant="outline">etcd {{ metrics.serverVersion ?? "-" }}</Badge>
<Badge variant="outline">Cluster {{ metrics.clusterVersion ?? "-" }}</Badge>
<Badge variant="outline">Auth revision {{ formatCount(metrics.authRevision) }}</Badge>
<Badge variant="outline">Keys {{ formatCount(metrics.mvccKeysTotal) }}</Badge>
<Badge variant="outline">Health {{ formatCount(metrics.healthSuccessTotal) }} / {{ formatCount(metrics.healthFailuresTotal) }}</Badge>
<span v-if="requestRate == null" class="text-muted-foreground">{{ t("etcd.dashboard.rateNeedsRefresh") }}</span>
</div>
</section>
<section v-else-if="metrics && !metrics.available" class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<div v-else-if="error" class="mb-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div class="flex items-start gap-3">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("etcd.dashboard.metricsUnavailable") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("etcd.dashboard.metricsUnavailableHint") }}</p>
<p v-if="metrics.error" class="mt-2 break-words font-mono text-xs text-muted-foreground">{{ metrics.error }}</p>
<div class="text-sm font-medium text-destructive">{{ t("etcd.dashboard.loadFailed") }}</div>
<div class="mt-1 break-words text-xs leading-5 text-muted-foreground">{{ error }}</div>
</div>
</div>
</section>
<div class="overflow-auto rounded-lg border">
<table class="w-full min-w-[1050px] text-left text-sm">
<thead class="bg-muted/70 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.endpointMember") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.role") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.version") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.revision") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.raftTermApplied") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.dbSizeInUse") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.latency") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in status.members" :key="member.endpoint" class="border-t">
<td class="px-3 py-2">
<div class="font-medium">{{ member.name || member.endpoint }}</div>
<div class="max-w-72 truncate font-mono text-xs text-muted-foreground">{{ member.endpoint }} · {{ member.memberId || "-" }}</div>
</td>
<td class="px-3 py-2">{{ member.learner ? t("etcd.dashboard.learner") : member.memberId === status.leaderId ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</td>
<td class="px-3 py-2">{{ member.version || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.revision || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.raftTerm || "-" }} / {{ member.raftAppliedIndex || "-" }}</td>
<td class="px-3 py-2">{{ formatBytes(Number(member.dbSize || 0)) }} / {{ formatBytes(Number(member.dbSizeInUse || 0)) }}</td>
<td class="px-3 py-2">{{ member.latencyMs == null ? "-" : `${member.latencyMs} ms` }}</td>
<td class="px-3 py-2">
<Badge :variant="member.reachable && member.errors.length === 0 ? 'secondary' : 'destructive'">
{{ member.reachable ? member.errors[0] || t("etcd.dashboard.reachable") : member.errors[0] || t("etcd.dashboard.unreachable") }}
</Badge>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="loading" class="flex h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.dashboard.loading") }}
<div v-if="status" class="grid gap-3">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.observedHealth") }}</div>
<div class="mt-3 flex items-center gap-2 text-xl font-semibold">
<CheckCircle2 v-if="health === 'healthy'" class="h-5 w-5 text-emerald-500" />
<AlertTriangle v-else class="h-5 w-5 text-amber-500" />
{{ t(`etcd.dashboard.health.${health}`) }}
</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Server class="h-4 w-4" /> {{ t("etcd.dashboard.reachableMembers") }}</div>
<div class="mt-3 text-xl font-semibold">{{ reachable }} / {{ status.members.length }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><KeyRound class="h-4 w-4" /> {{ t("etcd.dashboard.keyCount") }}</div>
<div class="mt-3 text-xl font-semibold">{{ status.keyCount ?? "-" }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.backendSize") }}</div>
<div class="mt-3 text-xl font-semibold">{{ formatBytes(totalDbSize) }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.fragmentation") }}</div>
<div class="mt-3 text-xl font-semibold">{{ (fragmentation * 100).toFixed(1) }}%</div>
</div>
</div>
<div class="flex flex-wrap gap-2 rounded-lg border p-3 text-xs">
<Badge variant="outline">{{ t("etcd.dashboard.cluster") }} {{ status.clusterId ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.revision") }} {{ status.revision ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.leader") }} {{ status.leaderId ?? "-" }}</Badge>
<Badge v-for="alarm in status.alarms" :key="alarm" variant="destructive">{{ alarm }}</Badge>
<Badge v-if="status.alarms.length === 0" variant="secondary">{{ t("etcd.dashboard.noAlarms") }}</Badge>
</div>
<section v-if="metrics?.available" class="rounded-lg border">
<div class="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div>
<div class="flex items-center gap-2 text-sm font-medium">
<Activity class="h-4 w-4 text-emerald-500" />
{{ t("etcd.dashboard.prometheusMetrics") }}
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.prometheusDescription") }}</p>
</div>
<div class="min-w-0 text-right text-xs text-muted-foreground">
<div class="max-w-[420px] truncate font-mono">{{ metrics.sourceUrl }}</div>
<div>{{ t("etcd.dashboard.collectedAt") }} {{ formatCollectedAt(metrics.collectedAtMs) }}</div>
</div>
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 xl:grid-cols-4">
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Activity class="h-4 w-4" /> {{ t("etcd.dashboard.requests") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatRate(requestRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.requestFailureRate") }} {{ formatPercent(requestFailurePercent) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.proposals") }}</div>
<div class="mt-2 text-xl font-semibold">{{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalLag") }} · {{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.storageQuota") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatPercent(quotaPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatBytes(metrics.dbSizeMetricBytes ?? Number.NaN) }} / {{ formatBytes(metrics.quotaBackendBytes ?? Number.NaN) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Clock3 class="h-4 w-4" /> {{ t("etcd.dashboard.uptime") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatDuration(uptimeSeconds) }}</div>
<div class="mt-1 text-xs text-muted-foreground">etcd {{ metrics.serverVersion ?? "-" }} · {{ metrics.goVersion ?? "-" }}</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Gauge class="h-4 w-4 text-sky-500" /> {{ t("etcd.dashboard.consensusReliability") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.leadership") }}</div>
<div class="mt-2 text-base font-semibold">{{ metrics.isLeader === 1 ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.leaderChanges") }} {{ formatCount(metrics.leaderChangesTotal) }} · {{ t("etcd.dashboard.knownPeers") }} {{ formatCount(metrics.knownPeers) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalState") }}</div>
<div class="mt-2 text-base font-semibold">{{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }} · {{ t("etcd.dashboard.lag") }} {{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.failedProposals") }} {{ formatCount(metrics.proposalsFailedTotal) }} · {{ formatRate(proposalFailureRate) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.raftFailures") }}</div>
<div class="mt-2 text-base font-semibold">Heartbeat {{ formatCount(metrics.heartbeatSendFailuresTotal) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Read index {{ formatCount(metrics.readIndexesFailedTotal) }} · Slow {{ formatCount(metrics.slowReadIndexesTotal) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.revisionState") }}</div>
<div class="mt-2 text-base font-semibold">{{ formatCount(metrics.mvccCurrentRevision) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.compactRevision") }} {{ formatCount(metrics.mvccCompactRevision) }} · {{ t("etcd.dashboard.slowApply") }} {{ formatCount(metrics.slowApplyTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Activity class="h-4 w-4 text-violet-500" /> {{ t("etcd.dashboard.requestLoad") }}</div>
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
<div class="overflow-auto rounded-md border">
<table class="w-full min-w-[620px] text-left text-xs">
<thead class="bg-muted/60 text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.grpcMethod") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.total") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.rate") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.failures") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.avgLatency") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in grpcMethodRows" :key="row.method" class="border-t">
<td class="px-3 py-2 font-medium">{{ row.method }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatCount(row.total) }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatRate(row.rate) }}</td>
<td class="px-3 py-2 tabular-nums">
{{ formatCount(row.failures) }} <span v-if="row.failureRate != null" class="text-muted-foreground">({{ formatRate(row.failureRate) }})</span>
</td>
<td class="px-3 py-2 tabular-nums">{{ formatMilliseconds(row.latencyMs) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Range</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccRangeRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccRangeTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Put</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccPutRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccPutTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Delete</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccDeleteRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccDeleteTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Txn</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccTxnRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccTxnTotal) }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><HardDrive class="h-4 w-4 text-amber-500" /> {{ t("etcd.dashboard.storageDisk") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.diskLatency") }}</div>
<div class="mt-2 font-semibold">WAL fsync {{ formatMilliseconds(walFsyncMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">WAL write {{ formatMilliseconds(walWriteMs) }} · {{ formatByteRate(walWriteRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Backend</div>
<div class="mt-2 font-semibold">Commit {{ formatMilliseconds(backendCommitMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot {{ formatMilliseconds(backendSnapshotMs) }} · Defrag {{ formatMilliseconds(backendDefragMs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.databaseState") }}</div>
<div class="mt-2 font-semibold">{{ formatBytes(metrics.dbSizeInUseMetricBytes ?? Number.NaN) }} {{ t("etcd.dashboard.inUse") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.openReadTransactions") }} {{ formatCount(metrics.openReadTransactions) }} · {{ t("etcd.dashboard.putBytes") }} {{ formatBytes(metrics.mvccTotalPutSizeBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.backgroundTasks") }}</div>
<div class="mt-2 font-semibold">Defrag {{ formatCount(metrics.diskDefragInflight) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot apply {{ formatCount(metrics.snapshotApplyInProgress) }}</div>
</div>
</div>
</div>
<div class="grid border-t xl:grid-cols-2 xl:divide-x">
<div class="px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Eye class="h-4 w-4 text-cyan-500" /> {{ t("etcd.dashboard.watchState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchers") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatcherTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchStreams") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatchStreamTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.events") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(mvccEventRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.slowPending") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccSlowWatcherTotal) }} / {{ formatCount(metrics.mvccPendingEventsTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3 xl:border-t-0">
<div class="flex items-center gap-2 text-sm font-medium"><Radio class="h-4 w-4 text-emerald-500" /> {{ t("etcd.dashboard.leaseState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.granted") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseGrantedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.renewed") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRenewedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.revokedExpired") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRevokedRate) }} / {{ formatRate(leaseExpiredRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.averageTtl") }}</div>
<div class="mt-1 text-base font-semibold">{{ averageLeaseTtl == null ? "-" : `${averageLeaseTtl.toFixed(1)}s` }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Cpu class="h-4 w-4 text-rose-500" /> {{ t("etcd.dashboard.runtimeResources") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.memory") }}</div>
<div class="mt-2 font-semibold">RSS {{ formatBytes(metrics.residentMemoryBytes ?? Number.NaN) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Heap {{ formatBytes(metrics.goHeapAllocBytes ?? Number.NaN) }} / {{ formatBytes(metrics.goHeapSysBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.runtime") }}</div>
<div class="mt-2 font-semibold">{{ formatCount(metrics.goroutines) }} goroutines</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatCount(metrics.goThreads) }} threads · GOMAXPROCS {{ formatCount(metrics.goMaxProcs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processResources") }}</div>
<div class="mt-2 font-semibold">CPU {{ formatPercent(cpuPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">FD {{ formatCount(metrics.openFds) }} / {{ formatCount(metrics.maxFds) }} · {{ formatPercent(fdPercent) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Network class="h-3.5 w-3.5" /> {{ t("etcd.dashboard.networkTraffic") }}</div>
<div class="mt-2 font-semibold"> {{ formatByteRate(clientReceivedRate) }} · {{ formatByteRate(clientSentRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Peer {{ formatByteRate(peerReceivedRate) }} · {{ formatByteRate(peerSentRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processNetworkGc") }}</div>
<div class="mt-2 font-semibold"> {{ formatByteRate(processReceivedRate) }} · {{ formatByteRate(processTransmittedRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">GC {{ formatMilliseconds(goGcMs) }} · {{ formatCount(metrics.goHeapObjects) }} objects</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 border-t px-4 py-3 text-xs">
<Badge variant="outline">etcd {{ metrics.serverVersion ?? "-" }}</Badge>
<Badge variant="outline">Cluster {{ metrics.clusterVersion ?? "-" }}</Badge>
<Badge variant="outline">Auth revision {{ formatCount(metrics.authRevision) }}</Badge>
<Badge variant="outline">Keys {{ formatCount(metrics.mvccKeysTotal) }}</Badge>
<Badge variant="outline">Health {{ formatCount(metrics.healthSuccessTotal) }} / {{ formatCount(metrics.healthFailuresTotal) }}</Badge>
<span v-if="requestRate == null" class="text-muted-foreground">{{ t("etcd.dashboard.rateNeedsRefresh") }}</span>
</div>
</section>
<section v-else-if="metrics && !metrics.available" class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<div class="flex items-start gap-3">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("etcd.dashboard.metricsUnavailable") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("etcd.dashboard.metricsUnavailableHint") }}</p>
<p v-if="metrics.error" class="mt-2 break-words font-mono text-xs text-muted-foreground">{{ metrics.error }}</p>
</div>
</div>
</section>
<div class="overflow-auto rounded-lg border">
<table class="w-full min-w-[1050px] text-left text-sm">
<thead class="bg-muted/70 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.endpointMember") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.role") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.version") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.revision") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.raftTermApplied") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.dbSizeInUse") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.latency") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in status.members" :key="member.endpoint" class="border-t">
<td class="px-3 py-2">
<div class="font-medium">{{ member.name || member.endpoint }}</div>
<div class="max-w-72 truncate font-mono text-xs text-muted-foreground">{{ member.endpoint }} · {{ member.memberId || "-" }}</div>
</td>
<td class="px-3 py-2">{{ member.learner ? t("etcd.dashboard.learner") : member.memberId === status.leaderId ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</td>
<td class="px-3 py-2">{{ member.version || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.revision || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.raftTerm || "-" }} / {{ member.raftAppliedIndex || "-" }}</td>
<td class="px-3 py-2">{{ formatBytes(Number(member.dbSize || 0)) }} / {{ formatBytes(Number(member.dbSizeInUse || 0)) }}</td>
<td class="px-3 py-2">{{ member.latencyMs == null ? "-" : `${member.latencyMs} ms` }}</td>
<td class="px-3 py-2">
<Badge :variant="member.reachable && member.errors.length === 0 ? 'secondary' : 'destructive'">
{{ member.reachable ? member.errors[0] || t("etcd.dashboard.reachable") : member.errors[0] || t("etcd.dashboard.unreachable") }}
</Badge>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="loading" class="flex h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.dashboard.loading") }}
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -166,6 +166,47 @@ describe("EtcdKeyBrowser TTL capability recovery", () => {
});
});
describe("EtcdKeyBrowser global search", () => {
it("clears results and counters while retaining the search form", async () => {
backend.etcdListPrefix.mockResolvedValue({
keys: [
{
key: "/test/config",
keyBytes: { encoding: "utf8", data: "/test/config" },
value: { encoding: "utf8", data: "matched value" },
modRevision: "8",
},
],
continuation: null,
revision: "8",
});
await mountBrowser();
const openSearch = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.globalSearch"));
openSearch?.click();
await flushUi();
const input = root!.querySelector<HTMLInputElement>('input[placeholder="etcd.searchPlaceholder"]')!;
input.value = "matched";
input.dispatchEvent(new Event("input"));
await flushUi();
const submit = [...root!.querySelectorAll("button")].filter((button) => button.textContent?.includes("etcd.globalSearch")).at(-1);
submit?.click();
await flushUi();
expect(root!.textContent).toContain("/test/config");
const clear = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.clearSearchResults"));
expect(clear).toBeTruthy();
clear?.click();
await flushUi();
expect(root!.textContent).not.toContain("/test/config");
expect(root!.textContent).toContain("已扫描 0 个 Key");
expect(input.value).toBe("matched");
expect([...root!.querySelectorAll("button")].some((button) => button.textContent?.includes("etcd.clearSearchResults"))).toBe(false);
});
});
describe("EtcdKeyBrowser byte-identity routing", () => {
it("keeps colliding display keys on distinct keyBytes routes", async () => {
backend.etcdListPrefix.mockResolvedValue({
@ -264,10 +305,10 @@ describe("EtcdKeyBrowser byte-identity routing", () => {
searchSubmit?.click();
await flushUi();
const rows = [...root.querySelectorAll("tbody tr")];
expect(rows).toHaveLength(2);
rows[0].dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
rows[1].dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
const resultButtons = [...root.querySelectorAll("button")].filter((button) => button.textContent?.includes("[base64:/w==]"));
expect(resultButtons).toHaveLength(2);
resultButtons[0].click();
resultButtons[1].click();
await flushUi();
expect(backend.selectKeyCalls).toHaveLength(2);

View File

@ -4,7 +4,7 @@ import { useI18n } from "vue-i18n";
import { Pane, Splitpanes } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { useConnectionStore } from "@/stores/connectionStore";
import { ChevronDown, ChevronRight, Clock3, Copy, Download, FolderClosed, FolderOpen, KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, Trash2 } from "@lucide/vue";
import { Activity, ChevronDown, ChevronRight, Clock3, Copy, Download, FolderClosed, FolderOpen, KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -96,6 +96,12 @@ interface KvKeyBrowserLabels {
summaryVersion?: string;
summaryLease?: string;
summarySize?: string;
watch?: string;
selectExistingLease?: string;
enterLeaseId?: string;
leasePickerHint?: string;
noLeasePickerHint?: string;
registryWarning?: string;
}
interface KvCreateModeOption {
@ -154,6 +160,9 @@ const props = withDefaults(
safeWrite?: boolean;
allowBinaryEdit?: boolean;
readOnly?: boolean;
onWatchKey?: (route: KvKeyRoute) => void;
leaseOptions?: Array<{ id: KvInt64; ttl: number; grantedTtl?: number }>;
onLeaseOptionsRequested?: () => void;
}>(),
{
supportsCreateModes: false,
@ -167,6 +176,7 @@ const props = withDefaults(
safeWrite: false,
allowBinaryEdit: false,
readOnly: false,
leaseOptions: () => [],
},
);
@ -175,6 +185,8 @@ const { toast } = useToast();
const connectionStore = useConnectionStore();
const searchInputRef = ref<HTMLInputElement>();
const prefix = ref("");
const keySuggestionOpen = ref(false);
const keySuggestionIndex = ref(-1);
const keys = ref<KvKeySummary[]>([]);
const continuation = ref<string | null>(null);
const listRevision = ref<KvInt64 | null>(null);
@ -232,6 +244,7 @@ let metadataRefreshInFlight = false;
let keyListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let keyListRefreshGeneration = 0;
let keyListRefreshDelayMs = keyListRefreshBaseIntervalMs;
let initialLoadPromise: Promise<void> | null = null;
type LoadKeysOptions = {
preserveSelection?: boolean;
};
@ -262,6 +275,21 @@ function knownLeaseKeyRoutes(): KvKeyRoute[] {
}
const tree = computed(() => buildKvKeyTree(keys.value));
const keySuggestions = computed(() => {
const query = prefix.value.trim();
if (!query) return [];
const seen = new Set<string>();
const suggestions: KvKeySummary[] = [];
for (const key of keys.value) {
if (key.key === query || !key.key.startsWith(query) || seen.has(summaryIdentity(key))) continue;
seen.add(summaryIdentity(key));
suggestions.push(key);
if (suggestions.length === 8) break;
}
return suggestions;
});
const showKeySuggestions = computed(() => keySuggestionOpen.value && keySuggestions.value.length > 0);
const visibleRows = computed<BrowserTreeRow[]>(() => {
if (props.lazyHierarchy) return flattenLazyKvKeyTree(lazyTreeState, expandedGroupIds.value);
return flattenVisibleKvKeyTree(tree.value, expandedGroupIds.value).map((row) => ({ type: "node", node: row.node, depth: row.depth }));
@ -333,6 +361,61 @@ function preserveExpandedGroups(expandAll = false) {
expandedGroupIds.value = preserveKvExpandedGroupIds(tree.value, expandedGroupIds.value, expandAll);
}
function closeKeySuggestions() {
keySuggestionOpen.value = false;
keySuggestionIndex.value = -1;
}
function onPrefixInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
keySuggestionOpen.value = Boolean(value.trim());
keySuggestionIndex.value = -1;
}
function moveKeySuggestion(delta: number) {
if (!keySuggestions.value.length) return;
keySuggestionOpen.value = true;
keySuggestionIndex.value = (keySuggestionIndex.value + delta + keySuggestions.value.length) % keySuggestions.value.length;
}
function acceptKeySuggestion(index: number) {
const suggestion = keySuggestions.value[index];
if (!suggestion) return;
prefix.value = suggestion.key;
closeKeySuggestions();
void loadKeys(true);
}
function onPrefixKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === "Escape") {
closeKeySuggestions();
return;
}
if (event.key === "ArrowDown") {
if (!keySuggestions.value.length) return;
event.preventDefault();
moveKeySuggestion(1);
return;
}
if (event.key === "ArrowUp") {
if (!keySuggestions.value.length) return;
event.preventDefault();
moveKeySuggestion(-1);
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
if (showKeySuggestions.value && keySuggestionIndex.value >= 0) {
acceptKeySuggestion(keySuggestionIndex.value);
return;
}
closeKeySuggestions();
void loadKeys(true);
}
function handleKvBrowserSplitResized(payload: { panes?: { size: number }[] }) {
const size = payload.panes?.[0]?.size;
if (typeof size !== "number" || size < 20 || size > 70) return;
@ -756,6 +839,11 @@ function openCreateDialog(parentPath?: string) {
showEditDialog.value = true;
}
function selectExpiryMode(mode: KvExpiryMode) {
editExpiryMode.value = mode;
if (mode === "lease") props.onLeaseOptionsRequested?.();
}
function openEditDialog() {
if (!selectedKey.value || !canEditSelectedValue.value) return;
isCreating.value = false;
@ -930,6 +1018,15 @@ async function copySelectedKey() {
await navigator.clipboard.writeText(selectedKey.value);
}
function watchSelectedKey() {
if (!selectedKey.value || !props.onWatchKey) return;
props.onWatchKey({
key: selectedKey.value,
keyIdentity: selectedKeyIdentity.value,
keyBytes: selectedKeyBytes.value,
});
}
function downloadText(filename: string, content: string, type = "application/json") {
const url = URL.createObjectURL(new Blob([content], { type }));
const anchor = document.createElement("a");
@ -1206,6 +1303,27 @@ function refresh(): boolean {
return true;
}
function expandPathToKey(key: string) {
if (props.lazyHierarchy) return;
const segments = key.split("/").filter(Boolean);
if (segments.length < 2) return;
const next = new Set(expandedGroupIds.value);
const groupPrefix = key.startsWith("/") ? "/" : "";
for (let index = 1; index < segments.length; index++) {
next.add(`group:${groupPrefix}${segments.slice(0, index).join("\u0000")}`);
}
expandedGroupIds.value = next;
}
async function selectKeyFromNavigation(key: string | KvKeyRoute) {
// Search results can remount this browser. Wait for the initial list reset
// before applying the selection so it cannot clear the detail pane afterward.
await initialLoadPromise?.catch(() => undefined);
const route = routeFromKey(key);
expandPathToKey(route.key);
await loadSelectedKey(route);
}
watch(
() => props.connectionId,
async () => {
@ -1251,13 +1369,19 @@ watch(editKey, () => {
editErrorKind.value = "request";
});
onMounted(async () => {
try {
await connectionStore.ensureConnected(props.connectionId);
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
void loadKeys(true);
onMounted(() => {
initialLoadPromise = (async () => {
try {
await connectionStore.ensureConnected(props.connectionId);
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
try {
await loadKeys(true);
} catch {
// The browser's normal refresh path can retry after a transient failure.
}
})();
});
onBeforeUnmount(() => {
@ -1267,7 +1391,7 @@ onBeforeUnmount(() => {
defineExpose({
focusSearch,
refresh,
selectKey: (key: string | KvKeyRoute) => loadSelectedKey(key),
selectKey: selectKeyFromNavigation,
openCreate: (parentPath?: string) => openCreateDialog(parentPath),
selection: () => ({ key: selectedKey.value, value: selectedValue.value }),
});
@ -1278,7 +1402,38 @@ defineExpose({
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input ref="searchInputRef" v-model="prefix" class="h-8 pl-8" :placeholder="labels.prefixPlaceholder" @keyup.enter="loadKeys(true)" />
<Input
ref="searchInputRef"
v-model="prefix"
class="h-8 pl-8"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showKeySuggestions"
aria-controls="kv-key-prefix-suggestions"
:aria-activedescendant="keySuggestionIndex >= 0 ? `kv-key-prefix-suggestion-${keySuggestionIndex}` : undefined"
:placeholder="labels.prefixPlaceholder"
@input="onPrefixInput"
@focus="keySuggestionOpen = Boolean(prefix.trim())"
@blur="closeKeySuggestions"
@keydown="onPrefixKeydown"
/>
<div v-if="showKeySuggestions" id="kv-key-prefix-suggestions" role="listbox" class="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-lg">
<button
v-for="(suggestion, index) in keySuggestions"
:id="`kv-key-prefix-suggestion-${index}`"
:key="summaryIdentity(suggestion)"
type="button"
role="option"
:aria-selected="keySuggestionIndex === index"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-xs"
:class="keySuggestionIndex === index ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70'"
@mouseenter="keySuggestionIndex = index"
@mousedown.prevent="acceptKeySuggestion(index)"
>
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate">{{ suggestion.key }}</span>
</button>
</div>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadKeys(true, { preserveSelection: true })">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
@ -1306,8 +1461,8 @@ defineExpose({
<CustomContextMenu v-if="row.type === 'node'" :items="nodeContextMenuItems(row.node)" v-slot="{ onContextMenu }">
<button
type="button"
class="flex h-8 w-full items-center gap-1.5 px-2 text-left hover:bg-accent"
:class="{ 'bg-accent/70': rowIsSelected(row.node) }"
class="flex h-8 w-full items-center gap-1.5 px-2 text-left transition-colors hover:bg-accent"
:class="rowIsSelected(row.node) ? 'bg-primary/10 font-medium text-foreground shadow-[inset_3px_0_0_hsl(var(--primary))]' : ''"
:style="{ paddingLeft: `${8 + row.depth * 18}px` }"
@click="onRowClick(row.node)"
@dblclick.stop.prevent="onRowDoubleClick(row.node)"
@ -1369,6 +1524,10 @@ defineExpose({
</div>
</div>
<div class="flex shrink-0 gap-2">
<Button v-if="onWatchKey" size="sm" variant="outline" class="h-8 gap-1.5" @click="watchSelectedKey">
<Activity class="h-3.5 w-3.5" />
{{ labels.watch || "Watch" }}
</Button>
<Button v-if="api.history" size="sm" variant="outline" class="h-8 gap-1.5" @click="openHistory">
<Clock3 class="h-3.5 w-3.5" />
{{ labels.history || "History" }}
@ -1427,7 +1586,9 @@ defineExpose({
<div v-if="editError && editErrorKind === 'keyAlreadyExists'" class="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
{{ editError }}
</div>
<div v-if="highRiskRegistryKey" class="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">This key is under /registry/, a namespace commonly used by control-plane components. Verify the owner and impact before saving.</div>
<div v-if="highRiskRegistryKey" class="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{{ labels.registryWarning || "This key is under /registry/, a namespace commonly used by control-plane components. Verify the owner and impact before saving." }}
</div>
</div>
<div v-if="showCreateModeSelect" class="grid gap-2">
@ -1454,7 +1615,7 @@ defineExpose({
class="flex min-h-20 items-start gap-3 rounded-md border bg-background px-3 py-3 text-left transition-colors hover:border-primary/50 disabled:cursor-not-allowed disabled:opacity-45"
:class="editExpiryMode === option.value ? 'border-primary bg-primary/5 ring-1 ring-primary/30' : 'border-input'"
:disabled="option.disabled"
@click="editExpiryMode = option.value"
@click="selectExpiryMode(option.value)"
>
<span class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border" :class="editExpiryMode === option.value ? 'border-primary' : 'border-muted-foreground/50'">
<span v-if="editExpiryMode === option.value" class="h-2 w-2 rounded-full bg-primary" />
@ -1471,7 +1632,16 @@ defineExpose({
</div>
<div v-else-if="editExpiryMode === 'lease'" class="grid gap-2 md:grid-cols-[160px_1fr] md:items-center">
<Label for="kv-edit-lease">{{ labels.leaseId || "Lease ID" }}</Label>
<Input id="kv-edit-lease" v-model="editLeaseId" class="h-10 font-mono" inputmode="numeric" :placeholder="labels.leasePlaceholder || 'Existing Lease ID'" />
<div class="grid gap-2">
<Select v-if="leaseOptions.length" :model-value="editLeaseId" @update:model-value="(value) => (editLeaseId = String(value))">
<SelectTrigger class="h-10 font-mono"><SelectValue :placeholder="labels.selectExistingLease || 'Select an existing Lease'" /></SelectTrigger>
<SelectContent
><SelectItem v-for="lease in leaseOptions" :key="lease.id" :value="String(lease.id)">{{ lease.id }} · TTL {{ lease.ttl }}s</SelectItem></SelectContent
>
</Select>
<Input id="kv-edit-lease" v-model="editLeaseId" class="h-10 font-mono" inputmode="numeric" :placeholder="leaseOptions.length ? labels.enterLeaseId || 'Or enter a Lease ID manually' : labels.leasePlaceholder || 'Existing Lease ID'" />
<span class="text-xs text-muted-foreground">{{ leaseOptions.length ? labels.leasePickerHint || "Choose a Lease from this session or enter a Lease ID manually." : labels.noLeasePickerHint || "No Lease is available in this session. Enter an ID manually and save." }}</span>
</div>
</div>
<div v-if="showTtlUnavailable" class="text-xs text-amber-700 dark:text-amber-300">
{{ labels.ttlUnavailable }}

View File

@ -14,17 +14,15 @@ describe("KvKeyBrowser node export", () => {
it("delegates etcd directory export to a fixed-revision recursive scan", () => {
expect(etcdBrowserSource).toContain("exportScope: exportEtcdNodeScope");
expect(etcdBrowserSource).toContain("const scan = await scanConnection(connectionId, request.path)");
expect(etcdBrowserSource).toContain("isKeyInKvExportScope(displayKey(keyValue(entry), entry.key), request)");
expect(etcdBrowserSource).toContain("isKeyInKvExportScope(displayKey(keyValue(entry)), request)");
expect(etcdBrowserSource).toContain("const missingValue = entries.find((entry) => !entry.value)");
});
it("keeps mirror deletes inside the exported directory and compares canonical Key bytes", () => {
expect(etcdBrowserSource).toContain("if (!isKeyInKvExportScope(shown, mirrorScope)");
expect(etcdBrowserSource).toContain("sourceKeys.has(kvValueByteIdentity(bytes))");
it("compares canonical Key bytes without exposing mirror-delete operations", () => {
expect(etcdBrowserSource).toContain("id: `source:${kvValueByteIdentity(source.key)}`");
expect(etcdBrowserSource).not.toContain("id: source.key.data");
expect(etcdBrowserSource).toContain('const mirrorDeleteAvailable = computed(() => transferBundle.value?.scopeKind === "prefix")');
expect(etcdBrowserSource).toContain(':disabled="!mirrorDeleteAvailable || transferLoading || transferApplying"');
expect(etcdBrowserSource).not.toContain("mirrorDeletes");
expect(etcdBrowserSource).not.toContain('operation: "delete"');
});
it("snapshots the target and invalidates stale transfer previews", () => {

View File

@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -480,6 +480,7 @@ function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "vector") return TableProperties;
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
if (tab.mode === "etcd-dashboard") return Gauge;
if (tab.mode === "etcd-access-control") return ShieldCheck;
if (tab.mode === "nacos") return Network;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
@ -624,6 +625,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
@ -818,6 +820,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />

View File

@ -46,6 +46,7 @@ const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/Re
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
const EtcdAccessControl = defineAsyncComponent(() => import("@/components/etcd/EtcdAccessControl.vue"));
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
@ -1875,6 +1876,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<template v-else-if="activeTab.mode === 'etcd-access-control'">
<div class="flex-1 min-h-0">
<EtcdAccessControl :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<!-- ZooKeeper mode: znode browser -->
<template v-else-if="activeTab.mode === 'zookeeper'">
<div class="flex-1 min-h-0">

View File

@ -1039,7 +1039,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu
}
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "zookeeper-root") return;
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root") return;
const connId = target.connectionId;
if (!connId) return;

View File

@ -609,6 +609,10 @@ async function toggle() {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:dashboard`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-dashboard");
} else if (node.type === "etcd-access-control" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:access-control`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-access-control");
} else if (node.type === "zookeeper-root" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "ZooKeeper"}:keys`;
@ -4091,7 +4095,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "zookeeper-root") {
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
return true;
}

View File

@ -30,6 +30,7 @@ import {
UsersRound,
CalendarClock,
Gauge,
ShieldCheck,
Lock,
Archive,
Square,
@ -257,6 +258,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Database, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
case "etcd-access-control":
return { icon: ShieldCheck, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "mongo-db":

View File

@ -897,6 +897,7 @@ export default {
redis: "Redis",
etcd: "etcd",
etcdDashboard: "etcd Dashboard",
etcdAccessControl: "etcd Access Control",
zookeeper: "ZooKeeper",
mongo: "Mongo",
gridfs: "GridFS",
@ -2875,6 +2876,7 @@ export default {
searchPlaceholder: "Search key or value contents",
searchProgress: "Scanned {scanned} keys, matched {matched}",
exportResults: "Export results",
clearSearchResults: "Clear results",
exported: "Exported {count} keys",
importPreview: "Import preview",
syncPreview: "Sync preview",
@ -2892,6 +2894,205 @@ export default {
transferPartiallyApplied: "Applied {count} operations before the batch stopped: {error}. The preview has been refreshed; retry only the remaining selected operations.",
transferPartialRefreshFailed: "Applied {count} operations before the batch stopped: {error}. Refreshing the preview also failed: {previewError}. Close and reopen the preview before retrying.",
targetChangedDuringTransfer: "The target connection changed while the batch was running",
watch: "Watch",
lease: "Lease",
key: "Key",
keyOrPrefix: "Key or prefix",
selectExistingLease: "Select an existing Lease",
enterLeaseId: "Or enter a Lease ID manually",
leasePickerHint: "Choose a Lease from this session or enter a Lease ID manually.",
noLeasePickerHint: "No Lease is available in this session. Enter an ID manually and save.",
registryWarning: "This key is under /registry/, a namespace commonly used by control-plane components. Verify the owner and impact before saving.",
access: {
users: "Users",
roles: "Roles",
title: "Access control",
description: "Manage native etcd users, roles, and Key permissions",
readOnly: "Read-only connection",
refresh: "Refresh",
userCount: "Users ({count})",
roleCount: "Roles ({count})",
createUser: "Create user",
createRole: "Create role",
noUsers: "No etcd users have been created",
noRoles: "No etcd roles have been created",
selectUser: "Select a user on the left to view roles and credential actions",
selectRole: "Select a role on the left to view and manage permissions",
loadingUser: "Loading user details...",
loadingRole: "Loading role details...",
userRolesSummary: "{count} roles assigned. Passwords are never read, displayed, or stored by the client.",
changePassword: "Change password",
deleteUser: "Delete user",
roleAssignments: "Role assignments",
roleAssignmentHint: "Roles determine the Key ranges this user can read and write.",
revokeAssociation: "Revoke association",
noAssignedRoles: "This user has no assigned roles.",
assignRole: "Assign a role",
assignRoleHint: "Select a role that is not yet assigned to this user.",
selectRoleOption: "Select a role",
noAssignableRoles: "No roles are available to assign",
grantRole: "Assign role",
rolePermissionHint: "Permissions can cover all Keys, a prefix, or an exact Key. Prefixes are stored as etcd half-open ranges.",
grantPermission: "Grant permission",
deleteRole: "Delete role",
resource: "Resource",
permission: "Permission",
allKeys: "All Keys",
exactKey: "Exact Key",
allKeyspace: "Entire keyspace",
edit: "Edit",
revoke: "Revoke",
noPermissions: "This role has no permissions.",
createEtcdUser: "Create etcd user",
username: "Username",
password: "Password",
initialRoles: "Initial roles",
optional: "Optional",
initialRolesHint: "The selected roles will be assigned immediately after creation.",
selectedCount: "{count} selected",
noRolesForNewUser: "There are no roles yet. You can create the user first and assign roles from its details.",
createAndAssignRoles: "Create and assign {count} roles",
createUserAction: "Create user",
createEtcdRole: "Create etcd role",
roleName: "Role name",
initialPermission: "Initial permission",
initialPermissionHint: "When provided, this permission is granted as the role is created.",
keyOrPrefixPlaceholder: "Key or Prefix",
allKeysHint: "This grants access to all Keys.",
createAndGrantPermission: "Create and grant permission",
read: "Read",
write: "Write",
readWrite: "Read & write",
passwordHint: "The new password is sent only with this request and cleared from the interface after submission.",
newPassword: "New password",
continue: "Continue",
editPermission: "Edit role permission",
permissionUpdateHint: "etcd Auth has no atomic update. Saving revokes the old permission and then grants the new one. If granting fails, DBX attempts to restore the old permission.",
saveChanges: "Save changes",
dangerousTitle: "Confirm dangerous operation",
dangerousHint: "This operation directly changes etcd Auth data. Enter the confirmation text below to continue.",
confirmationPlaceholder: "Enter confirmation text",
confirmExecute: "Confirm and execute",
cancel: "Cancel",
preflightRequired: "This permission change requires a new confirmation credential. Select Save changes and confirm again.",
preflightExpired: "The confirmation has expired. Select Save changes and confirm again.",
preflightMismatch: "The confirmation content or pending permission changed. Confirm again before retrying.",
confirmationMismatch: "Confirmation texts do not match. Please try again.",
createdUser: "Created user {user}",
createdUserWithRoles: "Created user {user} and assigned {count} roles",
createdUserRolesFailed: "The user was created, but assigning the initial roles failed: {error}",
createdRole: "Created role {role}",
createdRoleWithPermission: "Created role {role} and granted its initial permission",
createdRolePermissionFailed: "The role was created, but granting its initial permission failed: {error}",
deletedUser: "Deleted user {user}",
deletedRole: "Deleted role {role}",
passwordUpdated: "Updated password for {user}",
roleGranted: "Assigned role {role} to {user}",
roleRevoked: "Revoked role {role} from {user}",
permissionGranted: "Granted role permission",
permissionUpdated: "Updated role permission",
permissionRevoked: "Revoked role permission",
},
admin: {
maintenance: "Maintenance",
watch: "Watch",
lease: "Lease",
refresh: "Refresh",
maintenanceTitle: "History and disk maintenance",
maintenanceDescription: "Clean up unused history first, then decide whether to reclaim backend disk space. These operations do not replace each other.",
currentRevision: "Current revision {revision}",
reachableMembers: "Reachable members {reachable} / {total}",
compactTitle: "Compact history",
compactDescription: "Deletes MVCC history before the specified revision. Current Key values remain, but historical reads and Watches starting earlier cannot be recovered.",
compactDiskHint: "This does not release disk space. Run Defragment after compaction to reduce backend file size.",
compactRevision: "History retention start (revision)",
compactPlaceholder: "For example {revision}",
compactRequired: "Enter the revision to retain history from.",
compactDone: "History compaction completed",
compactInputHint: "Deletes history before this revision. Confirm that these historical versions are no longer needed before continuing.",
compactAction: "Compact history",
defragTitle: "Reclaim disk space",
defragDescription: "Defrag rewrites each member backend to return reclaimable disk space to the operating system. Followers are processed serially before the current leader.",
defragWarning: "Each member is briefly unavailable while being defragmented. Members are processed one at a time with the current leader last; confirm the cluster is healthy first.",
defragReachableMembers: "Process {count} reachable members",
leaderLast: "Leader last: {endpoint}",
defragAction: "Reclaim disk space on {count} members",
defragDone: "Defragmentation completed",
newWatch: "New watch",
editWatch: "Edit watch",
watchSearch: "Filter watches",
watchKeyPlaceholder: "For example /apps/payment/config",
watchScope: "Watch scope",
prefix: "Prefix",
exact: "Exact Key",
watchKeyHint: "Receive create, update, and delete events for this Key.",
watchPrefixHint: "Receive events for every Key that starts with this prefix.",
watchSessionHint: "Watches run only in this connection session and stop when the connection is closed or the etcd page is left. A connection can run up to four watches.",
watchCount: "{count} created",
runningWatchCount: "{count} running",
watchList: "Created watches",
itemCount: "{count} items",
scope: "Scope",
status: "Status",
actions: "Actions",
createWatch: "Create and start watch",
saveWatch: "Save configuration",
running: "Running",
stopped: "Stopped",
error: "Error",
editWatchTitle: "Edit watch",
stopWatch: "Stop watch",
startWatch: "Start watch",
deleteWatch: "Delete watch",
noMatchingWatches: "No matching watches",
noWatches: "No watches yet.",
eventStream: "Event stream",
eventCount: "{count} events",
filter: "Filter",
event: "Event",
create: "Create",
update: "Value change",
delete: "Delete",
noFilteredEvents: "No events match the current filter",
waitingEvents: "Waiting for Key changes...",
watchNotRunning: "This watch is not running.",
selectWatch: "Select a watch on the left to view its event stream",
leaseDescription: "When a Lease expires, all attached Keys are deleted automatically.",
grantLease: "Grant Lease",
renew: "Renew",
revoke: "Revoke",
previousPage: "Previous",
nextPage: "Next",
leasePageSize: "Up to {count} items per page",
grantedTtl: "Granted {ttl}s",
noKnownLeases: "No known Leases in this session",
loadingLease: "Loading Lease details...",
keepalive: "Renew automatically every TTL / 3 while this page stays open",
attachedKeys: "Attached Keys",
noAttachedKeys: "No attached Keys",
attachedKeysTruncated: "Attached Key results are truncated to 256 entries.",
selectLease: "Select a Lease to view its details",
leasePartial: "The current client or etcd server cannot enumerate Leases. Only Leases created or accessed in this session are shown; details and TTL still come from the server.",
grantNewLease: "Grant new Lease",
ttl: "TTL (seconds)",
ttlPlaceholder: "For example 60",
ttlHint: "The Lease duration must be a positive integer.",
customLeaseId: "Custom Lease ID",
customLeasePlaceholder: "Leave empty or enter 0 to let etcd allocate it",
customLeaseHint: "Use a decimal integer. etcd rejects an ID that already exists.",
confirmationHint: "This cluster maintenance operation cannot be automatically undone. Review the effects before continuing.",
confirmationCredentialHint: "Enter the confirmation text below. The credential is valid only for this operation.",
confirmationPlaceholder: "Enter confirmation text",
confirmationPrompt: "This operation may affect the etcd cluster. Enter the confirmation text:\n{confirmationText}",
confirmExecute: "Confirm and execute",
cancel: "Cancel",
noReachableMembers: "No reachable members are available for defragmentation.",
defragStopped: "Defragmentation stopped: {succeeded} succeeded, {failed} failed, {notExecuted} not run",
defragCompleted: "Defragmentation completed: {succeeded}/{total} succeeded",
leaseRenewed: "Renewed Lease {id}",
leaseGrantCancelled: "Stopped waiting. etcd may still be processing the grant; refresh to confirm whether the Lease was created.",
leaseCreated: "Created Lease {id}",
},
dashboard: {
title: "etcd Dashboard",
description: "Observation based on client-reachable endpoints; this is not an authoritative quorum health check.",
@ -2945,6 +3146,9 @@ export default {
total: "Total",
rate: "Rate",
failures: "Failures",
lag: "Lag",
slowApply: "Slow apply",
putBytes: "Put bytes",
avgLatency: "Average latency",
storageDisk: "Storage and disk",
databaseState: "Database state",

View File

@ -921,6 +921,7 @@ export default withEnglishFallback({
openDataTabs: "Tablas abiertas",
gridfs: "GridFS",
etcdDashboard: "Panel de etcd",
etcdAccessControl: "Control de acceso de etcd",
},
executionSummary: {
empty: "Sin resumen",
@ -2727,6 +2728,7 @@ export default withEnglishFallback({
searchPlaceholder: "Buscar en el contenido de claves o valores",
searchProgress: "Se analizaron {scanned} claves, {matched} coincidencias",
exportResults: "Exportar resultados",
clearSearchResults: "Limpiar resultados",
exported: "Se exportaron {count} claves",
importPreview: "Vista previa de importación",
syncPreview: "Vista previa de sincronización",
@ -2836,6 +2838,208 @@ export default withEnglishFallback({
warning: "advertencia",
healthy: "saludable",
},
lag: "Retraso",
slowApply: "Aplicación lenta",
putBytes: "Bytes escritos",
},
watch: "Observar",
lease: "Arrendamiento",
key: "Key",
keyOrPrefix: "Key o prefijo",
selectExistingLease: "Seleccionar un Lease existente",
enterLeaseId: "O introducir manualmente el ID del Lease",
leasePickerHint: "Puede seleccionar entre los Leases de la sesión actual, o introducir manualmente el ID del Lease.",
noLeasePickerHint: "La sesión actual no tiene Leases seleccionables. Puede introducir un ID manualmente y guardar.",
registryWarning: "Esta Key se encuentra bajo /registry/, un espacio de nombres utilizado normalmente por componentes del plano de control. Verifique la propiedad y el impacto antes de guardar.",
access: {
users: "Usuarios",
roles: "Roles",
title: "Control de acceso",
description: "Administrar usuarios nativos de etcd, roles y permisos de Key",
readOnly: "Conexión de solo lectura",
refresh: "Actualizar",
userCount: "Usuarios ({count})",
roleCount: "Roles ({count})",
createUser: "Crear usuario",
createRole: "Crear rol",
noUsers: "No se han creado usuarios de etcd",
noRoles: "No se han creado roles de etcd",
selectUser: "Seleccione un usuario a la izquierda para ver roles y operaciones de credenciales",
selectRole: "Seleccione un rol a la izquierda para ver y gestionar permisos",
loadingUser: "Cargando detalles del usuario...",
loadingRole: "Cargando detalles del rol...",
userRolesSummary: "{count} roles asociados. Las contraseñas no se leen, muestran ni almacenan en el cliente.",
changePassword: "Cambiar contraseña",
deleteUser: "Eliminar usuario",
roleAssignments: "Asignaciones de roles",
roleAssignmentHint: "El rol determina el rango de Keys que este usuario puede leer y escribir.",
revokeAssociation: "Revocar asociación",
noAssignedRoles: "Este usuario aún no tiene roles asignados.",
assignRole: "Asignar nuevo rol",
assignRoleHint: "Seleccione un rol aún no asociado y asígnelo a este usuario.",
selectRoleOption: "Seleccionar rol",
noAssignableRoles: "No hay roles asignables",
grantRole: "Asignar rol",
rolePermissionHint: "Los permisos pueden cubrir todas las Keys, un prefijo o una Key exacta; el prefijo se guarda usando el intervalo semiabierto de etcd.",
grantPermission: "Conceder permiso",
deleteRole: "Eliminar rol",
resource: "Recurso",
permission: "Permiso",
allKeys: "Todas las Keys",
exactKey: "Key exacta",
allKeyspace: "Todo el Keyspace",
edit: "Editar",
revoke: "Revocar",
noPermissions: "Este rol aún no tiene permisos concedidos.",
createEtcdUser: "Crear usuario de etcd",
username: "Nombre de usuario",
password: "Contraseña",
initialRoles: "Roles iniciales",
optional: "Opcional",
initialRolesHint: "Tras la creación, se asociarán los roles seleccionados inmediatamente.",
selectedCount: "{count} seleccionados",
noRolesForNewUser: "Actualmente no hay roles. Puede crear primero el usuario y luego asignar roles en los detalles del usuario.",
createAndAssignRoles: "Crear y asignar {count} roles",
createUserAction: "Crear usuario",
createEtcdRole: "Crear rol de etcd",
roleName: "Nombre del rol",
initialPermission: "Permiso inicial",
initialPermissionHint: "Al completarlo, se concederá este permiso inmediatamente al crear el rol.",
keyOrPrefixPlaceholder: "Key o prefijo",
allKeysHint: "Concede acceso a todas las Keys.",
createAndGrantPermission: "Crear y conceder permiso",
read: "Lectura",
write: "Escritura",
readWrite: "Lectura y escritura",
passwordHint: "La nueva contraseña solo se enviará en esta solicitud y se borrará de la interfaz inmediatamente después de enviarla.",
newPassword: "Nueva contraseña",
continue: "Continuar",
editPermission: "Editar permiso del rol",
permissionUpdateHint: "etcd Auth no proporciona actualización atómica; al guardar, se revocará el permiso anterior y se concederá el nuevo. Si la concesión falla, el sistema intentará restaurar el permiso anterior.",
saveChanges: "Guardar cambios",
dangerousTitle: "Confirmar operación peligrosa",
dangerousHint: "Esta operación modificará directamente los datos de Auth de etcd. Debe introducir el texto de confirmación a continuación para continuar.",
confirmationPlaceholder: "Introducir texto de confirmación",
confirmExecute: "Confirmar ejecución",
cancel: "Cancelar",
preflightRequired: 'Este cambio de permiso requiere una nueva credencial de confirmación. Vuelva a hacer clic en "Guardar cambios" y complete la confirmación para reintentar.',
preflightExpired: 'La confirmación ha caducado. Vuelva a hacer clic en "Guardar cambios" y complete la confirmación.',
preflightMismatch: "El contenido de confirmación o el permiso a modificar han cambiado. Vuelva a confirmar y reintente.",
confirmationMismatch: "El texto de confirmación no coincide. Inténtelo de nuevo.",
createdUser: "Usuario {user} creado",
createdUserWithRoles: "Usuario {user} creado con {count} roles asociados",
createdUserRolesFailed: "Usuario creado, pero falló la asociación de roles iniciales: {error}",
createdRole: "Rol {role} creado",
createdRoleWithPermission: "Rol {role} creado con permiso inicial concedido",
createdRolePermissionFailed: "Rol creado, pero falló la concesión del permiso inicial: {error}",
deletedUser: "Usuario {user} eliminado",
deletedRole: "Rol {role} eliminado",
passwordUpdated: "Contraseña de {user} actualizada",
roleGranted: "Rol {role} asignado a {user}",
roleRevoked: "Rol {role} revocado de {user}",
permissionGranted: "Permiso concedido al rol",
permissionUpdated: "Permiso del rol actualizado",
permissionRevoked: "Permiso del rol revocado",
},
admin: {
maintenance: "Mantenimiento",
watch: "Observador",
lease: "Arrendamiento",
refresh: "Actualizar",
maintenanceTitle: "Historial de mantenimiento y espacio en disco",
maintenanceDescription: "Primero limpie las versiones históricas no utilizadas según sea necesario, y luego decida si recuperar el espacio en disco ocupado por el archivo backend. Estas dos operaciones no se sustituyen entre sí.",
currentRevision: "Revisión actual {revision}",
reachableMembers: "Miembros accesibles {reachable} / {total}",
compactTitle: "Limpiar versiones históricas",
compactDescription: "Elimina el historial MVCC anterior a la revisión especificada. El valor actual de la Key no se elimina, pero las lecturas de revisiones pasadas y las Watch que comienzan desde una posición anterior no se podrán recuperar.",
compactDiskHint: "No libera espacio en disco. Si necesita reducir el tamaño del archivo backend, realice la desfragmentación después de la compactación.",
compactRevision: "Punto de inicio de retención del historial (revisión)",
compactPlaceholder: "Por ejemplo, {revision}",
compactRequired: "Introduzca la revisión desde la cual conservar el historial.",
compactDone: "Limpieza de versiones históricas completada",
compactInputHint: "Elimina el historial anterior a esta revisión. Confirma que estas versiones históricas ya no son necesarias antes de continuar.",
compactAction: "Limpiar historial",
defragTitle: "Recuperar espacio en disco",
defragDescription: "La desfragmentación reescribe el archivo backend de cada miembro, devolviendo el espacio en disco recuperable al sistema operativo. El sistema procesa primero los followers en serie y luego el líder actual.",
defragWarning: "Cada miembro queda brevemente no disponible durante la desfragmentación. Se procesan uno por uno y el líder actual queda para el final; confirma primero que el clúster esté sano.",
defragReachableMembers: "Procesar {count} miembros accesibles",
leaderLast: "Líder al final: {endpoint}",
defragAction: "Recuperar espacio en {count} miembros",
defragDone: "Recuperación de espacio en disco completada",
newWatch: "Nuevo observador",
editWatch: "Editar observador",
watchSearch: "Filtrar observadores",
watchKeyPlaceholder: "Por ejemplo, /apps/payment/config",
watchScope: "Alcance del observador",
prefix: "Prefijo",
exact: "Key exacta",
watchKeyHint: "Solo recibe eventos de creación, cambio de valor y eliminación de esta Key.",
watchPrefixHint: "Recibe eventos de todas las Keys que comienzan con este prefijo.",
watchSessionHint: "El observador solo se ejecuta en la sesión de conexión actual; se detiene automáticamente al cerrar la conexión o al salir de la página de etcd. Cada conexión puede ejecutar hasta 4 observadores simultáneamente.",
watchCount: "{count} creados",
runningWatchCount: "{count} en ejecución",
watchList: "Observadores creados",
itemCount: "{count} elementos",
scope: "Ámbito",
status: "Estado",
actions: "Acciones",
createWatch: "Crear e iniciar observador",
saveWatch: "Guardar configuración",
running: "En ejecución",
stopped: "Detenido",
error: "Error",
editWatchTitle: "Editar observador",
stopWatch: "Detener observador",
startWatch: "Iniciar observador",
deleteWatch: "Eliminar observador",
noMatchingWatches: "No se encontraron observadores coincidentes",
noWatches: "Aún no hay observadores.",
eventStream: "Flujo de eventos",
eventCount: "{count} eventos",
filter: "Filtrar",
event: "Evento",
create: "Crear",
update: "Cambio de valor",
delete: "Eliminar",
noFilteredEvents: "El filtro actual no tiene eventos coincidentes",
waitingEvents: "Esperando cambios en la Key...",
watchNotRunning: "Este observador no está ejecutándose actualmente.",
selectWatch: "Seleccione un observador a la izquierda para ver el flujo de eventos",
leaseDescription: "Al expirar el arrendamiento, sus Keys adjuntas se eliminan automáticamente.",
grantLease: "Conceder arrendamiento",
renew: "Renovar",
revoke: "Revocar",
previousPage: "Anterior",
nextPage: "Siguiente",
leasePageSize: "Hasta {count} elementos por página",
grantedTtl: "Concedido {ttl}s",
noKnownLeases: "La sesión actual no tiene Leases conocidos",
loadingLease: "Cargando detalles del Lease...",
keepalive: "Renovación automática cada TTL/3 mientras la página esté abierta",
attachedKeys: "Keys adjuntas",
noAttachedKeys: "No hay Keys adjuntas",
attachedKeysTruncated: "El resultado de Keys adjuntas se ha truncado; se muestran como máximo 256 elementos.",
selectLease: "Seleccione un Lease para ver detalles",
leasePartial: "El cliente actual o el servidor etcd no admiten la enumeración de Leases; solo se muestran los Leases creados o accedidos en esta sesión; los detalles y TTL provienen del servidor.",
grantNewLease: "Conceder nuevo arrendamiento",
ttl: "TTL (segundos)",
ttlPlaceholder: "Por ejemplo, 60",
ttlHint: "La duración del arrendamiento debe ser un entero positivo.",
customLeaseId: "ID de Lease personalizado",
customLeasePlaceholder: "Dejar vacío o poner 0 para que etcd lo asigne automáticamente",
customLeaseHint: "Use un entero decimal. Si el ID ya existe, etcd rechazará la creación.",
confirmationHint: "Esta es una operación de mantenimiento del clúster que no se puede deshacer automáticamente. Confirme el siguiente impacto antes de continuar.",
confirmationCredentialHint: "Introduzca el texto de confirmación a continuación. La credencial de confirmación solo es válida para esta operación.",
confirmationPlaceholder: "Introducir texto de confirmación",
confirmationPrompt: "Esta operación puede afectar al clúster etcd. Introduce el texto de confirmación:\n{confirmationText}",
confirmExecute: "Confirmar ejecución",
cancel: "Cancelar",
noReachableMembers: "No hay miembros accesibles; no se puede ejecutar la desfragmentación por el momento.",
defragStopped: "Recuperación de espacio en disco detenida: exitosos {succeeded}, fallidos {failed}, no ejecutados {notExecuted}",
defragCompleted: "Recuperación de espacio en disco completada: exitosos {succeeded}/{total}",
leaseRenewed: "Lease {id} renovado",
leaseGrantCancelled: "Espera cancelada; etcd puede estar procesando aún la solicitud de concesión; actualice para confirmar si se ha creado el Lease.",
leaseCreated: "Lease {id} creado",
},
},
zookeeper: {

View File

@ -919,6 +919,7 @@ export default withEnglishFallback({
openDataTabs: "Tabelle aperte",
gridfs: "GridFS",
etcdDashboard: "etcd dashboard",
etcdAccessControl: "controllo accessi etcd",
},
executionSummary: {
empty: "Nessun riepilogo",
@ -2725,6 +2726,7 @@ export default withEnglishFallback({
searchPlaceholder: "Cerca nel contenuto di chiavi o valori",
searchProgress: "Analizzate {scanned} chiavi, {matched} corrispondenze",
exportResults: "Esporta risultati",
clearSearchResults: "Cancella risultati",
exported: "Esportate {count} chiavi",
importPreview: "Anteprima importazione",
syncPreview: "Anteprima sincronizzazione",
@ -2834,6 +2836,208 @@ export default withEnglishFallback({
warning: "avviso",
healthy: "sano",
},
lag: "Ritardo",
slowApply: "Applicazione lenta",
putBytes: "Byte scritti",
},
watch: "Monitoraggio",
lease: "Lease",
key: "Key",
keyOrPrefix: "Key o prefisso",
selectExistingLease: "Seleziona un Lease esistente",
enterLeaseId: "o inserisci manualmente l'ID del Lease",
leasePickerHint: "Puoi selezionare dai Lease della sessione corrente o inserire manualmente l'ID del Lease.",
noLeasePickerHint: "Nessun Lease selezionabile nella sessione corrente; inserisci manualmente l'ID e salva.",
registryWarning: "Questa Key si trova sotto /registry/, un namespace tipicamente usato dai componenti del control plane. Verifica proprietà e impatto prima di salvare.",
access: {
users: "Utenti",
roles: "Ruoli",
title: "Controllo accessi",
description: "Gestisci utenti nativi, ruoli e permessi delle Key di etcd",
readOnly: "Connessione in sola lettura",
refresh: "Aggiorna",
userCount: "Utenti ({count})",
roleCount: "Ruoli ({count})",
createUser: "Crea utente",
createRole: "Crea ruolo",
noUsers: "Nessun utente etcd creato",
noRoles: "Nessun ruolo etcd creato",
selectUser: "Seleziona un utente a sinistra per visualizzare ruoli e operazioni sulle credenziali",
selectRole: "Seleziona un ruolo a sinistra per visualizzare e gestire i permessi",
loadingUser: "Caricamento dettagli utente...",
loadingRole: "Caricamento dettagli ruolo...",
userRolesSummary: "{count} ruoli associati. La password non viene letta, mostrata o salvata sul client.",
changePassword: "Cambia password",
deleteUser: "Elimina utente",
roleAssignments: "Assegnazioni ruoli",
roleAssignmentHint: "I ruoli determinano quali Key questo utente può leggere e scrivere.",
revokeAssociation: "Revoca associazione",
noAssignedRoles: "Nessun ruolo assegnato a questo utente.",
assignRole: "Associa nuovo ruolo",
assignRoleHint: "Seleziona un ruolo non ancora associato e assegnalo a questo utente.",
selectRoleOption: "Seleziona ruolo",
noAssignableRoles: "Nessun ruolo associabile",
grantRole: "Associa ruolo",
rolePermissionHint: "I permessi possono coprire tutte le Key, un prefisso o una Key esatta; il prefisso viene salvato usando l'intervallo semi-aperto di etcd.",
grantPermission: "Concedi permesso",
deleteRole: "Elimina ruolo",
resource: "Risorsa",
permission: "Permesso",
allKeys: "Tutte le Key",
exactKey: "Key esatta",
allKeyspace: "Tutto il keyspace",
edit: "Modifica",
revoke: "Revoca",
noPermissions: "Nessun permesso concesso a questo ruolo.",
createEtcdUser: "Crea utente etcd",
username: "Nome utente",
password: "Password",
initialRoles: "Ruoli iniziali",
optional: "Opzionale",
initialRolesHint: "I ruoli selezionati verranno associati immediatamente dopo la creazione.",
selectedCount: "Selezionati {count}",
noRolesForNewUser: "Nessun ruolo attualmente. Puoi creare l'utente e poi associare i ruoli nei dettagli utente.",
createAndAssignRoles: "Crea e associa {count} ruoli",
createUserAction: "Crea utente",
createEtcdRole: "Crea ruolo etcd",
roleName: "Nome ruolo",
initialPermission: "Permesso iniziale",
initialPermissionHint: "Se compilato, il permesso verrà concesso immediatamente alla creazione del ruolo.",
keyOrPrefixPlaceholder: "Key o prefisso",
allKeysHint: "Concederà l'accesso a tutte le Key.",
createAndGrantPermission: "Crea e concedi permesso",
read: "Lettura",
write: "Scrittura",
readWrite: "Lettura e scrittura",
passwordHint: "La nuova password verrà inviata solo in questa richiesta e verrà cancellata dall'interfaccia subito dopo l'invio.",
newPassword: "Nuova password",
continue: "Continua",
editPermission: "Modifica permessi ruolo",
permissionUpdateHint: "etcd Auth non fornisce aggiornamenti atomici; al salvataggio verrà revocato il vecchio permesso e concesso il nuovo. Se la concessione fallisce, il sistema tenterà di ripristinare il vecchio permesso.",
saveChanges: "Salva modifiche",
dangerousTitle: "Conferma operazione pericolosa",
dangerousHint: "Questa operazione modificherà direttamente i dati di etcd Auth. Inserisci il testo di conferma qui sotto per procedere.",
confirmationPlaceholder: "Inserisci il testo di conferma",
confirmExecute: "Conferma esecuzione",
cancel: "Annulla",
preflightRequired: "Questa modifica dei permessi richiede nuove credenziali di conferma. Fai di nuovo clic su 'Salva modifiche' e completa la conferma, poi riprova.",
preflightExpired: "La conferma è scaduta. Fai di nuovo clic su 'Salva modifiche' e completa la conferma.",
preflightMismatch: "Il contenuto della conferma o i permessi da modificare sono cambiati. Riconferma e riprova.",
confirmationMismatch: "Il testo di conferma non corrisponde. Riprova.",
createdUser: "Utente {user} creato",
createdUserWithRoles: "Utente {user} creato con {count} ruoli associati",
createdUserRolesFailed: "Utente creato, ma associazione dei ruoli iniziali fallita: {error}",
createdRole: "Ruolo {role} creato",
createdRoleWithPermission: "Ruolo {role} creato con permesso iniziale concesso",
createdRolePermissionFailed: "Ruolo creato, ma concessione del permesso iniziale fallita: {error}",
deletedUser: "Utente {user} eliminato",
deletedRole: "Ruolo {role} eliminato",
passwordUpdated: "Password di {user} aggiornata",
roleGranted: "Ruolo {role} concesso a {user}",
roleRevoked: "Ruolo {role} revocato da {user}",
permissionGranted: "Permesso concesso al ruolo",
permissionUpdated: "Permesso del ruolo aggiornato",
permissionRevoked: "Permesso del ruolo revocato",
},
admin: {
maintenance: "Manutenzione",
watch: "Watch",
lease: "Lease",
refresh: "Aggiorna",
maintenanceTitle: "Manutenzione cronologia e spazio su disco",
maintenanceDescription: "Pulisci le versioni storiche non più utilizzate secondo necessità, poi decidi se recuperare lo spazio su disco occupato dai file di backend. Le due operazioni non si sostituiscono a vicenda.",
currentRevision: "Revision corrente {revision}",
reachableMembers: "Membri raggiungibili {reachable} / {total}",
compactTitle: "Pulisci versioni storiche",
compactDescription: "Elimina la cronologia MVCC precedente alla revision specificata. I valori correnti delle Key non vengono eliminati, ma la lettura delle revision passate e i Watch a partire da posizioni precedenti non saranno più disponibili.",
compactDiskHint: "Non libera spazio su disco. Per ridurre i file di backend, esegui la deframmentazione dopo la compressione.",
compactRevision: "Punto di partenza per la conservazione della cronologia (revision)",
compactPlaceholder: "Ad esempio {revision}",
compactRequired: "Inserisci la revision da cui mantenere la cronologia.",
compactDone: "Pulizia versioni storiche completata",
compactInputHint: "Elimina la cronologia precedente a questa revisione. Prima di continuare, verifica che queste versioni storiche non siano più necessarie.",
compactAction: "Compatta cronologia",
defragTitle: "Recupera spazio su disco",
defragDescription: "La deframmentazione riscrive i file di backend di ciascun membro, restituendo al sistema operativo lo spazio su disco recuperabile. Il sistema elabora prima i follower in sequenza, poi il leader corrente.",
defragWarning: "Ogni membro è brevemente non disponibile durante la deframmentazione. I membri vengono elaborati uno alla volta con il leader corrente per ultimo; verifica prima che il cluster sia integro.",
defragReachableMembers: "Elabora {count} membri raggiungibili",
leaderLast: "Leader per ultimo: {endpoint}",
defragAction: "Recupera spazio su {count} membri",
defragDone: "Recupero spazio su disco completato",
newWatch: "Nuovo Watch",
editWatch: "Modifica Watch",
watchSearch: "Filtra Watch",
watchKeyPlaceholder: "Ad esempio /apps/payment/config",
watchScope: "Ambito di osservazione",
prefix: "Prefisso",
exact: "Key esatta",
watchKeyHint: "Riceve solo eventi di creazione, modifica del valore ed eliminazione di questa Key.",
watchPrefixHint: "Riceve eventi di tutte le Key che iniziano con questo prefisso.",
watchSessionHint: "Il Watch viene eseguito solo nella sessione di connessione corrente; si interrompe automaticamente quando si chiude la connessione o si lascia la pagina etcd. Ogni connessione può eseguire al massimo 4 Watch contemporaneamente.",
watchCount: "{count} creati",
runningWatchCount: "{count} in esecuzione",
watchList: "Watch creati",
itemCount: "{count} elementi",
scope: "Ambito",
status: "Stato",
actions: "Azioni",
createWatch: "Crea e avvia Watch",
saveWatch: "Salva configurazione",
running: "In esecuzione",
stopped: "Fermato",
error: "Errore",
editWatchTitle: "Modifica Watch",
stopWatch: "Ferma Watch",
startWatch: "Avvia Watch",
deleteWatch: "Elimina Watch",
noMatchingWatches: "Nessun Watch corrispondente trovato",
noWatches: "Nessun Watch presente.",
eventStream: "Flusso eventi",
eventCount: "{count} eventi",
filter: "Filtro",
event: "Evento",
create: "Crea",
update: "Modifica valore",
delete: "Elimina",
noFilteredEvents: "Nessun evento corrisponde al filtro corrente",
waitingEvents: "In attesa di modifiche alle Key...",
watchNotRunning: "Questo Watch non è in esecuzione.",
selectWatch: "Seleziona un Watch a sinistra per visualizzare il flusso eventi",
leaseDescription: "Alla scadenza del Lease, le Key ad esso associate verranno automaticamente eliminate.",
grantLease: "Concedi Lease",
renew: "Rinnova",
revoke: "Revoca",
previousPage: "Precedente",
nextPage: "Successiva",
leasePageSize: "Fino a {count} elementi per pagina",
grantedTtl: "Concesso {ttl}s",
noKnownLeases: "Nessun Lease noto nella sessione corrente",
loadingLease: "Caricamento dettagli Lease...",
keepalive: "Quando la pagina rimane aperta, rinnova automaticamente ogni TTL / 3",
attachedKeys: "Key associate",
noAttachedKeys: "Nessuna Key associata",
attachedKeysTruncated: "Risultati delle Key associate troncati; vengono mostrate al massimo 256 voci.",
selectLease: "Seleziona un Lease per visualizzare i dettagli",
leasePartial: "Il client corrente o il server etcd non supporta l'enumerazione dei Lease; vengono mostrati solo i Lease creati o visitati in questa sessione; dettagli e TTL provengono dal server.",
grantNewLease: "Concedi nuovo Lease",
ttl: "TTL (secondi)",
ttlPlaceholder: "Ad esempio 60",
ttlHint: "Durata del Lease, deve essere un numero intero positivo.",
customLeaseId: "ID Lease personalizzato",
customLeasePlaceholder: "Lascia vuoto o inserisci 0 per l'assegnazione automatica da parte di etcd",
customLeaseHint: "Usa un numero intero decimale. Se l'ID esiste già, etcd rifiuterà la creazione.",
confirmationHint: "Questa è un'operazione di manutenzione del cluster che non può essere automaticamente annullata. Conferma gli impatti qui sotto per procedere.",
confirmationCredentialHint: "Inserisci il testo di conferma qui sotto. Le credenziali di conferma sono valide solo per questa operazione.",
confirmationPlaceholder: "Inserisci il testo di conferma",
confirmationPrompt: "Questa operazione può influire sul cluster etcd. Inserisci il testo di conferma:\n{confirmationText}",
confirmExecute: "Conferma esecuzione",
cancel: "Annulla",
noReachableMembers: "Nessun membro raggiungibile, impossibile eseguire la deframmentazione al momento.",
defragStopped: "Recupero spazio su disco interrotto: riusciti {succeeded}, falliti {failed}, non eseguiti {notExecuted}",
defragCompleted: "Recupero spazio su disco completato: riusciti {succeeded}/{total}",
leaseRenewed: "Lease {id} rinnovato",
leaseGrantCancelled: "Attesa annullata; etcd potrebbe ancora elaborare la richiesta di concessione. Aggiorna per verificare se il Lease è stato creato.",
leaseCreated: "Lease {id} creato",
},
},
zookeeper: {

View File

@ -920,6 +920,7 @@ export default withEnglishFallback({
vector: "ベクター",
gridfs: "GridFS",
etcdDashboard: "etcd ダッシュボード",
etcdAccessControl: "etcd アクセス制御",
},
executionSummary: {
empty: "サマリーはありません",
@ -2760,6 +2761,7 @@ export default withEnglishFallback({
searchPlaceholder: "キーまたは値の内容を検索",
searchProgress: "{scanned} 件のキーを走査、{matched} 件一致",
exportResults: "結果をエクスポート",
clearSearchResults: "検索結果をクリア",
exported: "{count} 件のキーをエクスポートしました",
importPreview: "インポートのプレビュー",
syncPreview: "同期のプレビュー",
@ -2869,6 +2871,208 @@ export default withEnglishFallback({
warning: "警告",
healthy: "正常",
},
lag: "ラグ",
slowApply: "スローアプライ",
putBytes: "書き込みバイト",
},
watch: "監視",
lease: "リース",
key: "Key",
keyOrPrefix: "Key またはプレフィックス",
selectExistingLease: "既存のリースを選択",
enterLeaseId: "または手動でリースIDを入力",
leasePickerHint: "現在のセッションのリースから選択するか、手動でリースIDを入力できます。",
noLeasePickerHint: "現在のセッションに選択可能なリースはありません。手動でIDを入力して保存できます。",
registryWarning: "このKeyは/registry/以下にあります。この名前空間は通常、コントロールプレーンコンポーネントによって使用されます。保存前に所属と影響を確認してください。",
access: {
users: "ユーザー",
roles: "ロール",
title: "アクセス制御",
description: "etcdのネイティブユーザー、ロール、Key権限を管理",
readOnly: "読み取り専用接続",
refresh: "リフレッシュ",
userCount: "ユーザー ({count})",
roleCount: "ロール ({count})",
createUser: "ユーザーを作成",
createRole: "ロールを作成",
noUsers: "etcdユーザーが作成されていません",
noRoles: "etcdロールが作成されていません",
selectUser: "左側からユーザーを選択して、ロールと資格情報の操作を表示",
selectRole: "左側からロールを選択して、権限を表示および管理",
loadingUser: "ユーザー詳細を読み込み中...",
loadingRole: "ロール詳細を読み込み中...",
userRolesSummary: "{count}個のロールが関連付けられています。パスワードはクライアントで読み取り、表示、保存されません。",
changePassword: "パスワード変更",
deleteUser: "ユーザー削除",
roleAssignments: "ロール割り当て",
roleAssignmentHint: "ロールは、このユーザーが読み取りおよび書き込みできるKeyの範囲を決定します。",
revokeAssociation: "関連付けを取り消す",
noAssignedRoles: "このユーザーにはまだロールが割り当てられていません。",
assignRole: "新しいロールを関連付ける",
assignRoleHint: "まだ関連付けられていないロールを選択し、このユーザーに付与します。",
selectRoleOption: "ロールを選択",
noAssignableRoles: "関連付け可能なロールがありません",
grantRole: "ロールを関連付ける",
rolePermissionHint: "権限はすべてのKey、プレフィックス、正確なKeyをカバーできます。プレフィックスはetcdの半開区間を使用して保存されます。",
grantPermission: "権限を付与",
deleteRole: "ロールを削除",
resource: "リソース",
permission: "権限",
allKeys: "すべてのKey",
exactKey: "正確なKey",
allKeyspace: "すべてのKeyspace",
edit: "編集",
revoke: "取り消す",
noPermissions: "このロールにはまだ権限が付与されていません。",
createEtcdUser: "etcdユーザーを作成",
username: "ユーザー名",
password: "パスワード",
initialRoles: "初期ロール",
optional: "オプション",
initialRolesHint: "作成後すぐに選択したロールが関連付けられます。",
selectedCount: "選択済み {count}",
noRolesForNewUser: "現在ロールがありません。ユーザーを作成してから、ユーザー詳細でロールを関連付けることができます。",
createAndAssignRoles: "{count}個のロールを作成して関連付ける",
createUserAction: "ユーザーを作成",
createEtcdRole: "etcdロールを作成",
roleName: "ロール名",
initialPermission: "初期権限",
initialPermissionHint: "入力後、ロール作成時に即座にその権限が付与されます。",
keyOrPrefixPlaceholder: "Key またはプレフィックス",
allKeysHint: "すべてのKeyへのアクセスを許可します。",
createAndGrantPermission: "作成して権限を付与",
read: "読み取り",
write: "書き込み",
readWrite: "読み取りと書き込み",
passwordHint: "新しいパスワードはこのリクエストでのみ送信され、送信後すぐに画面から消去されます。",
newPassword: "新しいパスワード",
continue: "続行",
editPermission: "ロール権限を編集",
permissionUpdateHint: "etcd Authはアトミック更新を提供しません。保存時に古い権限を取り消してから新しい権限を付与します。付与に失敗した場合、システムは古い権限を復元しようとします。",
saveChanges: "変更を保存",
dangerousTitle: "危険な操作の確認",
dangerousHint: "この操作はetcd Authデータを直接変更します。続行するには、以下の確認テキストを入力してください。",
confirmationPlaceholder: "確認テキストを入力",
confirmExecute: "実行を確認",
cancel: "キャンセル",
preflightRequired: "この権限変更には新しい確認資格情報が必要です。もう一度「変更を保存」をクリックして確認を完了してから再試行してください。",
preflightExpired: "確認が期限切れになりました。もう一度「変更を保存」をクリックして確認を完了してください。",
preflightMismatch: "確認内容または変更予定の権限が変更されました。再確認してから再試行してください。",
confirmationMismatch: "確認テキストが一致しません。再試行してください。",
createdUser: "ユーザー {user} を作成しました",
createdUserWithRoles: "ユーザー {user} を作成し、{count} 個のロールを関連付けました",
createdUserRolesFailed: "ユーザーは作成されましたが、初期ロールの関連付けに失敗しました:{error}",
createdRole: "ロール {role} を作成しました",
createdRoleWithPermission: "ロール {role} を作成し、初期権限を付与しました",
createdRolePermissionFailed: "ロールは作成されましたが、初期権限の付与に失敗しました:{error}",
deletedUser: "ユーザー {user} を削除しました",
deletedRole: "ロール {role} を削除しました",
passwordUpdated: "{user} のパスワードを更新しました",
roleGranted: "ロール {role} を {user} に付与しました",
roleRevoked: "{user} のロール {role} を取り消しました",
permissionGranted: "ロールに権限を付与しました",
permissionUpdated: "ロール権限を更新しました",
permissionRevoked: "ロール権限を取り消しました",
},
admin: {
maintenance: "メンテナンス",
watch: "ウォッチャー",
lease: "リース",
refresh: "リフレッシュ",
maintenanceTitle: "履歴とディスク領域のメンテナンス",
maintenanceDescription: "まず不要になった履歴バージョンを必要に応じてクリーンアップし、その後バックエンドファイルが占有するディスク領域を回収するかどうかを決定します。これらの操作は互いに代替しません。",
currentRevision: "現在のrevision {revision}",
reachableMembers: "到達可能メンバー {reachable} / {total}",
compactTitle: "履歴バージョンのクリーンアップ",
compactDescription: "指定されたrevisionより前のMVCC履歴を削除します。現在のKey値は削除されませんが、過去のrevisionの読み取りや、それより前の位置からのWatchは復元できません。",
compactDiskHint: "ディスク領域は解放されません。バックエンドファイルを縮小する必要がある場合は、圧縮完了後にデフラグを実行してください。",
compactRevision: "履歴保持開始点revision",
compactPlaceholder: "例: {revision}",
compactRequired: "履歴を保持するrevisionを入力してください。",
compactDone: "履歴バージョンのクリーンアップが完了しました",
compactInputHint: "このリビジョンより前の履歴を削除します。続行する前に、これらの履歴バージョンが不要であることを確認してください。",
compactAction: "履歴をコンパクション",
defragTitle: "ディスク領域の回収",
defragDescription: "デフラグは各メンバーのバックエンドファイルを書き換え、回収可能なディスク領域をOSに返却します。システムは最初にフォロワーをシリアルに処理し、次に現在のリーダーを処理します。",
defragWarning: "デフラグ中は各メンバーが一時的に利用できなくなります。現在のリーダーを最後にして1台ずつ処理するため、先にクラスターが正常であることを確認してください。",
defragReachableMembers: "到達可能なメンバー {count} 台を処理",
leaderLast: "リーダーは最後: {endpoint}",
defragAction: "{count} 台のディスク領域を回収",
defragDone: "ディスク領域の回収が完了しました",
newWatch: "新しいウォッチャーを作成",
editWatch: "ウォッチャーを編集",
watchSearch: "ウォッチャーをフィルタ",
watchKeyPlaceholder: "例: /apps/payment/config",
watchScope: "監視範囲",
prefix: "プレフィックス",
exact: "正確なKey",
watchKeyHint: "このKeyの作成、値変更、削除イベントのみを受信します。",
watchPrefixHint: "このプレフィックスで始まるすべてのKeyのイベントを受信します。",
watchSessionHint: "ウォッチャーは現在の接続セッションでのみ実行され、接続を閉じるかetcdページを離れると自動的に停止します。各接続で最大4つのウォッチャーを同時に実行できます。",
watchCount: "作成済み {count} 件",
runningWatchCount: "実行中 {count} 件",
watchList: "作成済みウォッチャー",
itemCount: "{count} 件",
scope: "範囲",
status: "状態",
actions: "操作",
createWatch: "作成して監視を開始",
saveWatch: "設定を保存",
running: "実行中",
stopped: "停止中",
error: "エラー",
editWatchTitle: "ウォッチャーの編集",
stopWatch: "ウォッチャーを停止",
startWatch: "ウォッチャーを開始",
deleteWatch: "ウォッチャーを削除",
noMatchingWatches: "一致するウォッチャーが見つかりません",
noWatches: "まだウォッチャーがありません。",
eventStream: "イベントストリーム",
eventCount: "{count} 件のイベント",
filter: "フィルター",
event: "イベント",
create: "作成",
update: "値変更",
delete: "削除",
noFilteredEvents: "現在のフィルターに一致するイベントはありません",
waitingEvents: "Keyの変更を待機中...",
watchNotRunning: "このウォッチャーは現在実行されていません。",
selectWatch: "左側からウォッチャーを選択してイベントストリームを表示",
leaseDescription: "リースの有効期限が切れると、関連付けられたKeyは自動的に削除されます。",
grantLease: "リースを付与",
renew: "更新",
revoke: "取り消し",
previousPage: "前へ",
nextPage: "次へ",
leasePageSize: "1ページ最大 {count} 件",
grantedTtl: "付与時 {ttl}s",
noKnownLeases: "現在のセッションに既知のリースはありません",
loadingLease: "リース詳細を読み込み中...",
keepalive: "ページが開いている間、TTL / 3 で自動更新",
attachedKeys: "関連付けられたKey",
noAttachedKeys: "関連付けられたKeyはありません",
attachedKeysTruncated: "関連付けられたKeyの結果は切り捨てられ、最大256項目まで表示されます。",
selectLease: "リースを選択して詳細を表示",
leasePartial: "現在のクライアントまたはetcdサーバーがリースの列挙をサポートしていないため、このセッションで作成またはアクセスされたリースのみ表示されます。詳細とTTLはサーバーから取得されます。",
grantNewLease: "新しいリースを付与",
ttl: "TTL",
ttlPlaceholder: "例: 60",
ttlHint: "リースの有効期限です。正の整数である必要があります。",
customLeaseId: "カスタムリースID",
customLeasePlaceholder: "空欄または0にするとetcdが自動割り当て",
customLeaseHint: "10進数の整数を使用します。このIDが既に存在する場合、etcdは作成を拒否します。",
confirmationHint: "これは自動的に取り消せないクラスターメンテナンス操作です。以下の影響を確認して続行してください。",
confirmationCredentialHint: "以下の確認テキストを入力してください。確認資格情報はこの操作に対してのみ有効です。",
confirmationPlaceholder: "確認テキストを入力",
confirmationPrompt: "この操作はetcdクラスターに影響する可能性があります。確認テキストを入力してください:\n{confirmationText}",
confirmExecute: "実行を確認",
cancel: "キャンセル",
noReachableMembers: "到達可能なメンバーがないため、デフラグを実行できません。",
defragStopped: "ディスク領域の回収が停止しました:成功 {succeeded}、失敗 {failed}、未実行 {notExecuted}",
defragCompleted: "ディスク領域の回収が完了しました:成功 {succeeded}/{total}",
leaseRenewed: "リース {id} を更新しました",
leaseGrantCancelled: "待機をキャンセルしました。etcdがまだこの付与リクエストを処理している可能性があります。リースが作成されたかどうかリフレッシュして確認してください。",
leaseCreated: "リース {id} を作成しました",
},
},
redis: {

View File

@ -921,6 +921,7 @@ export default withEnglishFallback({
openDataTabs: "Tabelas abertas",
gridfs: "GridFS",
etcdDashboard: "painel etcd",
etcdAccessControl: "controle de acesso do etcd",
},
executionSummary: {
empty: "Nenhum resumo",
@ -2727,6 +2728,7 @@ export default withEnglishFallback({
searchPlaceholder: "Buscar no conteúdo da chave ou do valor",
searchProgress: "{scanned} chaves analisadas, {matched} correspondências",
exportResults: "Exportar resultados",
clearSearchResults: "Limpar resultados",
exported: "{count} chaves exportadas",
importPreview: "Prévia da importação",
syncPreview: "Prévia da sincronização",
@ -2836,6 +2838,208 @@ export default withEnglishFallback({
warning: "aviso",
healthy: "saudável",
},
lag: "Atraso",
slowApply: "Aplicação lenta",
putBytes: "Bytes gravados",
},
watch: "Monitor",
lease: "Lease",
key: "Chave",
keyOrPrefix: "Chave ou Prefixo",
selectExistingLease: "Selecionar Lease Existente",
enterLeaseId: "ou digitar o ID do Lease manualmente",
leasePickerHint: "Pode selecionar de Leases da sessão atual ou digitar o ID do Lease manualmente.",
noLeasePickerHint: "A sessão atual não possui Leases selecionáveis. Pode digitar o ID manualmente e salvar.",
registryWarning: "Esta Chave está em /registry/, namespace normalmente usado por componentes do plano de controle. Verifique a propriedade e o impacto antes de salvar.",
access: {
users: "Usuários",
roles: "Funções",
title: "Controle de Acesso",
description: "Gerenciar usuários nativos, funções e permissões de Chave do etcd",
readOnly: "Conexão somente leitura",
refresh: "Atualizar",
userCount: "Usuários ({count})",
roleCount: "Funções ({count})",
createUser: "Criar usuário",
createRole: "Criar função",
noUsers: "Nenhum usuário etcd criado",
noRoles: "Nenhuma função etcd criada",
selectUser: "Selecione um usuário à esquerda para ver funções e ações de credenciais",
selectRole: "Selecione uma função à esquerda para ver e gerenciar permissões",
loadingUser: "Carregando detalhes do usuário...",
loadingRole: "Carregando detalhes da função...",
userRolesSummary: "Associado a {count} funções. Senha não é lida, exibida ou salva no cliente.",
changePassword: "Alterar senha",
deleteUser: "Excluir usuário",
roleAssignments: "Atribuições de função",
roleAssignmentHint: "As funções determinam quais Chaves este usuário pode ler e escrever.",
revokeAssociation: "Revogar associação",
noAssignedRoles: "Este usuário ainda não possui funções atribuídas.",
assignRole: "Associar nova função",
assignRoleHint: "Selecione uma função ainda não associada e conceda a este usuário.",
selectRoleOption: "Selecionar função",
noAssignableRoles: "Nenhuma função disponível para associar",
grantRole: "Conceder função",
rolePermissionHint: "Permissões podem abranger todas as Chaves, Prefixo ou Chave exata; Prefixo é salvo com intervalo semiaberto do etcd.",
grantPermission: "Conceder permissão",
deleteRole: "Excluir função",
resource: "Recurso",
permission: "Permissão",
allKeys: "Todas as Chaves",
exactKey: "Chave exata",
allKeyspace: "Todo o Keyspace",
edit: "Editar",
revoke: "Revogar",
noPermissions: "Esta função ainda não possui permissões concedidas.",
createEtcdUser: "Criar usuário etcd",
username: "Nome de usuário",
password: "Senha",
initialRoles: "Funções iniciais",
optional: "Opcional",
initialRolesHint: "Após a criação, as funções selecionadas serão associadas imediatamente.",
selectedCount: "Selecionado {count}",
noRolesForNewUser: "Não há funções atualmente. Crie o usuário primeiro e depois associe funções nos detalhes do usuário.",
createAndAssignRoles: "Criar e associar {count} funções",
createUserAction: "Criar usuário",
createEtcdRole: "Criar função etcd",
roleName: "Nome da função",
initialPermission: "Permissão inicial",
initialPermissionHint: "Se preenchido, esta permissão será concedida imediatamente ao criar a função.",
keyOrPrefixPlaceholder: "Chave ou Prefixo",
allKeysHint: "Concederá acesso a todas as Chaves.",
createAndGrantPermission: "Criar e conceder permissão",
read: "Leitura",
write: "Escrita",
readWrite: "Leitura e escrita",
passwordHint: "A nova senha será enviada apenas nesta requisição e será limpa da interface imediatamente após o envio.",
newPassword: "Nova senha",
continue: "Continuar",
editPermission: "Editar permissões da função",
permissionUpdateHint: "etcd Auth não fornece atualização atômica; ao salvar, as permissões antigas são revogadas primeiro, depois as novas são concedidas. Se a concessão falhar, o sistema tentará restaurar as antigas.",
saveChanges: "Salvar alterações",
dangerousTitle: "Confirmar operação perigosa",
dangerousHint: "Esta operação modificará dados de autenticação do etcd diretamente. Digite o texto de confirmação abaixo para continuar.",
confirmationPlaceholder: "Digite o texto de confirmação",
confirmExecute: "Confirmar e executar",
cancel: "Cancelar",
preflightRequired: "Esta alteração de permissão requer nova credencial de confirmação. Clique novamente em 'Salvar alterações' e complete a confirmação.",
preflightExpired: "A confirmação expirou. Clique novamente em 'Salvar alterações' e complete a confirmação.",
preflightMismatch: "O conteúdo da confirmação ou as permissões a serem alteradas mudaram. Reconfirme e tente novamente.",
confirmationMismatch: "Texto de confirmação inconsistente. Tente novamente.",
createdUser: "Usuário {user} criado",
createdUserWithRoles: "Usuário {user} criado com {count} funções associadas",
createdUserRolesFailed: "Usuário criado, mas associação de funções iniciais falhou: {error}",
createdRole: "Função {role} criada",
createdRoleWithPermission: "Função {role} criada com permissão inicial concedida",
createdRolePermissionFailed: "Função criada, mas concessão de permissão inicial falhou: {error}",
deletedUser: "Usuário {user} excluído",
deletedRole: "Função {role} excluída",
passwordUpdated: "Senha de {user} atualizada",
roleGranted: "Função {role} concedida a {user}",
roleRevoked: "Função {role} revogada de {user}",
permissionGranted: "Permissão concedida à função",
permissionUpdated: "Permissão da função atualizada",
permissionRevoked: "Permissão da função revogada",
},
admin: {
maintenance: "Manutenção",
watch: "Monitor",
lease: "Lease",
refresh: "Atualizar",
maintenanceTitle: "Manutenção de histórico e espaço em disco",
maintenanceDescription: "Primeiro, limpe as versões históricas não utilizadas conforme necessário, depois decida se recupera o espaço em disco ocupado pelos arquivos de backend. Essas duas operações não são substitutas.",
currentRevision: "Revisão atual {revision}",
reachableMembers: "Membros alcançáveis {reachable} / {total}",
compactTitle: "Limpar versões históricas",
compactDescription: "Exclui o histórico MVCC anterior à revisão especificada. Os valores atuais da Chave não são excluídos, mas leituras de revisões passadas e Watches iniciados de posições anteriores não serão recuperáveis.",
compactDiskHint: "Não libera espaço em disco. Se precisar reduzir o arquivo de backend, execute a desfragmentação após a compactação.",
compactRevision: "Ponto inicial de retenção do histórico (revisão)",
compactPlaceholder: "Exemplo: {revision}",
compactRequired: "Digite a revisão a partir da qual manter o histórico.",
compactDone: "Limpeza de versões históricas concluída",
compactInputHint: "Exclui o histórico anterior a esta revisão. Confirme que essas versões históricas não são mais necessárias antes de continuar.",
compactAction: "Compactar histórico",
defragTitle: "Recuperar espaço em disco",
defragDescription: "A desfragmentação reescreve os arquivos de backend de cada membro, devolvendo o espaço em disco recuperável ao sistema operacional. O sistema processa os followers em série primeiro, depois o leader atual.",
defragWarning: "Cada membro fica brevemente indisponível durante a desfragmentação. Os membros são processados um por vez, com o leader atual por último; confirme primeiro que o cluster está saudável.",
defragReachableMembers: "Processar {count} membros acessíveis",
leaderLast: "Leader por último: {endpoint}",
defragAction: "Recuperar espaço em {count} membros",
defragDone: "Recuperação de espaço em disco concluída",
newWatch: "Novo monitor",
editWatch: "Editar monitor",
watchSearch: "Filtrar monitores",
watchKeyPlaceholder: "Exemplo: /apps/payment/config",
watchScope: "Escopo do monitor",
prefix: "Prefixo",
exact: "Chave exata",
watchKeyHint: "Recebe apenas eventos de criação, alteração de valor e exclusão desta Chave.",
watchPrefixHint: "Recebe eventos de todas as Chaves que começam com este prefixo.",
watchSessionHint: "O monitor é executado apenas na sessão de conexão atual; ele parará automaticamente ao fechar a conexão ou sair da página do etcd. Cada conexão pode executar no máximo 4 monitores simultaneamente.",
watchCount: "{count} criados",
runningWatchCount: "{count} em execução",
watchList: "Monitores criados",
itemCount: "{count} itens",
scope: "Escopo",
status: "Status",
actions: "Ações",
createWatch: "Criar e iniciar monitor",
saveWatch: "Salvar configuração",
running: "Em execução",
stopped: "Parado",
error: "Erro",
editWatchTitle: "Editar monitor",
stopWatch: "Parar monitor",
startWatch: "Iniciar monitor",
deleteWatch: "Excluir monitor",
noMatchingWatches: "Nenhum monitor correspondente encontrado",
noWatches: "Ainda não há monitores.",
eventStream: "Fluxo de eventos",
eventCount: "{count} eventos",
filter: "Filtrar",
event: "Evento",
create: "Criar",
update: "Alteração de valor",
delete: "Excluir",
noFilteredEvents: "Nenhum evento corresponde ao filtro atual",
waitingEvents: "Aguardando alterações na Chave...",
watchNotRunning: "Este monitor não está em execução no momento.",
selectWatch: "Selecione um monitor à esquerda para ver o fluxo de eventos",
leaseDescription: "Após a expiração do Lease, suas Chaves anexadas são excluídas automaticamente.",
grantLease: "Conceder Lease",
renew: "Renovar",
revoke: "Revogar",
previousPage: "Anterior",
nextPage: "Próxima",
leasePageSize: "Até {count} itens por página",
grantedTtl: "Concedido {ttl}s",
noKnownLeases: "Nenhum Lease conhecido na sessão atual",
loadingLease: "Carregando detalhes do Lease...",
keepalive: "Com a página aberta, renovação automática a cada TTL / 3",
attachedKeys: "Chaves anexadas",
noAttachedKeys: "Nenhuma Chave anexada",
attachedKeysTruncated: "Resultados de Chaves anexadas truncados, máximo de 256 itens exibidos.",
selectLease: "Selecione um Lease para ver detalhes",
leasePartial: "O cliente ou servidor etcd atual não suporta enumeração de Leases; apenas Leases criados ou acessados nesta sessão são mostrados; detalhes e TTL vêm do servidor.",
grantNewLease: "Conceder novo Lease",
ttl: "TTL (segundos)",
ttlPlaceholder: "Exemplo: 60",
ttlHint: "Tempo de vida do Lease, deve ser um inteiro positivo.",
customLeaseId: "ID de Lease personalizado",
customLeasePlaceholder: "Deixe em branco ou 0 para atribuição automática pelo etcd",
customLeaseHint: "Use um número inteiro decimal. etcd rejeitará a criação se o ID já existir.",
confirmationHint: "Esta é uma operação de manutenção de cluster que não pode ser desfeita automaticamente. Confirme o impacto abaixo para continuar.",
confirmationCredentialHint: "Digite o texto de confirmação abaixo. A credencial de confirmação é válida apenas para esta operação.",
confirmationPlaceholder: "Digite o texto de confirmação",
confirmationPrompt: "Esta operação pode afetar o cluster etcd. Digite o texto de confirmação:\n{confirmationText}",
confirmExecute: "Confirmar e executar",
cancel: "Cancelar",
noReachableMembers: "Nenhum membro alcançável, não é possível executar a desfragmentação no momento.",
defragStopped: "Recuperação de espaço em disco interrompida: sucesso {succeeded}, falha {failed}, não executado {notExecuted}",
defragCompleted: "Recuperação de espaço em disco concluída: sucesso {succeeded}/{total}",
leaseRenewed: "Lease {id} renovado",
leaseGrantCancelled: "Aguardando cancelado; etcd pode ainda estar processando a solicitação de concessão. Atualize para confirmar se o Lease foi criado.",
leaseCreated: "Lease {id} criado",
},
},
zookeeper: {

View File

@ -898,6 +898,7 @@ export default withEnglishFallback({
redis: "Redis",
etcd: "etcd",
etcdDashboard: "etcd 大盘",
etcdAccessControl: "etcd 访问控制",
zookeeper: "ZooKeeper",
mongo: "Mongo",
gridfs: "GridFS",
@ -2875,6 +2876,7 @@ export default withEnglishFallback({
searchPlaceholder: "搜索 Key 或 Value 内容",
searchProgress: "已扫描 {scanned} 个 Key命中 {matched} 个",
exportResults: "导出结果",
clearSearchResults: "清空结果",
exported: "已导出 {count} 个 Key",
importPreview: "导入预览",
syncPreview: "同步预览",
@ -2892,6 +2894,205 @@ export default withEnglishFallback({
transferPartiallyApplied: "批量任务停止前已成功应用 {count} 项:{error}。预览已刷新,请仅重试剩余选中项。",
transferPartialRefreshFailed: "批量任务停止前已成功应用 {count} 项:{error}。重新生成预览也失败:{previewError}。请关闭并重新打开预览后再重试。",
targetChangedDuringTransfer: "批量任务执行期间目标连接发生了变化",
watch: "监视",
lease: "租约",
key: "Key",
keyOrPrefix: "Key 或前缀",
selectExistingLease: "选择已有 Lease",
enterLeaseId: "或手动输入 Lease ID",
leasePickerHint: "可从当前会话租约中选择,或手动输入 Lease ID。",
noLeasePickerHint: "当前会话没有可选择的 Lease可手动输入 ID 后保存。",
registryWarning: "该 Key 位于 /registry/ 下,这个命名空间通常由控制平面组件使用。保存前请核实归属和影响。",
access: {
users: "用户",
roles: "角色",
title: "访问控制",
description: "管理 etcd 原生用户、角色及 Key 权限",
readOnly: "只读连接",
refresh: "刷新",
userCount: "用户 ({count})",
roleCount: "角色 ({count})",
createUser: "创建用户",
createRole: "创建角色",
noUsers: "未创建 etcd 用户",
noRoles: "未创建 etcd 角色",
selectUser: "从左侧选择用户以查看角色和凭据操作",
selectRole: "从左侧选择角色以查看和管理权限",
loadingUser: "加载用户详情...",
loadingRole: "加载角色详情...",
userRolesSummary: "已关联 {count} 个角色。密码不会被读取、显示或保存在客户端。",
changePassword: "修改密码",
deleteUser: "删除用户",
roleAssignments: "角色分配",
roleAssignmentHint: "角色决定该用户可读取和写入的 Key 范围。",
revokeAssociation: "撤销关联",
noAssignedRoles: "该用户尚未分配角色。",
assignRole: "关联新角色",
assignRoleHint: "选择一个尚未关联的角色并授予给此用户。",
selectRoleOption: "选择角色",
noAssignableRoles: "没有可关联的角色",
grantRole: "关联角色",
rolePermissionHint: "权限可覆盖所有 Key、Prefix 或精确 KeyPrefix 使用 etcd 半开区间保存。",
grantPermission: "授予权限",
deleteRole: "删除角色",
resource: "资源",
permission: "权限",
allKeys: "所有 Key",
exactKey: "精确 Key",
allKeyspace: "全部 Keyspace",
edit: "编辑",
revoke: "撤销",
noPermissions: "该角色尚未授予权限。",
createEtcdUser: "创建 etcd 用户",
username: "用户名",
password: "密码",
initialRoles: "初始角色",
optional: "可选",
initialRolesHint: "创建后会立即关联所选角色。",
selectedCount: "已选 {count}",
noRolesForNewUser: "当前没有角色。可以先创建用户,再在用户详情中关联角色。",
createAndAssignRoles: "创建并关联 {count} 个角色",
createUserAction: "创建用户",
createEtcdRole: "创建 etcd 角色",
roleName: "角色名",
initialPermission: "初始权限",
initialPermissionHint: "填写后会在创建角色时立即授予该权限。",
keyOrPrefixPlaceholder: "Key 或 Prefix",
allKeysHint: "将授权访问全部 Key。",
createAndGrantPermission: "创建并授予权限",
read: "读取",
write: "写入",
readWrite: "读取和写入",
passwordHint: "新密码只会在本次请求中发送,提交后立即从界面清除。",
newPassword: "新密码",
continue: "继续",
editPermission: "编辑角色权限",
permissionUpdateHint: "etcd Auth 不提供原子更新;保存时会先撤销旧权限,再授予新权限。若授予失败,系统会尝试恢复旧权限。",
saveChanges: "保存修改",
dangerousTitle: "确认危险操作",
dangerousHint: "该操作会直接修改 etcd Auth 数据。请输入下方确认文本后才能继续。",
confirmationPlaceholder: "输入确认文本",
confirmExecute: "确认执行",
cancel: "取消",
preflightRequired: "该权限变更需要新的确认凭据。请重新点击“保存修改”并完成确认后重试。",
preflightExpired: "确认已过期。请重新点击“保存修改”并完成确认。",
preflightMismatch: "确认内容或待修改的权限已变化。请重新确认后重试。",
confirmationMismatch: "确认文本不一致。请重试。",
createdUser: "已创建用户 {user}",
createdUserWithRoles: "已创建用户 {user} 并关联 {count} 个角色",
createdUserRolesFailed: "用户已创建,但初始角色关联失败:{error}",
createdRole: "已创建角色 {role}",
createdRoleWithPermission: "已创建角色 {role} 并授予初始权限",
createdRolePermissionFailed: "角色已创建,但初始权限授予失败:{error}",
deletedUser: "已删除用户 {user}",
deletedRole: "已删除角色 {role}",
passwordUpdated: "已更新 {user} 的密码",
roleGranted: "已将角色 {role} 授予 {user}",
roleRevoked: "已撤销 {user} 的角色 {role}",
permissionGranted: "已授予角色权限",
permissionUpdated: "已更新角色权限",
permissionRevoked: "已撤销角色权限",
},
admin: {
maintenance: "维护",
watch: "监视器",
lease: "租约",
refresh: "刷新",
maintenanceTitle: "维护历史与磁盘空间",
maintenanceDescription: "先按需清理不再使用的历史版本,再决定是否回收后端文件占用的磁盘空间。这两项操作互不替代。",
currentRevision: "当前 revision {revision}",
reachableMembers: "可达成员 {reachable} / {total}",
compactTitle: "清理历史版本",
compactDescription: "删除指定 revision 之前的 MVCC 历史。当前 Key 值不会被删除,但过去 revision 的读取和从较早位置开始的 Watch 将无法恢复。",
compactDiskHint: "不会释放磁盘空间。若需要缩小 backend 文件,请在压缩完成后执行碎片整理。",
compactRevision: "历史保留起点revision",
compactPlaceholder: "例如 {revision}",
compactRequired: "请输入要保留历史的 revision。",
compactDone: "历史版本清理完成",
compactInputHint: "将删除小于该值的历史记录。执行前请确认不再需要这些历史版本。",
compactAction: "清理历史版本",
defragTitle: "回收磁盘空间",
defragDescription: "碎片整理会重写各成员的后端文件,将可回收的磁盘空间归还给操作系统。系统会先串行处理 follower再处理当前 leader。",
defragWarning: "每个成员整理期间会短暂不可用。系统会逐个执行,并将当前 leader 放在最后;请先确认集群健康。",
defragReachableMembers: "将处理 {count} 个可达成员",
leaderLast: "Leader 最后:{endpoint}",
defragAction: "回收 {count} 个成员的磁盘空间",
defragDone: "磁盘空间回收完成",
newWatch: "新建监视器",
editWatch: "编辑监视器",
watchSearch: "筛选监视器",
watchKeyPlaceholder: "例如 /apps/payment/config",
watchScope: "监视范围",
prefix: "前缀",
exact: "精确 Key",
watchKeyHint: "只接收这个 Key 的创建、值变更和删除事件。",
watchPrefixHint: "接收以该前缀开头的所有 Key 事件。",
watchSessionHint: "监视器只在当前连接会话中运行,关闭连接或离开 etcd 页面后会自动停止。每个连接最多同时运行 4 个监视器。",
watchCount: "{count} 个已创建",
runningWatchCount: "{count} 个运行中",
watchList: "已创建的监视器",
itemCount: "{count} 项",
scope: "范围",
status: "状态",
actions: "操作",
createWatch: "创建并开始监视",
saveWatch: "保存配置",
running: "运行中",
stopped: "已停止",
error: "异常",
editWatchTitle: "编辑监视器",
stopWatch: "停止监视器",
startWatch: "启动监视器",
deleteWatch: "删除监视器",
noMatchingWatches: "未找到匹配的监视器",
noWatches: "还没有监视器。",
eventStream: "事件流",
eventCount: "{count} 条事件",
filter: "筛选",
event: "事件",
create: "创建",
update: "值变更",
delete: "删除",
noFilteredEvents: "当前筛选没有匹配事件",
waitingEvents: "正在等待 Key 变更...",
watchNotRunning: "该监视器当前没有运行。",
selectWatch: "从左侧选择一个监视器查看事件流",
leaseDescription: "租约到期后,其附着 Key 会自动删除。",
grantLease: "授予租约",
renew: "续期",
revoke: "撤销",
previousPage: "上一页",
nextPage: "下一页",
leasePageSize: "每页最多 {count} 项",
grantedTtl: "授予 {ttl}s",
noKnownLeases: "当前会话没有已知 Lease",
loadingLease: "加载 Lease 详情...",
keepalive: "页面保持打开时按 TTL / 3 自动续期",
attachedKeys: "附着 Key",
noAttachedKeys: "没有附着 Key",
attachedKeysTruncated: "附着 Key 结果已截断,最多显示 256 项。",
selectLease: "选择 Lease 查看详情",
leasePartial: "当前客户端或 etcd 服务端不支持 Lease 枚举,暂时只显示本会话创建或访问过的 Lease详情与 TTL 均来自服务端。",
grantNewLease: "授予新租约",
ttl: "TTL",
ttlPlaceholder: "例如 60",
ttlHint: "租约的有效期,必须是正整数。",
customLeaseId: "自定义 Lease ID",
customLeasePlaceholder: "留空或填 0 由 etcd 自动分配",
customLeaseHint: "使用十进制整数。该 ID 已存在时 etcd 会拒绝创建。",
confirmationHint: "这是一次不可自动撤销的集群维护操作。请确认以下影响后继续。",
confirmationCredentialHint: "请输入下方确认文本。确认凭据仅对本次操作有效。",
confirmationPlaceholder: "输入确认文本",
confirmationPrompt: "此操作可能影响 etcd 集群。请输入确认文本:\n{confirmationText}",
confirmExecute: "确认执行",
cancel: "取消",
noReachableMembers: "没有可达成员,暂时不能执行碎片整理。",
defragStopped: "磁盘空间回收已停止:成功 {succeeded},失败 {failed},未执行 {notExecuted}",
defragCompleted: "磁盘空间回收完成:成功 {succeeded}/{total}",
leaseRenewed: "Lease {id} 已续期",
leaseGrantCancelled: "已取消等待etcd 可能仍在处理该授权请求,请刷新确认是否已创建 Lease。",
leaseCreated: "已创建 Lease {id}",
},
dashboard: {
title: "etcd 概览",
description: "基于当前客户端可访问端点的观测结果,不等同于权威的集群 Quorum 健康检查。",
@ -2945,6 +3146,9 @@ export default withEnglishFallback({
total: "累计",
rate: "速率",
failures: "失败",
lag: "滞后",
slowApply: "慢应用",
putBytes: "写入字节",
avgLatency: "平均延迟",
storageDisk: "存储与磁盘",
databaseState: "数据库状态",

View File

@ -920,6 +920,7 @@ export default withEnglishFallback({
openDataTabs: "已開啟的資料表",
gridfs: "GridFS",
etcdDashboard: "etcd 儀表板",
etcdAccessControl: "etcd 存取控制",
},
executionSummary: {
empty: "無摘要",
@ -4997,6 +4998,7 @@ export default withEnglishFallback({
searchPlaceholder: "搜尋 Key 或值內容",
searchProgress: "已掃描 {scanned} 個 Key符合 {matched} 個",
exportResults: "匯出結果",
clearSearchResults: "清空結果",
exported: "已匯出 {count} 個 Key",
importPreview: "匯入預覽",
syncPreview: "同步預覽",
@ -5106,6 +5108,208 @@ export default withEnglishFallback({
warning: "警告",
healthy: "健康",
},
lag: "延遲",
slowApply: "慢套用",
putBytes: "寫入位元組",
},
watch: "監視",
lease: "租約",
key: "Key",
keyOrPrefix: "Key 或前綴",
selectExistingLease: "選擇已有 Lease",
enterLeaseId: "或手動輸入 Lease ID",
leasePickerHint: "可從當前會話租約中選擇,或手動輸入 Lease ID。",
noLeasePickerHint: "當前會話沒有可選擇的 Lease可手動輸入 ID 後儲存。",
registryWarning: "該 Key 位於 /registry/ 下,這個命名空間通常由控制平面元件使用。儲存前請核實歸屬和影響。",
access: {
users: "使用者",
roles: "角色",
title: "存取控制",
description: "管理 etcd 原生使用者、角色及 Key 權限",
readOnly: "唯讀連線",
refresh: "重新整理",
userCount: "使用者 ({count})",
roleCount: "角色 ({count})",
createUser: "建立使用者",
createRole: "建立角色",
noUsers: "未建立 etcd 使用者",
noRoles: "未建立 etcd 角色",
selectUser: "從左側選擇使用者以檢視角色和憑證操作",
selectRole: "從左側選擇角色以檢視和管理權限",
loadingUser: "載入使用者詳情...",
loadingRole: "載入角色詳情...",
userRolesSummary: "已關聯 {count} 個角色。密碼不會被讀取、顯示或儲存在用戶端。",
changePassword: "修改密碼",
deleteUser: "刪除使用者",
roleAssignments: "角色指派",
roleAssignmentHint: "角色決定該使用者可讀取和寫入的 Key 範圍。",
revokeAssociation: "撤銷關聯",
noAssignedRoles: "該使用者尚未指派角色。",
assignRole: "關聯新角色",
assignRoleHint: "選擇一個尚未關聯的角色並授予給此使用者。",
selectRoleOption: "選擇角色",
noAssignableRoles: "沒有可關聯的角色",
grantRole: "關聯角色",
rolePermissionHint: "權限可覆蓋所有 Key、Prefix 或精確 KeyPrefix 使用 etcd 半開區間儲存。",
grantPermission: "授予權限",
deleteRole: "刪除角色",
resource: "資源",
permission: "權限",
allKeys: "所有 Key",
exactKey: "精確 Key",
allKeyspace: "全部 Keyspace",
edit: "編輯",
revoke: "撤銷",
noPermissions: "該角色尚未授予權限。",
createEtcdUser: "建立 etcd 使用者",
username: "使用者名稱",
password: "密碼",
initialRoles: "初始角色",
optional: "選填",
initialRolesHint: "建立後會立即關聯所選角色。",
selectedCount: "已選 {count}",
noRolesForNewUser: "目前沒有角色。可以先建立使用者,再在使用者詳情中關聯角色。",
createAndAssignRoles: "建立並關聯 {count} 個角色",
createUserAction: "建立使用者",
createEtcdRole: "建立 etcd 角色",
roleName: "角色名稱",
initialPermission: "初始權限",
initialPermissionHint: "填寫後會在建立角色時立即授予該權限。",
keyOrPrefixPlaceholder: "Key 或 Prefix",
allKeysHint: "將授權存取全部 Key。",
createAndGrantPermission: "建立並授予權限",
read: "讀取",
write: "寫入",
readWrite: "讀取和寫入",
passwordHint: "新密碼只會在本次請求中傳送,提交後立即從介面清除。",
newPassword: "新密碼",
continue: "繼續",
editPermission: "編輯角色權限",
permissionUpdateHint: "etcd Auth 不提供原子更新;儲存時會先撤銷舊權限,再授予新權限。若授予失敗,系統會嘗試回復舊權限。",
saveChanges: "儲存修改",
dangerousTitle: "確認危險操作",
dangerousHint: "該操作會直接修改 etcd Auth 資料。請輸入下方確認文字後才能繼續。",
confirmationPlaceholder: "輸入確認文字",
confirmExecute: "確認執行",
cancel: "取消",
preflightRequired: "該權限變更需要新的確認憑證。請重新點擊「儲存修改」並完成確認後重試。",
preflightExpired: "確認已過期。請重新點擊「儲存修改」並完成確認。",
preflightMismatch: "確認內容或待修改的權限已變化。請重新確認後重試。",
confirmationMismatch: "確認文字不一致。請重試。",
createdUser: "已建立使用者 {user}",
createdUserWithRoles: "已建立使用者 {user} 並關聯 {count} 個角色",
createdUserRolesFailed: "使用者已建立,但初始角色關聯失敗:{error}",
createdRole: "已建立角色 {role}",
createdRoleWithPermission: "已建立角色 {role} 並授予初始權限",
createdRolePermissionFailed: "角色已建立,但初始權限授予失敗:{error}",
deletedUser: "已刪除使用者 {user}",
deletedRole: "已刪除角色 {role}",
passwordUpdated: "已更新 {user} 的密碼",
roleGranted: "已將角色 {role} 授予 {user}",
roleRevoked: "已撤銷 {user} 的角色 {role}",
permissionGranted: "已授予角色權限",
permissionUpdated: "已更新角色權限",
permissionRevoked: "已撤銷角色權限",
},
admin: {
maintenance: "維護",
watch: "監視器",
lease: "租約",
refresh: "重新整理",
maintenanceTitle: "維護歷史與磁碟空間",
maintenanceDescription: "先按需清理不再使用的歷史版本,再決定是否回收後端檔案佔用的磁碟空間。這兩項操作互不替代。",
currentRevision: "目前 revision {revision}",
reachableMembers: "可達成員 {reachable} / {total}",
compactTitle: "清理歷史版本",
compactDescription: "刪除指定 revision 之前的 MVCC 歷史。目前 Key 值不會被刪除,但過去 revision 的讀取和從較早位置開始的 Watch 將無法回復。",
compactDiskHint: "不會釋放磁碟空間。若需要縮小 backend 檔案,請在壓縮完成後執行磁碟重組。",
compactRevision: "歷史保留起點revision",
compactPlaceholder: "例如 {revision}",
compactRequired: "請輸入要保留歷史的 revision。",
compactDone: "歷史版本清理完成",
compactInputHint: "將刪除小於該值的歷史記錄。執行前請確認不再需要這些歷史版本。",
compactAction: "清理歷史版本",
defragTitle: "回收磁碟空間",
defragDescription: "磁碟重組會重寫各成員的後端檔案,將可回收的磁碟空間歸還給作業系統。系統會先序列處理 follower再處理當前 leader。",
defragWarning: "每個成員整理期間會短暫無法使用。系統會逐一執行,並將目前 leader 放在最後;請先確認叢集健康。",
defragReachableMembers: "將處理 {count} 個可連線成員",
leaderLast: "Leader 最後:{endpoint}",
defragAction: "回收 {count} 個成員的磁碟空間",
defragDone: "磁碟空間回收完成",
newWatch: "新增監視器",
editWatch: "編輯監視器",
watchSearch: "篩選監視器",
watchKeyPlaceholder: "例如 /apps/payment/config",
watchScope: "監視範圍",
prefix: "前綴",
exact: "精確 Key",
watchKeyHint: "只接收這個 Key 的建立、值變更和刪除事件。",
watchPrefixHint: "接收以該前綴開頭的所有 Key 事件。",
watchSessionHint: "監視器只在當前連線會話中執行,關閉連線或離開 etcd 頁面後會自動停止。每個連線最多同時執行 4 個監視器。",
watchCount: "已建立 {count} 個",
runningWatchCount: "執行中 {count} 個",
watchList: "已建立的監視器",
itemCount: "{count} 項",
scope: "範圍",
status: "狀態",
actions: "操作",
createWatch: "建立並開始監視",
saveWatch: "儲存設定",
running: "執行中",
stopped: "已停止",
error: "異常",
editWatchTitle: "編輯監視器",
stopWatch: "停止監視器",
startWatch: "啟動監視器",
deleteWatch: "刪除監視器",
noMatchingWatches: "未找到匹配的監視器",
noWatches: "還沒有監視器。",
eventStream: "事件串流",
eventCount: "{count} 條事件",
filter: "篩選",
event: "事件",
create: "建立",
update: "值變更",
delete: "刪除",
noFilteredEvents: "目前篩選沒有匹配事件",
waitingEvents: "正在等待 Key 變更...",
watchNotRunning: "該監視器目前沒有執行。",
selectWatch: "從左側選擇一個監視器檢視事件串流",
leaseDescription: "租約到期後,其附著 Key 會自動刪除。",
grantLease: "授予租約",
renew: "續約",
revoke: "撤銷",
previousPage: "上一頁",
nextPage: "下一頁",
leasePageSize: "每頁最多 {count} 項",
grantedTtl: "授予 {ttl}s",
noKnownLeases: "目前會話沒有已知 Lease",
loadingLease: "載入 Lease 詳情...",
keepalive: "頁面保持開啟時按 TTL / 3 自動續約",
attachedKeys: "附著 Key",
noAttachedKeys: "沒有附著 Key",
attachedKeysTruncated: "附著 Key 結果已截斷,最多顯示 256 項。",
selectLease: "選擇 Lease 檢視詳情",
leasePartial: "目前用戶端或 etcd 服務端不支援 Lease 列舉,暫時只顯示本會話建立或存取過的 Lease詳情與 TTL 均來自服務端。",
grantNewLease: "授予新租約",
ttl: "TTL",
ttlPlaceholder: "例如 60",
ttlHint: "租約的有效期,必須是正整數。",
customLeaseId: "自訂 Lease ID",
customLeasePlaceholder: "留空或填 0 由 etcd 自動指派",
customLeaseHint: "使用十進位整數。該 ID 已存在時 etcd 會拒絕建立。",
confirmationHint: "這是一次不可自動撤銷的叢集維護操作。請確認以下影響後繼續。",
confirmationCredentialHint: "請輸入下方確認文字。確認憑證僅對本次操作有效。",
confirmationPlaceholder: "輸入確認文字",
confirmationPrompt: "此操作可能影響 etcd 叢集。請輸入確認文字:\n{confirmationText}",
confirmExecute: "確認執行",
cancel: "取消",
noReachableMembers: "沒有可達成員,暫時不能執行磁碟重組。",
defragStopped: "磁碟空間回收已停止:成功 {succeeded},失敗 {failed},未執行 {notExecuted}",
defragCompleted: "磁碟空間回收完成:成功 {succeeded}/{total}",
leaseRenewed: "Lease {id} 已續約",
leaseGrantCancelled: "已取消等待etcd 可能仍在處理該授權請求,請重新整理確認是否已建立 Lease。",
leaseCreated: "已建立 Lease {id}",
},
},
zookeeper: {

View File

@ -9,7 +9,7 @@ describe("etcd key tree", () => {
{ key: "/service/api", modRevision: 5 },
]);
expect(tree.map((node) => node.label)).toEqual(["app", "service"]);
expect(tree.map((node) => node.label)).toEqual(["/app", "/service"]);
const app = tree[0];
expect(app.kind).toBe("group");
if (app.kind === "group") {
@ -19,8 +19,14 @@ describe("etcd key tree", () => {
it("flattens only expanded groups", () => {
const tree = buildEtcdKeyTree([{ key: "/app/config/name" }, { key: "/plain" }]);
const rows = flattenVisibleEtcdKeyTree(tree, new Set(["group:app"]));
const rows = flattenVisibleEtcdKeyTree(tree, new Set(["group:/app"]));
expect(rows.map((row) => `${row.depth}:${row.node.label}`)).toEqual(["0:app", "1:config", "0:plain"]);
expect(rows.map((row) => `${row.depth}:${row.node.label}`)).toEqual(["0:/app", "1:config", "0:/plain"]);
});
it("keeps leading-slash and relative key prefixes in separate branches", () => {
const tree = buildEtcdKeyTree([{ key: "/test/ttt" }, { key: "test/app/config" }]);
expect(tree.map((node) => `${node.id}:${node.label}`)).toEqual(["group:/test:/test", "group:test:test"]);
});
});

View File

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import { releaseEtcdWatch, releaseEtcdWatchBestEffort, releaseEtcdWatchesBestEffort, replaceEtcdWatch } from "@/lib/etcd/watchLifecycle";
describe("etcd watch lifecycle", () => {
it("releases terminal and stopped watch ids without consulting UI status", async () => {
const stop = vi.fn().mockResolvedValue({ stopped: true });
await releaseEtcdWatch("etcd-1", "terminal-watch", stop);
await releaseEtcdWatch("etcd-1", "stopped-watch", stop);
expect(stop.mock.calls).toEqual([
["etcd-1", "terminal-watch"],
["etcd-1", "stopped-watch"],
]);
});
it("stops the previous id before starting a replacement", async () => {
const order: string[] = [];
const stop = vi.fn(async (_connectionId: string, watchId: string) => {
order.push(`stop:${watchId}`);
});
const result = await replaceEtcdWatch("etcd-1", "old-watch", stop, async () => {
order.push("start");
return "new-watch";
});
expect(result).toBe("new-watch");
expect(order).toEqual(["stop:old-watch", "start"]);
});
it("releases every retained id during best-effort teardown", async () => {
const stop = vi.fn().mockResolvedValue({ stopped: true });
await releaseEtcdWatchesBestEffort("etcd-1", ["running", "terminal", "stopped", ""], stop);
expect(stop.mock.calls.map((call) => call[1]).sort()).toEqual(["running", "stopped", "terminal"]);
});
it("propagates a stop failure and does not start a replacement", async () => {
const stopError = new Error("stop timed out");
const stop = vi.fn().mockRejectedValue(stopError);
const start = vi.fn().mockResolvedValue("new-watch");
await expect(releaseEtcdWatch("etcd-1", "old-watch", stop)).rejects.toBe(stopError);
await expect(replaceEtcdWatch("etcd-1", "old-watch", stop, start)).rejects.toBe(stopError);
expect(start).not.toHaveBeenCalled();
});
it("suppresses stop failures only for best-effort cleanup", async () => {
const stop = vi.fn().mockRejectedValue(new Error("runtime unavailable"));
await expect(releaseEtcdWatchBestEffort("etcd-1", "old-watch", stop)).resolves.toBeUndefined();
await expect(releaseEtcdWatchesBestEffort("etcd-1", ["first", "second"], stop)).resolves.toBeUndefined();
expect(stop).toHaveBeenCalledTimes(3);
});
it("does not accumulate slots across repeated terminal cycles", async () => {
const active = new Set<string>();
const stop = vi.fn(async (_connectionId: string, watchId: string) => {
active.delete(watchId);
});
for (let index = 0; index < 4; index++) {
const watchId = `watch-${index}`;
active.add(watchId);
await releaseEtcdWatch("etcd-1", watchId, stop);
}
expect(active.size).toBe(0);
active.add("watch-next");
expect(active.size).toBe(1);
});
});

View File

@ -11,7 +11,7 @@ describe("kv key tree", () => {
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
kind: "group",
label: "app",
label: "/app",
key: "/app",
modRevision: "2",
children: [{ kind: "leaf", key: "/app/name" }],
@ -21,7 +21,7 @@ describe("kv key tree", () => {
it("keeps root keys as leaf nodes", () => {
const tree = buildKvKeyTree([{ key: "/plain", version: 2 }, { key: "/" }]);
expect(tree.map((node) => `${node.kind}:${node.label}`)).toEqual(["leaf:/", "leaf:plain"]);
expect(tree.map((node) => `${node.kind}:${node.label}`)).toEqual(["leaf:/", "leaf:/plain"]);
expect(tree[1]).toMatchObject({ kind: "leaf", key: "/plain", version: 2 });
});
@ -48,7 +48,7 @@ describe("kv key tree", () => {
{ key: "/service/api", modRevision: 6 },
]);
expect(tree.map((node) => node.label)).toEqual(["app", "service", "plain"]);
expect(tree.map((node) => node.label)).toEqual(["/app", "/service", "/plain"]);
const app = tree[0];
expect(app.kind).toBe("group");
if (app.kind === "group") {
@ -59,22 +59,22 @@ describe("kv key tree", () => {
it("collects stable group ids", () => {
const tree = buildKvKeyTree([{ key: "/app/config/name" }, { key: "/service/api" }]);
expect([...collectKvGroupIds(tree)].sort()).toEqual(["group:app", "group:app\u0000config", "group:service"]);
expect([...collectKvGroupIds(tree)].sort()).toEqual(["group:/app", "group:/app\u0000config", "group:/service"]);
});
it("flattens only expanded groups", () => {
const tree = buildKvKeyTree([{ key: "/app/config/name" }, { key: "/plain" }]);
const rows = flattenVisibleKvKeyTree(tree, new Set(["group:app"]));
const rows = flattenVisibleKvKeyTree(tree, new Set(["group:/app"]));
expect(rows.map((row) => `${row.depth}:${row.node.label}`)).toEqual(["0:app", "1:config", "0:plain"]);
expect(rows.map((row) => `${row.depth}:${row.node.label}`)).toEqual(["0:/app", "1:config", "0:/plain"]);
});
it("preserves only expanded groups still present after reload", () => {
const tree = buildKvKeyTree([{ key: "/app/config/name" }, { key: "/service/api" }]);
const next = preserveKvExpandedGroupIds(tree, new Set(["group:app", "group:missing"]));
const next = preserveKvExpandedGroupIds(tree, new Set(["group:/app", "group:missing"]));
expect([...next]).toEqual(["group:app"]);
expect([...preserveKvExpandedGroupIds(tree, new Set(), true)].sort()).toEqual(["group:app", "group:app\u0000config", "group:service"]);
expect([...next]).toEqual(["group:/app"]);
expect([...preserveKvExpandedGroupIds(tree, new Set(), true)].sort()).toEqual(["group:/app", "group:/app\u0000config", "group:/service"]);
});
it("preserves whether a virtual directory has a leading slash", () => {

View File

@ -18,9 +18,10 @@ describe("sidebar tree item layout", () => {
expect(alignedCommentLeadingWidth(undefined, true)).toBeUndefined();
});
it("renders etcd Keys and Dashboard as aligned leaf actions", () => {
it("renders etcd leaf actions without expanders", () => {
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-access-control", childCount: 0 })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {

View File

@ -430,6 +430,15 @@ export const etcdDelete = forward("etcdDelete");
export const etcdRename = forward("etcdRename");
export const etcdHistory = forward("etcdHistory");
export const etcdStatus = forward("etcdStatus");
export const etcdPreflight = forward("etcdPreflight");
export const etcdCompact = forward("etcdCompact");
export const etcdDefrag = forward("etcdDefrag");
export const etcdWatchStart = forward("etcdWatchStart");
export const etcdWatchPoll = forward("etcdWatchPoll");
export const etcdWatchStop = forward("etcdWatchStop");
export const etcdLeaseList = forward("etcdLeaseList");
export const etcdLeaseCall = forward("etcdLeaseCall");
export const etcdAuthCall = forward("etcdAuthCall");
// ZooKeeper
export const zookeeperListPrefix = forward("zookeeperListPrefix");
@ -673,6 +682,20 @@ export type {
KvStatusMember,
KvPrometheusMetrics,
KvStatusResponse,
EtcdDefragResponse,
EtcdDefragMemberResult,
EtcdWatchStartRequest,
EtcdWatchStartResponse,
EtcdWatchPollResponse,
EtcdLeaseListResponse,
EtcdLeaseDetail,
EtcdAuthUserListResponse,
EtcdAuthUserDetail,
EtcdAuthPermission,
EtcdAuthRoleListResponse,
EtcdAuthRoleDetail,
EtcdPreflightResponse,
EtcdDangerousApproval,
DocumentQueryResult,
MongoDocumentResult,
HistoryEntry,

View File

@ -89,6 +89,11 @@ import type {
KvDeleteResponse,
KvHistoryResponse,
KvStatusResponse,
EtcdDefragResponse,
EtcdWatchStartRequest,
EtcdWatchStartResponse,
EtcdWatchPollResponse,
EtcdLeaseListResponse,
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
@ -2521,6 +2526,33 @@ export async function etcdHistory(
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return post("/api/etcd/status", { connectionId });
}
export async function etcdPreflight(connectionId: string, action: string, params: Record<string, unknown>): Promise<import("./tauri").EtcdPreflightResponse> {
return post("/api/etcd/preflight", { connectionId, request: { action, params } });
}
export async function etcdCompact(connectionId: string, revision: KvInt64, approval: import("./tauri").EtcdDangerousApproval): Promise<{ revision: KvInt64 }> {
return post("/api/etcd/compact", { connectionId, revision, ...approval });
}
export async function etcdDefrag(connectionId: string, endpoints: string[], approval: import("./tauri").EtcdDangerousApproval): Promise<EtcdDefragResponse> {
return post("/api/etcd/defrag", { connectionId, endpoints, ...approval });
}
export async function etcdWatchStart(connectionId: string, request: EtcdWatchStartRequest): Promise<EtcdWatchStartResponse> {
return post("/api/etcd/watch/start", { connectionId, request });
}
export async function etcdWatchPoll(connectionId: string, watchId: string): Promise<EtcdWatchPollResponse> {
return post("/api/etcd/watch/poll", { connectionId, watchId });
}
export async function etcdWatchStop(connectionId: string, watchId: string): Promise<{ stopped: boolean }> {
return post("/api/etcd/watch/stop", { connectionId, watchId });
}
export async function etcdLeaseList(connectionId: string, limit = 100, continuation?: string | null): Promise<EtcdLeaseListResponse> {
return post("/api/etcd/lease/list", { connectionId, limit, continuation: continuation ?? null });
}
export async function etcdLeaseCall<T = unknown>(connectionId: string, operation: "get" | "grant" | "keepalive" | "revoke", params: Record<string, unknown>, approval?: import("./tauri").EtcdDangerousApproval): Promise<T> {
return post("/api/etcd/lease/call", { connectionId, operation, params, ...approval });
}
export async function etcdAuthCall<T = unknown>(connectionId: string, operation: string, params: Record<string, unknown>, approval?: import("./tauri").EtcdDangerousApproval): Promise<T> {
return post("/api/etcd/auth/call", { connectionId, operation, params, ...approval });
}
// ---------------------------------------------------------------------------
// ZooKeeper

View File

@ -2472,6 +2472,75 @@ export interface KvStatusResponse {
metrics?: KvPrometheusMetrics | null;
}
export interface EtcdDefragMemberResult {
endpoint: string;
status: "succeeded" | "failed" | "not_executed";
durationMs?: number | null;
error?: string | null;
}
export interface EtcdDefragResponse {
members: EtcdDefragMemberResult[];
}
export interface EtcdWatchStartRequest {
key: string;
keyBytes?: KvValue | null;
scope: "key" | "prefix";
startRevision?: KvInt64 | null;
includePrevKv: boolean;
}
export interface EtcdWatchStartResponse {
watchId: string;
startedRevision: KvInt64;
}
export interface EtcdWatchPollResponse {
watchId: string;
batches: Array<{ revision: KvInt64; events: Array<{ eventType: "put" | "delete"; revision: KvInt64; key: string; keyBytes?: KvValue | null; value?: KvValue | null; previousValue?: KvValue | null; metadata?: KvKeyMetadata | null }> }>;
terminal?: { reason: string; message?: string; compactedRevision?: KvInt64 | null } | null;
}
export interface EtcdLeaseListResponse {
leases: Array<{ id: KvInt64; ttl: number; grantedTtl?: number }>;
partial: boolean;
nextContinuation?: string | null;
}
export interface EtcdLeaseDetail {
id: KvInt64;
ttl: number;
grantedTtl?: number;
keys: KvValue[];
truncated: boolean;
}
export interface EtcdAuthUserListResponse {
users: string[];
}
export interface EtcdAuthUserDetail {
user: string;
roles: string[];
}
export interface EtcdAuthPermission {
access: "read" | "write" | "readwrite";
key: KvValue;
rangeEnd: KvValue;
resource: "all" | "key" | "prefix";
}
export interface EtcdAuthRoleListResponse {
roles: string[];
}
export interface EtcdAuthRoleDetail {
role: string;
permissions: EtcdAuthPermission[];
}
export interface EtcdPreflightResponse {
token: string;
action: string;
confirmationText: string;
expiresAtMs: number;
clusterId?: KvInt64 | null;
}
export interface EtcdDangerousApproval {
preflightToken: string;
confirmationText: string;
}
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", {
connectionId,
@ -2548,6 +2617,33 @@ export async function etcdHistory(
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return invoke("etcd_status", { connectionId });
}
export async function etcdPreflight(connectionId: string, action: string, params: Record<string, unknown>): Promise<EtcdPreflightResponse> {
return invoke("etcd_preflight", { connectionId, request: { action, params } });
}
export async function etcdCompact(connectionId: string, revision: KvInt64, approval: EtcdDangerousApproval): Promise<{ revision: KvInt64 }> {
return invoke("etcd_compact", { connectionId, revision, ...approval });
}
export async function etcdDefrag(connectionId: string, endpoints: string[], approval: EtcdDangerousApproval): Promise<EtcdDefragResponse> {
return invoke("etcd_defrag", { connectionId, endpoints, ...approval });
}
export async function etcdWatchStart(connectionId: string, request: EtcdWatchStartRequest): Promise<EtcdWatchStartResponse> {
return invoke("etcd_watch_start", { connectionId, request });
}
export async function etcdWatchPoll(connectionId: string, watchId: string): Promise<EtcdWatchPollResponse> {
return invoke("etcd_watch_poll", { connectionId, watchId });
}
export async function etcdWatchStop(connectionId: string, watchId: string): Promise<{ stopped: boolean }> {
return invoke("etcd_watch_stop", { connectionId, watchId });
}
export async function etcdLeaseList(connectionId: string, limit = 100, continuation?: string | null): Promise<EtcdLeaseListResponse> {
return invoke("etcd_lease_list", { connectionId, limit, continuation: continuation ?? null });
}
export async function etcdLeaseCall<T = unknown>(connectionId: string, operation: "get" | "grant" | "keepalive" | "revoke", params: Record<string, unknown>, approval?: EtcdDangerousApproval): Promise<T> {
return invoke("etcd_lease_call", { connectionId, operation, params, ...approval });
}
export async function etcdAuthCall<T = unknown>(connectionId: string, operation: string, params: Record<string, unknown>, approval?: EtcdDangerousApproval): Promise<T> {
return invoke("etcd_auth_call", { connectionId, operation, params, ...approval });
}
// --- ZooKeeper ---
export async function zookeeperListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {

View File

@ -12,4 +12,10 @@ describe("connectionHealth", () => {
expect(shouldMarkDisconnected("syntax error at or near SELECT")).toBe(false);
expect(shouldMarkDisconnected("Access denied for user root")).toBe(false);
});
it("marks transient etcd gRPC failures as disconnected", () => {
expect(shouldMarkDisconnected("ETCD_CONNECTION_UNAVAILABLE: etcd is temporarily unavailable")).toBe(true);
expect(shouldMarkDisconnected("io.grpc.StatusRuntimeException: DEADLINE_EXCEEDED")).toBe(true);
expect(shouldMarkDisconnected("ETCD_PERMISSION_DENIED: not authorized")).toBe(false);
});
});

View File

@ -32,6 +32,10 @@ const CONNECTION_ERROR_PATTERNS = [
"sqltransientconnectionexception",
"i/o error",
"no route to host",
"etcd_connection_unavailable",
"etcd_connection_timeout",
"statusruntimeexception: unavailable",
"deadline_exceeded",
];
export function staleConnectionMessage(error: unknown): string {

View File

@ -0,0 +1,19 @@
export type EtcdWatchStop = (connectionId: string, watchId: string) => Promise<unknown>;
export async function releaseEtcdWatch(connectionId: string, watchId: string, stop: EtcdWatchStop): Promise<void> {
if (!watchId) return;
await stop(connectionId, watchId);
}
export async function releaseEtcdWatchBestEffort(connectionId: string, watchId: string, stop: EtcdWatchStop): Promise<void> {
await releaseEtcdWatch(connectionId, watchId, stop).catch(() => undefined);
}
export async function releaseEtcdWatchesBestEffort(connectionId: string, watchIds: Iterable<string>, stop: EtcdWatchStop): Promise<void> {
await Promise.all([...watchIds].filter(Boolean).map((watchId) => releaseEtcdWatchBestEffort(connectionId, watchId, stop)));
}
export async function replaceEtcdWatch<T>(connectionId: string, previousWatchId: string, stop: EtcdWatchStop, start: () => Promise<T>): Promise<T> {
await releaseEtcdWatch(connectionId, previousWatchId, stop);
return start();
}

View File

@ -5,6 +5,7 @@ export interface KvKeyTreeLeafNode {
id: string;
label: string;
key: string;
leadingSlash: boolean;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
pathSegments: string[];
@ -19,6 +20,7 @@ export interface KvKeyTreeGroupNode {
kind: "group";
id: string;
label: string;
leadingSlash: boolean;
pathSegments: string[];
children: KvKeyTreeNode[];
key?: string;
@ -38,12 +40,23 @@ export interface KvKeyTreeRow {
depth: number;
}
function keySegments(key: string): string[] {
return key.split("/").filter(Boolean);
function keyPath(key: string): { segments: string[]; leadingSlash: boolean } {
return {
segments: key.split("/").filter(Boolean),
leadingSlash: key.startsWith("/"),
};
}
function groupId(pathSegments: string[]): string {
return `group:${pathSegments.join("\u0000")}`;
function groupId(pathSegments: string[], leadingSlash: boolean): string {
return `group:${leadingSlash ? "/" : ""}${pathSegments.join("\u0000")}`;
}
function nodePathIdentity(pathSegments: string[], leadingSlash: boolean): string {
return `${leadingSlash ? "/" : ""}${pathSegments.join("\u0000")}`;
}
function treeLabel(segment: string, index: number, leadingSlash: boolean): string {
return leadingSlash && index === 0 ? `/${segment}` : segment;
}
function summaryIdentity(key: KvKeySummary): string {
@ -68,13 +81,14 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
const groups = new Map<string, KvKeyTreeGroupNode>();
for (const key of keys) {
const segments = keySegments(key.key);
const { segments, leadingSlash } = keyPath(key.key);
if (segments.length <= 1) {
root.push({
kind: "leaf",
id: leafId(key),
label: segments[0] || key.key || "/",
label: segments[0] ? treeLabel(segments[0], 0, leadingSlash) : key.key || "/",
key: key.key,
leadingSlash,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
@ -89,17 +103,18 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
let current = root;
const groupSegments: string[] = [];
for (const segment of segments.slice(0, -1)) {
for (const [index, segment] of segments.slice(0, -1).entries()) {
groupSegments.push(segment);
const id = groupId(groupSegments);
const id = groupId(groupSegments, leadingSlash);
let group = groups.get(id);
if (!group) {
const leafIndex = current.findIndex((candidate) => candidate.kind === "leaf" && candidate.pathSegments.join("\u0000") === groupSegments.join("\u0000"));
const leafIndex = current.findIndex((candidate) => candidate.kind === "leaf" && nodePathIdentity(candidate.pathSegments, candidate.leadingSlash) === nodePathIdentity(groupSegments, leadingSlash));
const existingLeaf = leafIndex >= 0 ? (current.splice(leafIndex, 1)[0] as KvKeyTreeLeafNode) : null;
group = {
kind: "group",
id,
label: segment,
label: treeLabel(segment, index, leadingSlash),
leadingSlash,
pathSegments: [...groupSegments],
children: [],
key: existingLeaf?.key,
@ -117,7 +132,7 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
current = group.children;
}
const existingGroup = groups.get(groupId(segments));
const existingGroup = groups.get(groupId(segments, leadingSlash));
if (existingGroup) {
existingGroup.key = key.key;
existingGroup.keyIdentity = key.keyIdentity;
@ -135,6 +150,7 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
id: leafId(key),
label: segments[segments.length - 1],
key: key.key,
leadingSlash,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
@ -186,17 +202,6 @@ export function kvKeyTreeNodePath(node: KvKeyTreeNode): string {
if (node.kind === "leaf") return node.key;
if (node.key) return node.key;
const descendantKey = firstDescendantKey(node);
const joined = node.pathSegments.join("/");
return descendantKey?.startsWith("/") ? `/${joined}` : joined;
}
function firstDescendantKey(node: KvKeyTreeGroupNode): string | null {
for (const child of node.children) {
if (child.kind === "leaf") return child.key;
if (child.key) return child.key;
const nested = firstDescendantKey(child);
if (nested) return nested;
}
return null;
return node.leadingSlash ? `/${joined}` : joined;
}

View File

@ -40,6 +40,10 @@ export type ActiveTabSidebarTarget =
type: "etcd-dashboard";
connectionId: string;
}
| {
type: "etcd-access-control";
connectionId: string;
}
| {
type: "zookeeper-root";
connectionId: string;
@ -136,6 +140,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
return { type: "etcd-dashboard", connectionId: tab.connectionId };
}
if (tab.mode === "etcd-access-control") {
return { type: "etcd-access-control", connectionId: tab.connectionId };
}
if (tab.mode === "zookeeper") {
return { type: "zookeeper-root", connectionId: tab.connectionId };
}
@ -211,6 +219,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "etcd-dashboard" && node.connectionId === target.connectionId;
}
if (target.type === "etcd-access-control") {
return node.type === "etcd-access-control" && node.connectionId === target.connectionId;
}
if (target.type === "zookeeper-root") {
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
}

View File

@ -17,6 +17,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"mq-tenant",
"etcd-root",
"etcd-dashboard",
"etcd-access-control",
"zookeeper-root",
"mongo-gridfs",
"mongo-bucket",

View File

@ -8,7 +8,7 @@ export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "etcd-dashboard", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "etcd-dashboard", "etcd-access-control", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "trigger", "sequence", "synonym", "package", "package-body", "type", "type-body"]);
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);

View File

@ -1,5 +1,5 @@
import type { Component } from "vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, Link2, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, Link2, ListTree, Network, Package, Plus, ScrollText, Server, ShieldCheck, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import type { ColumnInfo, TreeNode } from "@/types/database";
export type TreeNodeIconInfo = {
@ -32,6 +32,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
case "etcd-access-control":
return { icon: ShieldCheck, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "table":

View File

@ -110,6 +110,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@dashboard`;
}
if (tab.mode === "etcd-access-control") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@${t("tabs.etcdAccessControl")}`;
}
if (tab.mode === "zookeeper") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
@ -404,6 +408,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
if (tab.mode === "etcd-access-control") return t("tabs.etcdAccessControl");
if (tab.mode === "zookeeper") return t("tabs.zookeeper");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "objects") return t("tabs.objects");

View File

@ -3209,9 +3209,18 @@ export const useConnectionStore = defineStore("connection", () => {
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-access-control`,
label: "用户和角色",
type: "etcd-access-control" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-dashboard`,
label: "Dashboard",
label: "服务仪表盘",
type: "etcd-dashboard" as const,
connectionId,
database: "",

View File

@ -786,6 +786,7 @@ export type TreeNodeType =
| "nacos-namespace"
| "etcd-root"
| "etcd-dashboard"
| "etcd-access-control"
| "zookeeper-root"
| "mongo-db"
| "mongo-gridfs"
@ -977,6 +978,7 @@ export interface QueryTab {
| "hbase"
| "etcd"
| "etcd-dashboard"
| "etcd-access-control"
| "zookeeper"
| "mq"
| "nacos"

View File

@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@ -40,5 +40,5 @@
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status"]
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"]
}

View File

@ -2,7 +2,7 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "multi_session"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"commonMethods": [
@ -44,7 +44,7 @@
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
}

File diff suppressed because it is too large Load Diff

View File

@ -1163,11 +1163,13 @@ impl AppState {
Ok(Ok(())) => {}
Ok(Err(err)) => {
log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool");
pool_activity.write().await.remove(&key);
cancel_contexts.write().await.remove(&key);
let removed = connections.write().await.remove(&key);
let removed = remove_keepalive_pool_if_current(&connections, &key, target).await;
if let Some(pool) = removed {
pool_activity.write().await.remove(&key);
cancel_contexts.write().await.remove(&key);
close_pool_kind_with_timeout(key, pool).await;
} else {
log::debug!("Skipping stale keepalive result for replaced pool '{key}'");
}
break;
}
@ -1176,11 +1178,13 @@ impl AppState {
"Connection keepalive timed out for '{key}' after {}s; invalidating pool",
timeout.as_secs()
);
pool_activity.write().await.remove(&key);
cancel_contexts.write().await.remove(&key);
let removed = connections.write().await.remove(&key);
let removed = remove_keepalive_pool_if_current(&connections, &key, target).await;
if let Some(pool) = removed {
pool_activity.write().await.remove(&key);
cancel_contexts.write().await.remove(&key);
close_pool_kind_with_timeout(key, pool).await;
} else {
log::debug!("Skipping stale keepalive timeout for replaced pool '{key}'");
}
break;
}
@ -1616,7 +1620,7 @@ impl AppState {
agent_connection_pool_database_type!() => {
let connect_params =
agent_connect_params(&db_config, &host, port, db_config.effective_database().unwrap_or(""));
if db_config.db_type != DatabaseType::Etcd && db_config.db_type != DatabaseType::ZooKeeper {
if db_config.db_type != DatabaseType::ZooKeeper {
let agent_session_id = uuid::Uuid::new_v4().simple().to_string();
let mut initial_result = self
.agent_manager
@ -1735,7 +1739,7 @@ impl AppState {
};
PoolKind::Agent(Arc::new(tokio::sync::Mutex::new(client)))
} else {
// Kerberos JVM properties are connection-scoped; shared agent daemons must not inherit them.
// ZooKeeper JVM properties are connection-scoped; shared agent daemons must not inherit them.
let mut client = self
.agent_manager
.spawn_with_extra_java_args(
@ -3096,7 +3100,7 @@ impl AppState {
(checks, redis_keys)
};
let mut dead_keys = Vec::new();
let mut dead_pools = Vec::new();
let timeout = crate::db::connection_timeout();
// Check cloned pools (async I/O, no lock held)
@ -3219,9 +3223,18 @@ impl AppState {
}
}
PoolKind::Agent(client) => {
let mut agent = client.lock().await;
match agent.test_connection(serde_json::json!({})).await {
let Ok(mut agent) = client.try_lock() else {
log::debug!("Agent connection pool '{key}' is busy; skipping resume health probe");
continue;
};
match agent.validate_connection(Some(timeout)).await {
Ok(_) => true,
Err(err) if is_agent_validate_connection_unsupported(&err) => {
log::debug!(
"Agent connection pool '{key}' does not support validate_connection; keeping pool"
);
true
}
Err(e) => {
log::warn!("Agent connection pool '{key}' is unhealthy: {e}");
false
@ -3236,7 +3249,7 @@ impl AppState {
PoolKind::Redis(_) => unreachable!("Redis handled separately"),
};
if !healthy {
dead_keys.push(key.clone());
dead_pools.push((key.clone(), agent_pool_identity(pool)));
}
}
@ -3249,7 +3262,7 @@ impl AppState {
Ok(()) => {}
Err(e) => {
log::warn!("Redis connection pool '{key}' is unhealthy: {e}");
dead_keys.push(key.clone());
dead_pools.push((key.clone(), None));
}
}
}
@ -3257,22 +3270,35 @@ impl AppState {
}
// Remove dead pools
if !dead_keys.is_empty() {
self.stop_keepalive_tasks(&dead_keys).await;
{
let mut activity = self.pool_activity.write().await;
for key in &dead_keys {
activity.remove(key);
}
}
if !dead_pools.is_empty() {
let mut conns = self.connections.write().await;
let mut removed = Vec::with_capacity(dead_keys.len());
for key in &dead_keys {
if let Some(pool) = conns.remove(key) {
removed.push((key.clone(), pool));
let mut removed = Vec::with_capacity(dead_pools.len());
for (key, expected_agent) in &dead_pools {
let still_checked_pool = match expected_agent {
Some(expected) => matches!(
conns.get(key),
Some(PoolKind::Agent(current)) if Arc::ptr_eq(current, expected)
),
None => true,
};
if still_checked_pool {
if let Some(pool) = conns.remove(key) {
removed.push((key.clone(), pool));
}
} else {
log::debug!("Skipping stale Agent health result for replaced pool '{key}'");
}
}
drop(conns);
let removed_keys: Vec<String> = removed.iter().map(|(key, _)| key.clone()).collect();
self.stop_keepalive_tasks(&removed_keys).await;
{
let mut activity = self.pool_activity.write().await;
for key in &removed_keys {
activity.remove(key);
}
}
close_removed_pools(removed).await;
}
@ -3297,6 +3323,33 @@ impl AppState {
close_removed_pools_in_background(&self.task_supervisor, removed);
}
pub async fn invalidate_agent_pool_if_current(
&self,
pool_key: &str,
expected: &Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
) -> bool {
let removed = {
let mut pools = self.connections.write().await;
let is_current = matches!(
pools.get(pool_key),
Some(PoolKind::Agent(current)) if Arc::ptr_eq(current, expected)
);
if is_current {
self.task_supervisor.stop(&format!("keepalive:{pool_key}"));
pools.remove(pool_key)
} else {
None
}
};
let Some(pool) = removed else {
return false;
};
self.pool_activity.write().await.remove(pool_key);
self.postgres_cancel_contexts.write().await.remove(pool_key);
close_removed_pools_in_background(&self.task_supervisor, vec![(pool_key.to_string(), pool)]);
true
}
async fn drain_all_connection_pools(&self) -> Vec<(String, PoolKind)> {
let pool_keys = self.connections.read().await.keys().cloned().collect::<Vec<_>>();
self.stop_keepalive_tasks(&pool_keys).await;
@ -3441,6 +3494,32 @@ enum KeepaliveTarget {
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
}
impl KeepaliveTarget {
fn matches_pool(&self, pool: &PoolKind) -> bool {
match (self, pool) {
(Self::Agent(expected), PoolKind::Agent(current)) => Arc::ptr_eq(expected, current),
(Self::SqlServer(expected), PoolKind::SqlServer(current)) => Arc::ptr_eq(expected, current),
(Self::Agent(_), _) | (_, PoolKind::Agent(_)) | (Self::SqlServer(_), _) | (_, PoolKind::SqlServer(_)) => {
false
}
_ => true,
}
}
}
async fn remove_keepalive_pool_if_current(
connections: &Arc<RwLock<HashMap<String, PoolKind>>>,
pool_key: &str,
target: &KeepaliveTarget,
) -> Option<PoolKind> {
let mut pools = connections.write().await;
if pools.get(pool_key).is_some_and(|pool| target.matches_pool(pool)) {
pools.remove(pool_key)
} else {
None
}
}
fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Option<KeepaliveTarget> {
match pool {
PoolKind::Mysql(pool, _) => Some(KeepaliveTarget::Mysql(pool.clone())),
@ -3870,7 +3949,14 @@ fn should_validate_existing_pool_before_reuse(db_type: DatabaseType) -> bool {
// PostgreSQL uses deadpool's Fast recycling and the query executor's
// ReconnectAndRetry path. An eager SELECT 1 here would add a network
// round-trip before every query without improving recovery behavior.
!matches!(db_type, DatabaseType::Postgres)
!matches!(db_type, DatabaseType::Postgres | DatabaseType::Etcd)
}
fn agent_pool_identity(pool: &PoolKind) -> Option<Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>> {
match pool {
PoolKind::Agent(client) => Some(client.clone()),
_ => None,
}
}
#[cfg(test)]
@ -4359,8 +4445,9 @@ mod tests {
}
#[test]
fn postgres_pool_reuse_skips_eager_validation_query() {
fn drivers_with_internal_recovery_skip_eager_pool_validation() {
assert!(!super::should_validate_existing_pool_before_reuse(DatabaseType::Postgres));
assert!(!super::should_validate_existing_pool_before_reuse(DatabaseType::Etcd));
assert!(super::should_validate_existing_pool_before_reuse(DatabaseType::Mysql));
}

View File

@ -398,11 +398,16 @@ pub enum AgentCapability {
KvListValues,
KvStatus,
KvHistory,
EtcdCompaction,
EtcdDefrag,
EtcdWatch,
EtcdLease,
EtcdAuth,
MultiSession,
}
impl AgentCapability {
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 19] = [
Self::Connect,
Self::TestConnection,
Self::Metadata,
@ -416,6 +421,11 @@ impl AgentCapability {
Self::KvListValues,
Self::KvStatus,
Self::KvHistory,
Self::EtcdCompaction,
Self::EtcdDefrag,
Self::EtcdWatch,
Self::EtcdLease,
Self::EtcdAuth,
Self::MultiSession,
];
@ -434,6 +444,11 @@ impl AgentCapability {
Self::KvListValues => "kv_list_values",
Self::KvStatus => "kv_status",
Self::KvHistory => "kv_history",
Self::EtcdCompaction => "etcd_compaction",
Self::EtcdDefrag => "etcd_defrag",
Self::EtcdWatch => "etcd_watch",
Self::EtcdLease => "etcd_lease",
Self::EtcdAuth => "etcd_auth",
Self::MultiSession => "multi_session",
}
}
@ -666,11 +681,64 @@ pub enum AgentKvMethod {
Rename,
History,
Status,
Compact,
Defrag,
WatchStart,
WatchPoll,
WatchStop,
LeaseList,
LeaseGet,
LeaseGrant,
LeaseKeepalive,
LeaseRevoke,
AuthUserList,
AuthUserGet,
AuthUserAdd,
AuthUserDelete,
AuthUserChangePassword,
AuthUserGrantRole,
AuthUserRevokeRole,
AuthRoleList,
AuthRoleGet,
AuthRoleAdd,
AuthRoleDelete,
AuthRoleGrantPermission,
AuthRoleRevokePermission,
}
impl AgentKvMethod {
pub const ALL: [Self; 7] =
[Self::ListPrefix, Self::Get, Self::Put, Self::Delete, Self::Rename, Self::History, Self::Status];
pub const ALL: [Self; 30] = [
Self::ListPrefix,
Self::Get,
Self::Put,
Self::Delete,
Self::Rename,
Self::History,
Self::Status,
Self::Compact,
Self::Defrag,
Self::WatchStart,
Self::WatchPoll,
Self::WatchStop,
Self::LeaseList,
Self::LeaseGet,
Self::LeaseGrant,
Self::LeaseKeepalive,
Self::LeaseRevoke,
Self::AuthUserList,
Self::AuthUserGet,
Self::AuthUserAdd,
Self::AuthUserDelete,
Self::AuthUserChangePassword,
Self::AuthUserGrantRole,
Self::AuthUserRevokeRole,
Self::AuthRoleList,
Self::AuthRoleGet,
Self::AuthRoleAdd,
Self::AuthRoleDelete,
Self::AuthRoleGrantPermission,
Self::AuthRoleRevokePermission,
];
pub fn as_str(self) -> &'static str {
match self {
@ -681,6 +749,29 @@ impl AgentKvMethod {
Self::Rename => "kv_rename",
Self::History => "kv_history",
Self::Status => "kv_status",
Self::Compact => "etcd_compact",
Self::Defrag => "etcd_defrag",
Self::WatchStart => "etcd_watch_start",
Self::WatchPoll => "etcd_watch_poll",
Self::WatchStop => "etcd_watch_stop",
Self::LeaseList => "etcd_lease_list",
Self::LeaseGet => "etcd_lease_get",
Self::LeaseGrant => "etcd_lease_grant",
Self::LeaseKeepalive => "etcd_lease_keepalive_once",
Self::LeaseRevoke => "etcd_lease_revoke",
Self::AuthUserList => "etcd_auth_user_list",
Self::AuthUserGet => "etcd_auth_user_get",
Self::AuthUserAdd => "etcd_auth_user_add",
Self::AuthUserDelete => "etcd_auth_user_delete",
Self::AuthUserChangePassword => "etcd_auth_user_change_password",
Self::AuthUserGrantRole => "etcd_auth_user_grant_role",
Self::AuthUserRevokeRole => "etcd_auth_user_revoke_role",
Self::AuthRoleList => "etcd_auth_role_list",
Self::AuthRoleGet => "etcd_auth_role_get",
Self::AuthRoleAdd => "etcd_auth_role_add",
Self::AuthRoleDelete => "etcd_auth_role_delete",
Self::AuthRoleGrantPermission => "etcd_auth_role_grant_permission",
Self::AuthRoleRevokePermission => "etcd_auth_role_revoke_permission",
}
}
}
@ -1752,6 +1843,11 @@ pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability:
| AgentCapability::KvListValues
| AgentCapability::KvStatus
| AgentCapability::KvHistory
| AgentCapability::EtcdCompaction
| AgentCapability::EtcdDefrag
| AgentCapability::EtcdWatch
| AgentCapability::EtcdLease
| AgentCapability::EtcdAuth
) {
return handshake.map(|value| value.supports(capability)).unwrap_or(false);
}
@ -2813,8 +2909,13 @@ for line in sys.stdin:
assert_eq!(AgentCapability::KvListValues.as_str(), "kv_list_values");
assert_eq!(AgentCapability::KvStatus.as_str(), "kv_status");
assert_eq!(AgentCapability::KvHistory.as_str(), "kv_history");
assert_eq!(AgentCapability::EtcdCompaction.as_str(), "etcd_compaction");
assert_eq!(AgentCapability::EtcdDefrag.as_str(), "etcd_defrag");
assert_eq!(AgentCapability::EtcdWatch.as_str(), "etcd_watch");
assert_eq!(AgentCapability::EtcdLease.as_str(), "etcd_lease");
assert_eq!(AgentCapability::EtcdAuth.as_str(), "etcd_auth");
assert_eq!(AgentCapability::MultiSession.as_str(), "multi_session");
assert_eq!(AgentCapability::ALL.len(), 14);
assert_eq!(AgentCapability::ALL.len(), 19);
}
#[test]
@ -2878,7 +2979,19 @@ for line in sys.stdin:
assert_eq!(AgentKvMethod::Rename.as_str(), "kv_rename");
assert_eq!(AgentKvMethod::History.as_str(), "kv_history");
assert_eq!(AgentKvMethod::Status.as_str(), "kv_status");
assert_eq!(AgentKvMethod::ALL.len(), 7);
assert_eq!(AgentKvMethod::Compact.as_str(), "etcd_compact");
assert_eq!(AgentKvMethod::Defrag.as_str(), "etcd_defrag");
assert_eq!(AgentKvMethod::WatchStart.as_str(), "etcd_watch_start");
assert_eq!(AgentKvMethod::WatchPoll.as_str(), "etcd_watch_poll");
assert_eq!(AgentKvMethod::WatchStop.as_str(), "etcd_watch_stop");
assert_eq!(AgentKvMethod::LeaseList.as_str(), "etcd_lease_list");
assert_eq!(AgentKvMethod::LeaseGet.as_str(), "etcd_lease_get");
assert_eq!(AgentKvMethod::LeaseGrant.as_str(), "etcd_lease_grant");
assert_eq!(AgentKvMethod::LeaseKeepalive.as_str(), "etcd_lease_keepalive_once");
assert_eq!(AgentKvMethod::LeaseRevoke.as_str(), "etcd_lease_revoke");
assert_eq!(AgentKvMethod::AuthUserList.as_str(), "etcd_auth_user_list");
assert_eq!(AgentKvMethod::AuthRoleGrantPermission.as_str(), "etcd_auth_role_grant_permission");
assert_eq!(AgentKvMethod::ALL.len(), 30);
}
#[test]

View File

@ -530,6 +530,15 @@ async fn main() {
.route("/etcd/rename", post(routes::etcd::rename))
.route("/etcd/history", post(routes::etcd::history))
.route("/etcd/status", post(routes::etcd::status))
.route("/etcd/preflight", post(routes::etcd::preflight))
.route("/etcd/compact", post(routes::etcd::compact))
.route("/etcd/defrag", post(routes::etcd::defrag))
.route("/etcd/watch/start", post(routes::etcd::watch_start))
.route("/etcd/watch/poll", post(routes::etcd::watch_poll))
.route("/etcd/watch/stop", post(routes::etcd::watch_stop))
.route("/etcd/lease/list", post(routes::etcd::lease_list))
.route("/etcd/lease/call", post(routes::etcd::lease_call))
.route("/etcd/auth/call", post(routes::etcd::auth_call))
// ZooKeeper
.route("/zookeeper/list-prefix", post(routes::zookeeper::list_prefix))
.route("/zookeeper/get", post(routes::zookeeper::get))

View File

@ -6,6 +6,7 @@ use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
use dbx_core::db::agent_driver::AgentKvMethod;
/// Check if a connection is read-only and return an error if so.
async fn ensure_writable(
@ -64,6 +65,14 @@ pub struct EtcdConnectionRequest {
pub connection_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdLeaseListRequest {
pub connection_id: String,
pub limit: Option<usize>,
pub continuation: Option<String>,
}
pub async fn supports_ttl(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdConnectionRequest>,
@ -199,3 +208,248 @@ pub async fn status(
let result = dbx_core::agent_kv::kv_status_core(&state.app, &req.connection_id).await.map_err(AppError::from)?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdCompactRequest {
pub connection_id: String,
pub revision: dbx_core::agent_kv::KvInt64,
pub preflight_token: String,
pub confirmation_text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdDefragRequest {
pub connection_id: String,
pub endpoints: Vec<String>,
pub preflight_token: String,
pub confirmation_text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdWatchStartRouteRequest {
pub connection_id: String,
pub request: dbx_core::agent_kv::EtcdWatchStartRequest,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdWatchRequest {
pub connection_id: String,
pub watch_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdOperationRequest {
pub connection_id: String,
pub operation: String,
pub params: serde_json::Value,
pub preflight_token: Option<String>,
pub confirmation_text: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EtcdPreflightRouteRequest {
pub connection_id: String,
pub request: dbx_core::agent_kv::EtcdPreflightRequest,
}
pub async fn preflight(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdPreflightRouteRequest>,
) -> Result<Json<dbx_core::agent_kv::EtcdPreflightResponse>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Dangerous etcd operation").await?;
Ok(Json(
dbx_core::agent_kv::etcd_preflight_core(&state.app, &req.connection_id, req.request)
.await
.map_err(AppError::from)?,
))
}
pub async fn compact(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdCompactRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Compact").await?;
let params = serde_json::json!({ "revision": req.revision.clone() });
dbx_core::agent_kv::etcd_consume_preflight_core(
&state.app,
&req.connection_id,
"compact",
&params,
&req.preflight_token,
&req.confirmation_text,
)
.await
.map_err(AppError::from)?;
Ok(Json(
dbx_core::agent_kv::etcd_compact_core(&state.app, &req.connection_id, req.revision)
.await
.map_err(AppError::from)?,
))
}
pub async fn defrag(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdDefragRequest>,
) -> Result<Json<dbx_core::agent_kv::EtcdDefragResponse>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Defrag").await?;
let params = serde_json::json!({ "endpoints": req.endpoints.clone() });
dbx_core::agent_kv::etcd_consume_preflight_core(
&state.app,
&req.connection_id,
"defrag",
&params,
&req.preflight_token,
&req.confirmation_text,
)
.await
.map_err(AppError::from)?;
Ok(Json(
dbx_core::agent_kv::etcd_defrag_core(&state.app, &req.connection_id, req.endpoints)
.await
.map_err(AppError::from)?,
))
}
pub async fn watch_start(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdWatchStartRouteRequest>,
) -> Result<Json<dbx_core::agent_kv::EtcdWatchStartResponse>, AppError> {
Ok(Json(
dbx_core::agent_kv::etcd_watch_start_core(&state.app, &req.connection_id, req.request)
.await
.map_err(AppError::from)?,
))
}
pub async fn watch_poll(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdWatchRequest>,
) -> Result<Json<dbx_core::agent_kv::EtcdWatchPollResponse>, AppError> {
Ok(Json(
dbx_core::agent_kv::etcd_watch_poll_core(&state.app, &req.connection_id, &req.watch_id)
.await
.map_err(AppError::from)?,
))
}
pub async fn watch_stop(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdWatchRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(
dbx_core::agent_kv::etcd_watch_stop_core(&state.app, &req.connection_id, &req.watch_id)
.await
.map_err(AppError::from)?,
))
}
pub async fn lease_list(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdLeaseListRequest>,
) -> Result<Json<dbx_core::agent_kv::EtcdLeaseListResponse>, AppError> {
Ok(Json(
dbx_core::agent_kv::etcd_lease_list_core(
&state.app,
&req.connection_id,
req.limit.unwrap_or(100),
req.continuation.as_deref(),
)
.await
.map_err(AppError::from)?,
))
}
pub async fn lease_call(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdOperationRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let (method, write) = match req.operation.as_str() {
"get" => (AgentKvMethod::LeaseGet, false),
"grant" => (AgentKvMethod::LeaseGrant, true),
"keepalive" => (AgentKvMethod::LeaseKeepalive, true),
"revoke" => (AgentKvMethod::LeaseRevoke, true),
_ => return Err(AppError::from("Unsupported etcd lease operation".to_string())),
};
if write {
ensure_writable(&state.app, &req.connection_id, "Lease operation").await?;
}
if req.operation == "revoke" {
dbx_core::agent_kv::etcd_consume_preflight_core(
&state.app,
&req.connection_id,
"lease_revoke",
&req.params,
req.preflight_token
.as_deref()
.ok_or_else(|| AppError::from("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation"))?,
req.confirmation_text
.as_deref()
.ok_or_else(|| AppError::from("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation"))?,
)
.await
.map_err(AppError::from)?;
}
Ok(Json(
dbx_core::agent_kv::etcd_lease_call_core(&state.app, &req.connection_id, method, req.params)
.await
.map_err(AppError::from)?,
))
}
pub async fn auth_call(
State(state): State<Arc<WebState>>,
Json(req): Json<EtcdOperationRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let (method, write) = match req.operation.as_str() {
"user_list" => (AgentKvMethod::AuthUserList, false),
"user_get" => (AgentKvMethod::AuthUserGet, false),
"user_add" => (AgentKvMethod::AuthUserAdd, true),
"user_delete" => (AgentKvMethod::AuthUserDelete, true),
"user_change_password" => (AgentKvMethod::AuthUserChangePassword, true),
"user_grant_role" => (AgentKvMethod::AuthUserGrantRole, true),
"user_revoke_role" => (AgentKvMethod::AuthUserRevokeRole, true),
"role_list" => (AgentKvMethod::AuthRoleList, false),
"role_get" => (AgentKvMethod::AuthRoleGet, false),
"role_add" => (AgentKvMethod::AuthRoleAdd, true),
"role_delete" => (AgentKvMethod::AuthRoleDelete, true),
"role_grant_permission" => (AgentKvMethod::AuthRoleGrantPermission, true),
"role_revoke_permission" => (AgentKvMethod::AuthRoleRevokePermission, true),
_ => return Err(AppError::from("Unsupported etcd Auth operation".to_string())),
};
if write {
ensure_writable(&state.app, &req.connection_id, "Auth operation").await?;
}
if let Some(action) = auth_preflight_action(&req.operation) {
dbx_core::agent_kv::etcd_consume_preflight_core(
&state.app,
&req.connection_id,
action,
&req.params,
req.preflight_token
.as_deref()
.ok_or_else(|| AppError::from("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation"))?,
req.confirmation_text
.as_deref()
.ok_or_else(|| AppError::from("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation"))?,
)
.await
.map_err(AppError::from)?;
}
Ok(Json(
dbx_core::agent_kv::etcd_auth_call_core(&state.app, &req.connection_id, method, req.params)
.await
.map_err(AppError::from)?,
))
}
fn auth_preflight_action(operation: &str) -> Option<&'static str> {
match operation {
"user_add" => Some("auth_user_add"),
"user_delete" => Some("auth_user_delete"),
"user_change_password" => Some("auth_user_change_password"),
"user_grant_role" => Some("auth_user_grant_role"),
"user_revoke_role" => Some("auth_user_revoke_role"),
"role_add" => Some("auth_role_add"),
"role_delete" => Some("auth_role_delete"),
"role_grant_permission" => Some("auth_role_grant_permission"),
"role_revoke_permission" => Some("auth_role_revoke_permission"),
_ => None,
}
}

View File

@ -0,0 +1,84 @@
{
"format": "dbx-etcd-bundle",
"version": 1,
"exportedAt": "2026-07-28T00:00:00.000Z",
"prefix": "",
"scopeKind": "prefix",
"entries": [
{
"key": { "encoding": "utf8", "data": "test/app/config" },
"value": { "encoding": "utf8", "data": "{\"app\":\"orders\",\"environment\":\"test\",\"debug\":true,\"logLevel\":\"debug\"}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "test/app/feature-flags" },
"value": { "encoding": "utf8", "data": "{\"newCheckout\":true,\"searchV2\":true,\"betaBanner\":true}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "test/app/version" },
"value": { "encoding": "utf8", "data": "2026.07.28-test" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "test/database/primary" },
"value": { "encoding": "utf8", "data": "postgresql://test-user:test-password@postgres.test.local:5432/orders" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "test/redis/endpoint" },
"value": { "encoding": "utf8", "data": "redis://redis.test.local:6379/0" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "pre/app/config" },
"value": { "encoding": "utf8", "data": "{\"app\":\"orders\",\"environment\":\"pre\",\"debug\":false,\"logLevel\":\"info\"}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "pre/app/feature-flags" },
"value": { "encoding": "utf8", "data": "{\"newCheckout\":true,\"searchV2\":false,\"betaBanner\":false}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "pre/app/version" },
"value": { "encoding": "utf8", "data": "2026.07.28-rc.1" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "pre/database/primary" },
"value": { "encoding": "utf8", "data": "postgresql://orders-pre@postgres.pre.local:5432/orders" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "pre/redis/endpoint" },
"value": { "encoding": "utf8", "data": "redis://redis.pre.local:6379/0" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "prod/app/config" },
"value": { "encoding": "utf8", "data": "{\"app\":\"orders\",\"environment\":\"prod\",\"debug\":false,\"logLevel\":\"warn\"}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "prod/app/feature-flags" },
"value": { "encoding": "utf8", "data": "{\"newCheckout\":false,\"searchV2\":false,\"betaBanner\":false}" },
"formatHint": "json"
},
{
"key": { "encoding": "utf8", "data": "prod/app/version" },
"value": { "encoding": "utf8", "data": "2026.07.15" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "prod/database/primary" },
"value": { "encoding": "utf8", "data": "postgresql://orders-prod@postgres.prod.local:5432/orders" },
"formatHint": "text"
},
{
"key": { "encoding": "utf8", "data": "prod/redis/endpoint" },
"value": { "encoding": "utf8", "data": "redis://redis.prod.local:6379/0" },
"formatHint": "text"
}
]
}

View File

@ -3,10 +3,12 @@ use tauri::State;
use crate::commands::connection::{ensure_connection_writable, AppState};
use dbx_core::agent_kv::{
KvDeleteOptions, KvDeleteResponse, KvGetOptions, KvGetResponse, KvHistoryRequest, KvHistoryResponse, KvInt64,
KvListPrefixResponse, KvPutOptions, KvPutResponse, KvRangeOptions, KvRenameRequest, KvRenameResponse,
KvStatusResponse, KvValue,
EtcdDefragResponse, EtcdLeaseListResponse, EtcdPreflightRequest, EtcdPreflightResponse, EtcdWatchPollResponse,
EtcdWatchStartRequest, EtcdWatchStartResponse, KvDeleteOptions, KvDeleteResponse, KvGetOptions, KvGetResponse,
KvHistoryRequest, KvHistoryResponse, KvInt64, KvListPrefixResponse, KvPutOptions, KvPutResponse, KvRangeOptions,
KvRenameRequest, KvRenameResponse, KvStatusResponse, KvValue,
};
use dbx_core::db::agent_driver::AgentKvMethod;
#[tauri::command]
pub async fn etcd_list_prefix(
@ -125,3 +127,185 @@ pub async fn etcd_history(
pub async fn etcd_status(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<KvStatusResponse, String> {
dbx_core::agent_kv::kv_status_core(&state, &connection_id).await
}
#[tauri::command]
pub async fn etcd_preflight(
state: State<'_, Arc<AppState>>,
connection_id: String,
request: EtcdPreflightRequest,
) -> Result<EtcdPreflightResponse, String> {
ensure_connection_writable(&state, &connection_id, "Dangerous etcd operation").await?;
dbx_core::agent_kv::etcd_preflight_core(&state, &connection_id, request).await
}
#[tauri::command]
pub async fn etcd_compact(
state: State<'_, Arc<AppState>>,
connection_id: String,
revision: KvInt64,
preflight_token: String,
confirmation_text: String,
) -> Result<serde_json::Value, String> {
ensure_connection_writable(&state, &connection_id, "Compact").await?;
let params = serde_json::json!({ "revision": revision.clone() });
dbx_core::agent_kv::etcd_consume_preflight_core(
&state,
&connection_id,
"compact",
&params,
&preflight_token,
&confirmation_text,
)
.await?;
dbx_core::agent_kv::etcd_compact_core(&state, &connection_id, revision).await
}
#[tauri::command]
pub async fn etcd_defrag(
state: State<'_, Arc<AppState>>,
connection_id: String,
endpoints: Vec<String>,
preflight_token: String,
confirmation_text: String,
) -> Result<EtcdDefragResponse, String> {
ensure_connection_writable(&state, &connection_id, "Defrag").await?;
let params = serde_json::json!({ "endpoints": endpoints.clone() });
dbx_core::agent_kv::etcd_consume_preflight_core(
&state,
&connection_id,
"defrag",
&params,
&preflight_token,
&confirmation_text,
)
.await?;
dbx_core::agent_kv::etcd_defrag_core(&state, &connection_id, endpoints).await
}
#[tauri::command]
pub async fn etcd_watch_start(
state: State<'_, Arc<AppState>>,
connection_id: String,
request: EtcdWatchStartRequest,
) -> Result<EtcdWatchStartResponse, String> {
dbx_core::agent_kv::etcd_watch_start_core(&state, &connection_id, request).await
}
#[tauri::command]
pub async fn etcd_watch_poll(
state: State<'_, Arc<AppState>>,
connection_id: String,
watch_id: String,
) -> Result<EtcdWatchPollResponse, String> {
dbx_core::agent_kv::etcd_watch_poll_core(&state, &connection_id, &watch_id).await
}
#[tauri::command]
pub async fn etcd_watch_stop(
state: State<'_, Arc<AppState>>,
connection_id: String,
watch_id: String,
) -> Result<serde_json::Value, String> {
dbx_core::agent_kv::etcd_watch_stop_core(&state, &connection_id, &watch_id).await
}
#[tauri::command]
pub async fn etcd_lease_list(
state: State<'_, Arc<AppState>>,
connection_id: String,
limit: Option<usize>,
continuation: Option<String>,
) -> Result<EtcdLeaseListResponse, String> {
dbx_core::agent_kv::etcd_lease_list_core(&state, &connection_id, limit.unwrap_or(100), continuation.as_deref())
.await
}
#[tauri::command]
pub async fn etcd_lease_call(
state: State<'_, Arc<AppState>>,
connection_id: String,
operation: String,
params: serde_json::Value,
preflight_token: Option<String>,
confirmation_text: Option<String>,
) -> Result<serde_json::Value, String> {
let (method, write) = match operation.as_str() {
"get" => (AgentKvMethod::LeaseGet, false),
"grant" => (AgentKvMethod::LeaseGrant, true),
"keepalive" => (AgentKvMethod::LeaseKeepalive, true),
"revoke" => (AgentKvMethod::LeaseRevoke, true),
_ => return Err("Unsupported etcd lease operation".to_string()),
};
if write {
ensure_connection_writable(&state, &connection_id, "Lease operation").await?;
}
if operation == "revoke" {
dbx_core::agent_kv::etcd_consume_preflight_core(
&state,
&connection_id,
"lease_revoke",
&params,
preflight_token.as_deref().ok_or("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation")?,
confirmation_text.as_deref().ok_or("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation")?,
)
.await?;
}
dbx_core::agent_kv::etcd_lease_call_core(&state, &connection_id, method, params).await
}
#[tauri::command]
pub async fn etcd_auth_call(
state: State<'_, Arc<AppState>>,
connection_id: String,
operation: String,
params: serde_json::Value,
preflight_token: Option<String>,
confirmation_text: Option<String>,
) -> Result<serde_json::Value, String> {
let (method, write) = match operation.as_str() {
"user_list" => (AgentKvMethod::AuthUserList, false),
"user_get" => (AgentKvMethod::AuthUserGet, false),
"user_add" => (AgentKvMethod::AuthUserAdd, true),
"user_delete" => (AgentKvMethod::AuthUserDelete, true),
"user_change_password" => (AgentKvMethod::AuthUserChangePassword, true),
"user_grant_role" => (AgentKvMethod::AuthUserGrantRole, true),
"user_revoke_role" => (AgentKvMethod::AuthUserRevokeRole, true),
"role_list" => (AgentKvMethod::AuthRoleList, false),
"role_get" => (AgentKvMethod::AuthRoleGet, false),
"role_add" => (AgentKvMethod::AuthRoleAdd, true),
"role_delete" => (AgentKvMethod::AuthRoleDelete, true),
"role_grant_permission" => (AgentKvMethod::AuthRoleGrantPermission, true),
"role_revoke_permission" => (AgentKvMethod::AuthRoleRevokePermission, true),
_ => return Err("Unsupported etcd Auth operation".to_string()),
};
if write {
ensure_connection_writable(&state, &connection_id, "Auth operation").await?;
}
if let Some(action) = auth_preflight_action(&operation) {
dbx_core::agent_kv::etcd_consume_preflight_core(
&state,
&connection_id,
action,
&params,
preflight_token.as_deref().ok_or("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation")?,
confirmation_text.as_deref().ok_or("ETCD_PREFLIGHT_REQUIRED: Request a preflight confirmation")?,
)
.await?;
}
dbx_core::agent_kv::etcd_auth_call_core(&state, &connection_id, method, params).await
}
fn auth_preflight_action(operation: &str) -> Option<&'static str> {
match operation {
"user_add" => Some("auth_user_add"),
"user_delete" => Some("auth_user_delete"),
"user_change_password" => Some("auth_user_change_password"),
"user_grant_role" => Some("auth_user_grant_role"),
"user_revoke_role" => Some("auth_user_revoke_role"),
"role_add" => Some("auth_role_add"),
"role_delete" => Some("auth_role_delete"),
"role_grant_permission" => Some("auth_role_grant_permission"),
"role_revoke_permission" => Some("auth_role_revoke_permission"),
_ => None,
}
}

View File

@ -1801,6 +1801,15 @@ pub fn run() {
commands::etcd_cmd::etcd_rename,
commands::etcd_cmd::etcd_history,
commands::etcd_cmd::etcd_status,
commands::etcd_cmd::etcd_preflight,
commands::etcd_cmd::etcd_compact,
commands::etcd_cmd::etcd_defrag,
commands::etcd_cmd::etcd_watch_start,
commands::etcd_cmd::etcd_watch_poll,
commands::etcd_cmd::etcd_watch_stop,
commands::etcd_cmd::etcd_lease_list,
commands::etcd_cmd::etcd_lease_call,
commands::etcd_cmd::etcd_auth_call,
commands::zookeeper_cmd::zookeeper_list_prefix,
commands::zookeeper_cmd::zookeeper_get,
commands::zookeeper_cmd::zookeeper_put,